diff --git a/Cargo.toml b/Cargo.toml index db0d1af..73eae56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,8 @@ sha2 = "0.11" [dev-dependencies] # Isolated temp dirs for the .hydrate workdir-state tests. tempfile = "3" +# Building canned JSON bodies for the scoped-request integration test. +serde_json = "1" [lib] name = "hydrate" diff --git a/openapi.json b/openapi.json index a1ce419..0fb1da4 100644 --- a/openapi.json +++ b/openapi.json @@ -1 +1 @@ -{"components": {"responses": {"Forbidden": {"content": {"application/json": {"schema": {"properties": {"detail": {"properties": {"code": {"type": "string"}, "message": {"type": "string"}}, "type": "object"}}, "type": "object"}}}, "description": "Credentials are valid but lack the scope required for this route, or the principal lacks membership in the target project."}, "NotFound": {"content": {"application/json": {"schema": {"properties": {"detail": {"properties": {"code": {"example": "not_found", "type": "string"}, "message": {"type": "string"}}, "type": "object"}}, "type": "object"}}}, "description": "Resource not found OR not accessible to this principal. Leak parity: the response is identical in both cases so an attacker cannot enumerate resources via 404-vs-403 timing."}, "RateLimited": {"content": {"application/json": {"schema": {"properties": {"detail": {"example": "rate_limited", "type": "string"}}, "type": "object"}}}, "description": "Per-bucket rate limit exceeded. The response carries ``Retry-After`` and the standard ``X-RateLimit-*`` headers (Limit / Remaining / Reset).", "headers": {"Retry-After": {"schema": {"type": "integer"}}, "X-RateLimit-Limit": {"schema": {"type": "integer"}}, "X-RateLimit-Remaining": {"schema": {"type": "integer"}}, "X-RateLimit-Reset": {"schema": {"type": "integer"}}}}, "Unauthenticated": {"content": {"application/json": {"schema": {"properties": {"detail": {"example": "unauthenticated", "type": "string"}}, "required": ["detail"], "type": "object"}}}, "description": "No credentials, malformed credentials, or revoked credentials. The envelope is leak-parity (same shape across all 401 paths) so an attacker cannot distinguish revoked vs. unknown via the body."}, "Unprocessable": {"content": {"application/json": {"schema": {"properties": {"detail": {"oneOf": [{"type": "string"}, {"properties": {"delta_ordinal": {"type": "integer"}, "delta_type": {"type": ["string", "null"]}, "error": {"type": "string"}, "field": {"type": ["string", "null"]}, "reason": {"type": ["string", "null"]}, "recoverable": {"type": "boolean"}, "recovery_hint": {"type": ["string", "null"]}}, "type": "object"}]}}, "type": "object"}}}, "description": "Request body failed validation. For delta routes the response carries ``error``, ``delta_ordinal``, ``delta_type``, ``field``, ``reason``, ``recoverable``, and ``recovery_hint`` so a client can auto-recover or surface a typed error."}, "VersionConflict": {"content": {"application/json": {"schema": {"properties": {"detail": {"properties": {"current_version": {"type": "integer"}, "error": {"example": "version_conflict", "type": "string"}}, "type": "object"}}, "type": "object"}}}, "description": "Optimistic concurrency check failed \u2014 the branch's current version is not the one the client sent in ``expected_version``. The response carries ``current_version`` so the client can refetch + retry."}}, "schemas": {"AddEdgeDelta": {"description": "Insert an edge.", "properties": {"edge": {"$ref": "#/components/schemas/Edge"}, "type": {"const": "add_edge", "title": "Type", "type": "string"}}, "required": ["type", "edge"], "title": "AddEdgeDelta", "type": "object"}, "AddNodeDelta": {"description": "Insert a node.", "properties": {"node": {"$ref": "#/components/schemas/Node"}, "type": {"const": "add_node", "title": "Type", "type": "string"}}, "required": ["type", "node"], "title": "AddNodeDelta", "type": "object"}, "BoundaryResponse": {"additionalProperties": false, "properties": {"boundary": {"$ref": "#/components/schemas/WireNode"}, "branch": {"$ref": "#/components/schemas/BranchRef"}, "children": {"items": {"$ref": "#/components/schemas/WireNode"}, "title": "Children", "type": "array"}, "edges": {"items": {"$ref": "#/components/schemas/WireEdge"}, "title": "Edges", "type": "array"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branch", "boundary", "children", "edges"], "title": "BoundaryResponse", "type": "object"}, "BranchCreateResponse": {"additionalProperties": false, "properties": {"branch": {"$ref": "#/components/schemas/BranchMeta"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branch"], "title": "BranchCreateResponse", "type": "object"}, "BranchListResponse": {"additionalProperties": false, "properties": {"branches": {"items": {"$ref": "#/components/schemas/BranchMeta"}, "title": "Branches", "type": "array"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branches"], "title": "BranchListResponse", "type": "object"}, "BranchMeta": {"additionalProperties": false, "properties": {"base_main_version": {"title": "Base Main Version", "type": "integer"}, "created_at": {"title": "Created At", "type": "string"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "is_main": {"title": "Is Main", "type": "boolean"}, "last_active_at": {"title": "Last Active At", "type": "string"}, "merged_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Merged At"}, "name": {"title": "Name", "type": "string"}, "owner_id": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "title": "Owner Id"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "status": {"title": "Status", "type": "string"}, "version": {"title": "Version", "type": "integer"}}, "required": ["id", "project_id", "owner_id", "name", "is_main", "version", "base_main_version", "status", "last_active_at", "created_at", "merged_at"], "title": "BranchMeta", "type": "object"}, "BranchNotActiveBody": {"additionalProperties": false, "properties": {"current_status": {"title": "Current Status", "type": "string"}, "error": {"const": "branch_not_active", "title": "Error", "type": "string"}}, "required": ["error", "current_status"], "title": "BranchNotActiveBody", "type": "object"}, "BranchRef": {"additionalProperties": false, "properties": {"id": {"format": "uuid", "title": "Id", "type": "string"}, "version": {"title": "Version", "type": "integer"}}, "required": ["id", "version"], "title": "BranchRef", "type": "object"}, "DeleteEdgeDelta": {"description": "Remove an edge.", "properties": {"edge_id": {"format": "uuid", "title": "Edge Id", "type": "string"}, "type": {"const": "delete_edge", "title": "Type", "type": "string"}}, "required": ["type", "edge_id"], "title": "DeleteEdgeDelta", "type": "object"}, "DeleteNodeDelta": {"description": "Cascade-delete a node and its descendant subtree.", "properties": {"node_id": {"format": "uuid", "title": "Node Id", "type": "string"}, "type": {"const": "delete_node", "title": "Type", "type": "string"}}, "required": ["type", "node_id"], "title": "DeleteNodeDelta", "type": "object"}, "DeltaApplyResponse": {"additionalProperties": false, "properties": {"applied": {"title": "Applied", "type": "boolean"}, "branch": {"$ref": "#/components/schemas/BranchRef"}, "delta_count": {"title": "Delta Count", "type": "integer"}, "positions_applied": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "title": "Positions Applied"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branch", "applied", "delta_count"], "title": "DeltaApplyResponse", "type": "object"}, "DeltaFieldErrorBody": {"additionalProperties": false, "description": "The 7-key per-delta envelope (always all keys present) the frontend\nauto-recovery consumes. Shared by every 422 delta error; `error`\ndiscriminates the cause.", "properties": {"delta_ordinal": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Delta Ordinal"}, "delta_type": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Delta Type"}, "error": {"enum": ["malformed_delta_field", "unknown_delta_type", "delta_not_object", "delta_too_large", "batch_too_large", "batch_count_exceeded"], "title": "Error", "type": "string"}, "field": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Field"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}, "recoverable": {"title": "Recoverable", "type": "boolean"}, "recovery_hint": {"anyOf": [{"$ref": "#/components/schemas/RecoveryHint"}, {"type": "null"}]}}, "required": ["error", "delta_ordinal", "delta_type", "field", "reason", "recoverable", "recovery_hint"], "title": "DeltaFieldErrorBody", "type": "object"}, "Edge": {"description": "An edge connecting two nodes' ports.\n\nEndpoints are given as `source_handle` / `target_handle`, each a port\nid. Unknown keys are ignored rather than rejected.", "properties": {"id": {"format": "uuid", "title": "Id", "type": "string"}, "source_handle": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "default": null, "title": "Source Handle"}, "target_handle": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "default": null, "title": "Target Handle"}}, "required": ["id"], "title": "Edge", "type": "object"}, "Finding": {"additionalProperties": false, "description": "A single coherence finding over the graph that results from a delta\nbatch. All three v1 ``code``s are ``error``-severity; ``severity`` keeps the\n``warning`` arm in the contract for future advisory codes. ``locator`` is the\nnode / port / edge id (a string) the finding is about.", "properties": {"code": {"enum": ["unsatisfied_input", "dangling_wire", "type_mismatch"], "title": "Code", "type": "string"}, "locator": {"title": "Locator", "type": "string"}, "message": {"title": "Message", "type": "string"}, "severity": {"enum": ["error", "warning"], "title": "Severity", "type": "string"}}, "required": ["code", "severity", "locator", "message"], "title": "Finding", "type": "object"}, "FlattenBoundaryDelta": {"description": "Flatten a boundary node: delete it and promote its children to its parent.", "properties": {"node_id": {"format": "uuid", "title": "Node Id", "type": "string"}, "type": {"const": "flatten_boundary", "title": "Type", "type": "string"}}, "required": ["type", "node_id"], "title": "FlattenBoundaryDelta", "type": "object"}, "GraphResponse": {"additionalProperties": false, "properties": {"branch": {"$ref": "#/components/schemas/BranchRef"}, "edges": {"items": {"$ref": "#/components/schemas/WireEdge"}, "title": "Edges", "type": "array"}, "nodes": {"items": {"$ref": "#/components/schemas/WireNode"}, "title": "Nodes", "type": "array"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branch", "nodes", "edges"], "title": "GraphResponse", "type": "object"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "title": "Detail", "type": "array"}}, "title": "HTTPValidationError", "type": "object"}, "HealthzResponse": {"additionalProperties": false, "properties": {"ok": {"title": "Ok", "type": "boolean"}}, "required": ["ok"], "title": "HealthzResponse", "type": "object"}, "Node": {"description": "A behavior or boundary node.", "properties": {"data": {"$ref": "#/components/schemas/NodeData"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "kind": {"enum": ["behavior", "boundary", "state", "io", "interface"], "title": "Kind", "type": "string"}, "parent_id": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "default": null, "title": "Parent Id"}}, "required": ["id", "kind"], "title": "Node", "type": "object"}, "NodeData": {"description": "A node's data payload; its shape varies by kind and whether the node is\nexternal.\n\nOn update, key-presence semantics apply: a field present with a null value is\nset to null, while an omitted field is left unchanged. All fields are optional\nat the type level \u2014 cross-field rules (e.g. behavior nodes reject `user_kind`;\nexternal nodes require `external_kind`) are enforced server-side, so a partial\nupdate is not blocked by \"missing required field\" errors that don't apply to it.", "properties": {"aliases": {"items": {"maxLength": 500, "type": "string"}, "maxItems": 50, "title": "Aliases", "type": "array"}, "config": {"items": {"$ref": "#/components/schemas/Port"}, "title": "Config", "type": "array"}, "constraints": {"items": {"type": "string"}, "title": "Constraints", "type": "array"}, "description": {"default": "", "title": "Description", "type": "string"}, "documentation_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Documentation Url"}, "external_kind": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "External Kind"}, "inputs": {"items": {"$ref": "#/components/schemas/Port"}, "title": "Inputs", "type": "array"}, "is_external": {"default": false, "title": "Is External", "type": "boolean"}, "is_test_node": {"default": false, "title": "Is Test Node", "type": "boolean"}, "kind": {"anyOf": [{"enum": ["behavior", "boundary", "state", "io", "interface"], "type": "string"}, {"type": "null"}], "default": null, "title": "Kind"}, "language": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Language"}, "name": {"default": "", "title": "Name", "type": "string"}, "outputs": {"items": {"$ref": "#/components/schemas/Port"}, "title": "Outputs", "type": "array"}, "parent_id": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "default": null, "title": "Parent Id"}, "path_prefix": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Path Prefix"}, "protocol": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Protocol"}, "source_decisions": {"items": {"format": "uuid", "type": "string"}, "title": "Source Decisions", "type": "array"}, "status": {"default": "idle", "title": "Status", "type": "string"}, "user_kind": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "User Kind"}, "verifications": {"items": {"$ref": "#/components/schemas/Verification"}, "title": "Verifications", "type": "array"}}, "title": "NodeData", "type": "object"}, "NodeNeighborhoodResponse": {"additionalProperties": false, "properties": {"branch": {"$ref": "#/components/schemas/BranchRef"}, "edges": {"items": {"$ref": "#/components/schemas/WireEdge"}, "title": "Edges", "type": "array"}, "neighbors": {"items": {"$ref": "#/components/schemas/WireNode"}, "title": "Neighbors", "type": "array"}, "node": {"$ref": "#/components/schemas/WireNode"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branch", "node", "neighbors", "edges"], "title": "NodeNeighborhoodResponse", "type": "object"}, "NotFoundBody": {"additionalProperties": false, "description": "Returned when a delta targets a node that doesn't exist (404). `node_id`\nidentifies the missing target.", "properties": {"error": {"const": "not_found", "title": "Error", "type": "string"}, "node_id": {"title": "Node Id", "type": "string"}, "reason": {"title": "Reason", "type": "string"}}, "required": ["error", "node_id", "reason"], "title": "NotFoundBody", "type": "object"}, "Port": {"description": "An input, output, or config port on a node.", "properties": {"contract_name": {"anyOf": [{"maxLength": 500, "type": "string"}, {"type": "null"}], "default": null, "title": "Contract Name"}, "description": {"default": "", "title": "Description", "type": "string"}, "external": {"default": false, "title": "External", "type": "boolean"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "name": {"default": "", "title": "Name", "type": "string"}, "type": {"default": "", "title": "Type", "type": "string"}}, "required": ["id"], "title": "Port", "type": "object"}, "Position": {"additionalProperties": false, "properties": {"x": {"title": "X", "type": "number"}, "y": {"title": "Y", "type": "number"}}, "required": ["x", "y"], "title": "Position", "type": "object"}, "ProjectOut": {"additionalProperties": false, "properties": {"archived": {"title": "Archived", "type": "boolean"}, "created_at": {"title": "Created At", "type": "string"}, "h2o_schema_version": {"title": "H2O Schema Version", "type": "integer"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "intent": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Intent"}, "language": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Language"}, "last_opened_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Last Opened At"}, "name": {"title": "Name", "type": "string"}, "updated_at": {"title": "Updated At", "type": "string"}}, "required": ["id", "name", "language", "intent", "archived", "h2o_schema_version", "created_at", "updated_at", "last_opened_at"], "title": "ProjectOut", "type": "object"}, "ProjectsListResponse": {"additionalProperties": false, "properties": {"projects": {"items": {"$ref": "#/components/schemas/ProjectOut"}, "title": "Projects", "type": "array"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "projects"], "title": "ProjectsListResponse", "type": "object"}, "RecoveryHint": {"description": "The finite set of recovery hints a client may receive in\n`ApiError.recovery_hint`:\n\n - \"regenerate_uuid\" \u2014 mint a new UUID at `field` and retry\n - \"drop_field\" \u2014 delete `field` and retry\n - null \u2014 not auto-recoverable; user intervention needed\n\nA closed set, so integrations can switch over the value; a new hint is added\nhere deliberately, giving clients a chance to handle it.", "enum": ["regenerate_uuid", "drop_field"], "title": "RecoveryHint", "type": "string"}, "ReparentNodeDelta": {"description": "Move a node to a new parent (or to top-level).\n\n`parent_id` must be present on the wire (use null for top-level); an absent key\nis rejected, so a client bug surfaces as a clean parse error rather than an\nambiguous no-op.", "properties": {"node_id": {"format": "uuid", "title": "Node Id", "type": "string"}, "parent_id": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "title": "Parent Id"}, "type": {"const": "reparent_node", "title": "Type", "type": "string"}}, "required": ["type", "node_id", "parent_id"], "title": "ReparentNodeDelta", "type": "object"}, "UpdateNodeDataDelta": {"description": "Update a node's data, with key-presence partial semantics.\n\n`after` may be empty; whether a field is present in `after` distinguishes\n\"set this field\" from \"leave it unchanged\".", "properties": {"after": {"$ref": "#/components/schemas/NodeData"}, "node_id": {"format": "uuid", "title": "Node Id", "type": "string"}, "type": {"const": "update_node_data", "title": "Type", "type": "string"}}, "required": ["type", "node_id", "after"], "title": "UpdateNodeDataDelta", "type": "object"}, "V1CreateBranchBody": {"description": "Body for ``POST /v1/projects/{pid}/branches``. ``name`` defaults to the\nnext ``main-work[-N]`` for the caller, so a bodyless POST works.", "properties": {"name": {"anyOf": [{"maxLength": 200, "minLength": 1, "type": "string"}, {"type": "null"}], "title": "Name"}}, "title": "V1CreateBranchBody", "type": "object"}, "V1DeltasBody": {"description": "Request body for ``POST /v1/branches/{bid}/deltas``.", "properties": {"deltas": {"description": "Ordered list of delta operations to apply. Each must carry a `type` field in the closed write-surface vocabulary (add_node / delete_node / update_node_data / reparent_node / add_edge / delete_edge / flatten_boundary). Empty list returns 200 with `applied: false` and no version bump.", "items": {"discriminator": {"mapping": {"add_edge": "#/components/schemas/AddEdgeDelta", "add_node": "#/components/schemas/AddNodeDelta", "delete_edge": "#/components/schemas/DeleteEdgeDelta", "delete_node": "#/components/schemas/DeleteNodeDelta", "flatten_boundary": "#/components/schemas/FlattenBoundaryDelta", "reparent_node": "#/components/schemas/ReparentNodeDelta", "update_node_data": "#/components/schemas/UpdateNodeDataDelta"}, "propertyName": "type"}, "oneOf": [{"$ref": "#/components/schemas/AddNodeDelta"}, {"$ref": "#/components/schemas/DeleteNodeDelta"}, {"$ref": "#/components/schemas/UpdateNodeDataDelta"}, {"$ref": "#/components/schemas/ReparentNodeDelta"}, {"$ref": "#/components/schemas/AddEdgeDelta"}, {"$ref": "#/components/schemas/DeleteEdgeDelta"}, {"$ref": "#/components/schemas/FlattenBoundaryDelta"}]}, "title": "Deltas", "type": "array"}, "expected_version": {"description": "The branch.version the client believes is current. If it doesn't match the server's view at commit time, the route 409s with the actual current_version in the detail.", "minimum": 0.0, "title": "Expected Version", "type": "integer"}, "positions": {"anyOf": [{"additionalProperties": {"$ref": "#/components/schemas/V1NodePosition"}, "type": "object"}, {"type": "null"}], "description": "Optional explicit cell pins as `node_id -> {x, y}`, applied to the branch owner's view AFTER the deltas. **Additive**: a pin is introduced only for a node the owner hasn't already placed \u2014 it never overwrites a hand-placement and never deletes. Use only to override the derived grid placement for nodes you create.", "title": "Positions"}}, "required": ["expected_version"], "title": "V1DeltasBody", "type": "object"}, "V1NodePosition": {"description": "A single explicit pin coordinate on the `/v1/` write surface.", "properties": {"x": {"title": "X", "type": "number"}, "y": {"title": "Y", "type": "number"}}, "required": ["x", "y"], "title": "V1NodePosition", "type": "object"}, "V1ValidateBody": {"description": "Request body for ``POST /v1/branches/{bid}/validate``.", "properties": {"deltas": {"items": {"discriminator": {"mapping": {"add_edge": "#/components/schemas/AddEdgeDelta", "add_node": "#/components/schemas/AddNodeDelta", "delete_edge": "#/components/schemas/DeleteEdgeDelta", "delete_node": "#/components/schemas/DeleteNodeDelta", "flatten_boundary": "#/components/schemas/FlattenBoundaryDelta", "reparent_node": "#/components/schemas/ReparentNodeDelta", "update_node_data": "#/components/schemas/UpdateNodeDataDelta"}, "propertyName": "type"}, "oneOf": [{"$ref": "#/components/schemas/AddNodeDelta"}, {"$ref": "#/components/schemas/DeleteNodeDelta"}, {"$ref": "#/components/schemas/UpdateNodeDataDelta"}, {"$ref": "#/components/schemas/ReparentNodeDelta"}, {"$ref": "#/components/schemas/AddEdgeDelta"}, {"$ref": "#/components/schemas/DeleteEdgeDelta"}, {"$ref": "#/components/schemas/FlattenBoundaryDelta"}]}, "title": "Deltas", "type": "array"}}, "title": "V1ValidateBody", "type": "object"}, "ValidateResponse": {"additionalProperties": false, "properties": {"branch": {"$ref": "#/components/schemas/BranchRef"}, "findings": {"items": {"$ref": "#/components/schemas/Finding"}, "title": "Findings", "type": "array"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "valid": {"title": "Valid", "type": "boolean"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branch", "valid"], "title": "ValidateResponse", "type": "object"}, "ValidationError": {"properties": {"ctx": {"title": "Context", "type": "object"}, "input": {"title": "Input"}, "loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "title": "Location", "type": "array"}, "msg": {"title": "Message", "type": "string"}, "type": {"title": "Error Type", "type": "string"}}, "required": ["loc", "msg", "type"], "title": "ValidationError", "type": "object"}, "Verification": {"description": "A test specification attached to a behavior node.", "properties": {"author": {"enum": ["user", "agent"], "title": "Author", "type": "string"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "text": {"title": "Text", "type": "string"}, "type": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Type"}}, "required": ["id", "author", "text"], "title": "Verification", "type": "object"}, "VersionConflictBody": {"additionalProperties": false, "properties": {"current_version": {"title": "Current Version", "type": "integer"}, "error": {"const": "version_conflict", "title": "Error", "type": "string"}}, "required": ["error", "current_version"], "title": "VersionConflictBody", "type": "object"}, "WireEdge": {"additionalProperties": false, "description": "Read-path edge: `source`/`target` are server-derived NODE ids\n(absent from the client-authored `h2o.types.Edge`); `*_handle` are the\nPORT ids.", "properties": {"id": {"format": "uuid", "title": "Id", "type": "string"}, "source": {"format": "uuid", "title": "Source", "type": "string"}, "source_handle": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "title": "Source Handle"}, "target": {"format": "uuid", "title": "Target", "type": "string"}, "target_handle": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "title": "Target Handle"}}, "required": ["id", "source", "target", "source_handle", "target_handle"], "title": "WireEdge", "type": "object"}, "WireNode": {"additionalProperties": false, "properties": {"data": {"$ref": "#/components/schemas/WireNodeData"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "kind": {"enum": ["behavior", "boundary", "state", "io", "interface"], "title": "Kind", "type": "string"}, "parent_id": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "title": "Parent Id"}, "position": {"$ref": "#/components/schemas/Position"}}, "required": ["id", "kind", "parent_id", "position", "data"], "title": "WireNode", "type": "object"}, "WireNodeData": {"additionalProperties": false, "description": "Read-path node `data`. NOT `h2o.NodeData`: the response omits\n`kind`/`parent_id` (those live on the wrapper) and emits the boundary/\nexternal scalars matrix-conditionally \u2014 so the matrix fields are\noptional (absent = not-applicable-to-kind).", "properties": {"config": {"items": {"$ref": "#/components/schemas/WirePort"}, "title": "Config", "type": "array"}, "constraints": {"items": {"type": "string"}, "title": "Constraints", "type": "array"}, "description": {"title": "Description", "type": "string"}, "documentation_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Documentation Url"}, "external_kind": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "External Kind"}, "inputs": {"items": {"$ref": "#/components/schemas/WirePort"}, "title": "Inputs", "type": "array"}, "is_external": {"title": "Is External", "type": "boolean"}, "is_test_node": {"title": "Is Test Node", "type": "boolean"}, "language": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Language"}, "name": {"title": "Name", "type": "string"}, "outputs": {"items": {"$ref": "#/components/schemas/WirePort"}, "title": "Outputs", "type": "array"}, "path_prefix": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Path Prefix"}, "protocol": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Protocol"}, "source_decisions": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "default": null, "title": "Source Decisions"}, "status": {"title": "Status", "type": "string"}, "user_kind": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "User Kind"}, "verifications": {"items": {"$ref": "#/components/schemas/WireVerification"}, "title": "Verifications", "type": "array"}}, "required": ["name", "description", "status", "is_test_node", "is_external"], "title": "WireNodeData", "type": "object"}, "WirePort": {"additionalProperties": false, "properties": {"contract_name": {"anyOf": [{"maxLength": 500, "type": "string"}, {"type": "null"}], "default": null, "title": "Contract Name"}, "description": {"default": "", "title": "Description", "type": "string"}, "external": {"default": false, "title": "External", "type": "boolean"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "name": {"default": "", "title": "Name", "type": "string"}, "type": {"default": "", "title": "Type", "type": "string"}}, "required": ["id"], "title": "WirePort", "type": "object"}, "WireVerification": {"additionalProperties": false, "properties": {"author": {"enum": ["user", "agent"], "title": "Author", "type": "string"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "text": {"title": "Text", "type": "string"}, "type": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Type"}}, "required": ["id", "author", "text"], "title": "WireVerification", "type": "object"}}, "securitySchemes": {"Bearer": {"description": "API key. Send as `Authorization: Bearer `.", "scheme": "bearer", "type": "http"}}}, "info": {"description": "The hydrate.sh public API. All routes are scoped to the `/v1/` prefix and require a Bearer API key (see Authentication).\n\n**Stability:** `v1` is the wire-stable surface. Breaking changes will rebase to `/v2/`.", "title": "hydrate.sh v1 API", "version": "1.0.0-rc.1"}, "openapi": "3.1.0", "paths": {"/v1/__healthz": {"get": {"description": "Returns `{\"ok\": true}` when the /v1/ surface is serving traffic. Unauthenticated. Use this for load-balancer and uptime-monitor checks; a 404 from this path means the /v1/ surface is disabled on the responding instance, not that the server is down.", "operationId": "healthz_v1___healthz_get", "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HealthzResponse"}}}, "description": "Successful Response"}}, "security": [], "summary": "Liveness probe", "tags": ["health"]}}, "/v1/branches/{branch_id}/deltas": {"post": {"description": "Apply an ordered batch of structural changes to a branch under optimistic concurrency. Pass the `version` from the branch read as `expected_version`; a 409 with `current_version` is returned if the branch moved underneath you. Pair with the `Idempotency-Key` header for at-most-once apply across retries.", "operationId": "apply_branch_deltas_v1_branches__branch_id__deltas_post", "parameters": [{"description": "The branch to apply to.", "in": "path", "name": "branch_id", "required": true, "schema": {"description": "The branch to apply to.", "format": "uuid", "title": "Branch Id", "type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/V1DeltasBody"}}}, "required": true}, "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeltaApplyResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"content": {"application/json": {"schema": {"properties": {"detail": {"oneOf": [{"properties": {"code": {"type": "string"}, "message": {"type": "string"}}, "required": ["code", "message"], "type": "object"}, {"$ref": "#/components/schemas/NotFoundBody"}]}}, "required": ["detail"], "type": "object"}}}, "description": "Branch/project not found or not accessible, OR a delta targets a node that doesn't exist."}, "409": {"content": {"application/json": {"schema": {"properties": {"detail": {"oneOf": [{"$ref": "#/components/schemas/VersionConflictBody"}, {"$ref": "#/components/schemas/BranchNotActiveBody"}]}}, "required": ["detail"], "type": "object"}}}, "description": "OCC version conflict, or the branch is not active."}, "422": {"content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/HTTPValidationError"}, {"properties": {"detail": {"$ref": "#/components/schemas/DeltaFieldErrorBody"}}, "required": ["detail"], "type": "object"}]}}}, "description": "Request body failed validation, OR a delta is malformed / unknown / not an object."}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Apply a delta batch to a branch (OCC)", "tags": ["branches"]}}, "/v1/branches/{branch_id}/graph": {"get": {"description": "Read the full graph (nodes, ports, edges, version) of a specific branch. Use the returned `version` as `expected_version` on a subsequent apply to that branch.", "operationId": "fetch_branch_graph_v1_branches__branch_id__graph_get", "parameters": [{"description": "The branch to read.", "in": "path", "name": "branch_id", "required": true, "schema": {"description": "The branch to read.", "format": "uuid", "title": "Branch Id", "type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GraphResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Read a specific branch's graph", "tags": ["branches"]}}, "/v1/branches/{branch_id}/validate": {"post": {"description": "Dry-run a delta batch and get back the full coherence report over the graph that would result \u2014 never mutating the branch. Returns 200 with `{valid, findings}`: `findings` is the complete list of coherence problems (an input port with no incoming edge, a wire pinned to a missing port, or an edge whose endpoint types differ), and `valid` is true only when there are no error-severity findings. The verdict is in the body, so an incoherent graph is still a 200. A 4xx is returned only for a request that can't be processed at all (a malformed body or an unknown delta type). An empty delta list reports the branch's current coherence.", "operationId": "validate_branch_deltas_v1_branches__branch_id__validate_post", "parameters": [{"description": "The branch to validate against.", "in": "path", "name": "branch_id", "required": true, "schema": {"description": "The branch to validate against.", "format": "uuid", "title": "Branch Id", "type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/V1ValidateBody"}}}, "required": true}, "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ValidateResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"content": {"application/json": {"schema": {"properties": {"detail": {"oneOf": [{"properties": {"code": {"type": "string"}, "message": {"type": "string"}}, "required": ["code", "message"], "type": "object"}, {"$ref": "#/components/schemas/NotFoundBody"}]}}, "required": ["detail"], "type": "object"}}}, "description": "Branch/project not found or not accessible, OR a delta targets a node that doesn't exist."}, "422": {"content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/HTTPValidationError"}, {"properties": {"detail": {"$ref": "#/components/schemas/DeltaFieldErrorBody"}}, "required": ["detail"], "type": "object"}]}}}, "description": "Request body failed validation, OR a delta is malformed / unknown / not an object."}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Dry-run a delta batch and report coherence findings", "tags": ["branches"]}}, "/v1/graph/{project_id}": {"get": {"description": "Returns the active main-branch graph (nodes, ports, edges, version) for the project. Read-only: the main branch is merge-only. To make changes, fork a branch (`POST /v1/projects/{project_id}/branches`) and apply deltas to it.", "operationId": "fetch_graph_v1_graph__project_id__get", "parameters": [{"description": "The project whose main-branch graph to fetch.", "in": "path", "name": "project_id", "required": true, "schema": {"description": "The project whose main-branch graph to fetch.", "format": "uuid", "title": "Project Id", "type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GraphResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Get the canonical graph for a project", "tags": ["graph"]}}, "/v1/graph/{project_id}/boundary/{node_id}": {"get": {"description": "Returns the boundary view for a single node: the inputs and outputs that cross the node's enforcement perimeter. Used by clients that visualize a node's contract surface in isolation from the rest of the graph.", "operationId": "fetch_boundary_v1_graph__project_id__boundary__node_id__get", "parameters": [{"description": "The project the boundary lives in.", "in": "path", "name": "project_id", "required": true, "schema": {"description": "The project the boundary lives in.", "format": "uuid", "title": "Project Id", "type": "string"}}, {"description": "The boundary node to inspect.", "in": "path", "name": "node_id", "required": true, "schema": {"description": "The boundary node to inspect.", "format": "uuid", "title": "Node Id", "type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BoundaryResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Get the boundary topology around a node", "tags": ["graph"]}}, "/v1/graph/{project_id}/node/{node_id}": {"get": {"description": "Returns a single node along with its directly connected neighbors and edges (the 1-hop topology). The node must belong to the project's active main branch.", "operationId": "fetch_node_with_neighbors_v1_graph__project_id__node__node_id__get", "parameters": [{"description": "The project the node lives in.", "in": "path", "name": "project_id", "required": true, "schema": {"description": "The project the node lives in.", "format": "uuid", "title": "Project Id", "type": "string"}}, {"description": "The node to fetch.", "in": "path", "name": "node_id", "required": true, "schema": {"description": "The node to fetch.", "format": "uuid", "title": "Node Id", "type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/NodeNeighborhoodResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Get a node with its 1-hop neighborhood", "tags": ["graph"]}}, "/v1/projects": {"get": {"description": "Returns the projects the caller can see. For API-key callers the list is further filtered by the per-key project allowlist (if configured). Archived projects are excluded. An empty list is a normal response when the caller has no visible projects.", "operationId": "list_projects_v1_projects_get", "parameters": [{"description": "Maximum projects to return. Defaults to 100; max 200.", "in": "query", "name": "limit", "required": false, "schema": {"default": 100, "description": "Maximum projects to return. Defaults to 100; max 200.", "maximum": 200, "minimum": 1, "title": "Limit", "type": "integer"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectsListResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "List your projects", "tags": ["projects"]}}, "/v1/projects/{project_id}/branches": {"get": {"description": "List your active working branches on this project. The protected main branch is not included. Each branch is a unit of work you can read and apply changes to before merging into main.", "operationId": "list_branches_v1_projects__project_id__branches_get", "parameters": [{"description": "The project to list branches for.", "in": "path", "name": "project_id", "required": true, "schema": {"description": "The project to list branches for.", "format": "uuid", "title": "Project Id", "type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BranchListResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "List the caller's working branches on a project", "tags": ["branches"]}, "post": {"description": "Fork a new working branch from the project's main branch. The branch is copied from main's current state and owned by you; your changes stay isolated on it until merged. Requires an editor role, and there is a per-project limit on how many active branches you can hold at once.", "operationId": "create_branch_v1_projects__project_id__branches_post", "parameters": [{"description": "The project to fork a branch in.", "in": "path", "name": "project_id", "required": true, "schema": {"description": "The project to fork a branch in.", "format": "uuid", "title": "Project Id", "type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/V1CreateBranchBody"}}}, "required": true}, "responses": {"201": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BranchCreateResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Start a unit of work (fork a branch from main)", "tags": ["branches"]}}}, "security": [{"Bearer": []}], "servers": [{"description": "Production", "url": "https://api.hydrate.sh"}], "tags": [{"description": "Work on a branch (a unit of work) rather than the protected main branch: fork a branch from main, list your branches, and read / validate / apply deltas against a specific branch.", "name": "branches"}, {"description": "Read the canonical graph for a project, validate a delta batch (dry-run), or apply a delta batch through the OCC + idempotency middleware chain.", "name": "graph"}, {"description": "Liveness probes. Unauthenticated. Used by load balancers and uptime monitors.", "name": "health"}, {"description": "List the projects your principal can see. Filtered by membership + (for API keys) the per-key project allowlist.", "name": "projects"}]} \ No newline at end of file +{"components": {"responses": {"Forbidden": {"content": {"application/json": {"schema": {"properties": {"detail": {"properties": {"code": {"type": "string"}, "message": {"type": "string"}}, "type": "object"}}, "type": "object"}}}, "description": "Credentials are valid but lack the scope required for this route, or the principal lacks membership in the target project."}, "NotFound": {"content": {"application/json": {"schema": {"properties": {"detail": {"properties": {"code": {"example": "not_found", "type": "string"}, "message": {"type": "string"}}, "type": "object"}}, "type": "object"}}}, "description": "Resource not found OR not accessible to this principal. Leak parity: the response is identical in both cases so an attacker cannot enumerate resources via 404-vs-403 timing."}, "ProjectCreateConflict": {"content": {"application/json": {"schema": {"properties": {"detail": {"properties": {"code": {"enum": ["name_taken", "project_limit_reached"], "type": "string"}, "message": {"type": "string"}}, "required": ["code", "message"], "type": "object"}}, "type": "object"}}}, "description": "The project could not be created because it conflicts with existing state. ``name_taken`` \u2014 an active project of yours already uses this name (case-insensitive); pick another. ``project_limit_reached`` \u2014 you are at the per-user project cap, which counts ARCHIVED projects too; delete a project to free a slot (archiving does not)."}, "ProjectCreateUnprocessable": {"content": {"application/json": {"schema": {"properties": {"detail": {"properties": {"code": {"enum": ["invalid_name", "unsupported_language", "unsupported_intent"], "type": "string"}, "message": {"type": "string"}}, "required": ["code", "message"], "type": "object"}}, "type": "object"}}}, "description": "A field in the request body is not acceptable. ``invalid_name`` \u2014 the name is blank after trimming. ``unsupported_language`` / ``unsupported_intent`` \u2014 the value is outside the server's allowlist. NOTE: this envelope is ``{detail: {code, message}}``, NOT FastAPI's ``HTTPValidationError``."}, "RateLimited": {"content": {"application/json": {"schema": {"properties": {"detail": {"example": "rate_limited", "type": "string"}}, "type": "object"}}}, "description": "Per-bucket rate limit exceeded. The response carries ``Retry-After`` and the standard ``X-RateLimit-*`` headers (Limit / Remaining / Reset).", "headers": {"Retry-After": {"schema": {"type": "integer"}}, "X-RateLimit-Limit": {"schema": {"type": "integer"}}, "X-RateLimit-Remaining": {"schema": {"type": "integer"}}, "X-RateLimit-Reset": {"schema": {"type": "integer"}}}}, "Unauthenticated": {"content": {"application/json": {"schema": {"properties": {"detail": {"example": "unauthenticated", "type": "string"}}, "required": ["detail"], "type": "object"}}}, "description": "No credentials, malformed credentials, or revoked credentials. The envelope is leak-parity (same shape across all 401 paths) so an attacker cannot distinguish revoked vs. unknown via the body."}, "Unprocessable": {"content": {"application/json": {"schema": {"properties": {"detail": {"oneOf": [{"type": "string"}, {"properties": {"delta_ordinal": {"type": "integer"}, "delta_type": {"type": ["string", "null"]}, "error": {"type": "string"}, "field": {"type": ["string", "null"]}, "reason": {"type": ["string", "null"]}, "recoverable": {"type": "boolean"}, "recovery_hint": {"type": ["string", "null"]}}, "type": "object"}]}}, "type": "object"}}}, "description": "Request body failed validation. For delta routes the response carries ``error``, ``delta_ordinal``, ``delta_type``, ``field``, ``reason``, ``recoverable``, and ``recovery_hint`` so a client can auto-recover or surface a typed error."}, "VersionConflict": {"content": {"application/json": {"schema": {"properties": {"detail": {"properties": {"current_version": {"type": "integer"}, "error": {"example": "version_conflict", "type": "string"}}, "type": "object"}}, "type": "object"}}}, "description": "Optimistic concurrency check failed \u2014 the branch's current version is not the one the client sent in ``expected_version``. The response carries ``current_version`` so the client can refetch + retry."}}, "schemas": {"AddEdgeDelta": {"description": "Insert an edge.", "properties": {"edge": {"$ref": "#/components/schemas/Edge"}, "type": {"const": "add_edge", "title": "Type", "type": "string"}}, "required": ["type", "edge"], "title": "AddEdgeDelta", "type": "object"}, "AddNodeDelta": {"description": "Insert a node.", "properties": {"node": {"$ref": "#/components/schemas/Node"}, "type": {"const": "add_node", "title": "Type", "type": "string"}}, "required": ["type", "node"], "title": "AddNodeDelta", "type": "object"}, "BoundaryResponse": {"additionalProperties": false, "properties": {"boundary": {"$ref": "#/components/schemas/WireNode"}, "branch": {"$ref": "#/components/schemas/BranchRef"}, "children": {"items": {"$ref": "#/components/schemas/WireNode"}, "title": "Children", "type": "array"}, "edges": {"items": {"$ref": "#/components/schemas/WireEdge"}, "title": "Edges", "type": "array"}, "paths": {"additionalProperties": {"type": "string"}, "description": "Dotted path for every node returned that has one, keyed by node id. Server-rendered because a scoped read returns a SLICE: the ancestors a path is built from are not in the payload, so a client cannot derive it. Every returned node appears in exactly one of `paths` or `unaddressable` \u2014 index this map for anything not listed there.", "propertyNames": {"format": "uuid"}, "title": "Paths", "type": "object"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "unaddressable": {"additionalProperties": {"type": "string"}, "description": "Nodes that could not be given a dotted path, keyed by node id, with the reason: `empty_name` (the node, or an ancestor, has no name \u2014 legal while designing), `reserved_separator` (a name contains `.`), or `ambiguous` (two returned nodes would render the same path, so neither is safe to act on). These are data conditions a user fixes by renaming, not errors.", "propertyNames": {"format": "uuid"}, "title": "Unaddressable", "type": "object"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branch", "boundary", "children", "edges", "paths", "unaddressable"], "title": "BoundaryResponse", "type": "object"}, "BranchCreateResponse": {"additionalProperties": false, "properties": {"branch": {"$ref": "#/components/schemas/BranchMeta"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branch"], "title": "BranchCreateResponse", "type": "object"}, "BranchListResponse": {"additionalProperties": false, "properties": {"branches": {"items": {"$ref": "#/components/schemas/BranchMeta"}, "title": "Branches", "type": "array"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branches"], "title": "BranchListResponse", "type": "object"}, "BranchMeta": {"additionalProperties": false, "properties": {"base_main_version": {"title": "Base Main Version", "type": "integer"}, "created_at": {"title": "Created At", "type": "string"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "is_main": {"title": "Is Main", "type": "boolean"}, "last_active_at": {"title": "Last Active At", "type": "string"}, "merged_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Merged At"}, "name": {"title": "Name", "type": "string"}, "owner_id": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "title": "Owner Id"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "status": {"title": "Status", "type": "string"}, "version": {"title": "Version", "type": "integer"}}, "required": ["id", "project_id", "owner_id", "name", "is_main", "version", "base_main_version", "status", "last_active_at", "created_at", "merged_at"], "title": "BranchMeta", "type": "object"}, "BranchNotActiveBody": {"additionalProperties": false, "properties": {"current_status": {"title": "Current Status", "type": "string"}, "error": {"const": "branch_not_active", "title": "Error", "type": "string"}}, "required": ["error", "current_status"], "title": "BranchNotActiveBody", "type": "object"}, "BranchRef": {"additionalProperties": false, "properties": {"id": {"format": "uuid", "title": "Id", "type": "string"}, "version": {"title": "Version", "type": "integer"}}, "required": ["id", "version"], "title": "BranchRef", "type": "object"}, "DeleteEdgeDelta": {"description": "Remove an edge.", "properties": {"edge_id": {"format": "uuid", "title": "Edge Id", "type": "string"}, "type": {"const": "delete_edge", "title": "Type", "type": "string"}}, "required": ["type", "edge_id"], "title": "DeleteEdgeDelta", "type": "object"}, "DeleteNodeDelta": {"description": "Cascade-delete a node and its descendant subtree.", "properties": {"node_id": {"format": "uuid", "title": "Node Id", "type": "string"}, "type": {"const": "delete_node", "title": "Type", "type": "string"}}, "required": ["type", "node_id"], "title": "DeleteNodeDelta", "type": "object"}, "DeltaApplyResponse": {"additionalProperties": false, "properties": {"applied": {"title": "Applied", "type": "boolean"}, "branch": {"$ref": "#/components/schemas/BranchRef"}, "delta_count": {"title": "Delta Count", "type": "integer"}, "positions_applied": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "title": "Positions Applied"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branch", "applied", "delta_count"], "title": "DeltaApplyResponse", "type": "object"}, "DeltaFieldErrorBody": {"additionalProperties": false, "description": "The 7-key per-delta envelope (always all keys present) the frontend\nauto-recovery consumes. Shared by every 422 delta error; `error`\ndiscriminates the cause.", "properties": {"delta_ordinal": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Delta Ordinal"}, "delta_type": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Delta Type"}, "error": {"enum": ["malformed_delta_field", "unknown_delta_type", "delta_not_object", "delta_too_large", "batch_too_large", "batch_count_exceeded"], "title": "Error", "type": "string"}, "field": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Field"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}, "recoverable": {"title": "Recoverable", "type": "boolean"}, "recovery_hint": {"anyOf": [{"$ref": "#/components/schemas/RecoveryHint"}, {"type": "null"}]}}, "required": ["error", "delta_ordinal", "delta_type", "field", "reason", "recoverable", "recovery_hint"], "title": "DeltaFieldErrorBody", "type": "object"}, "Edge": {"description": "An edge connecting two nodes' ports.\n\nEndpoints are given as `source_handle` / `target_handle`, each a port\nid. Unknown keys are ignored rather than rejected.", "properties": {"id": {"format": "uuid", "title": "Id", "type": "string"}, "source_handle": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "default": null, "title": "Source Handle"}, "target_handle": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "default": null, "title": "Target Handle"}}, "required": ["id"], "title": "Edge", "type": "object"}, "Finding": {"additionalProperties": false, "description": "A single coherence finding over the graph that results from a delta\nbatch. All three v1 ``code``s are ``error``-severity; ``severity`` keeps the\n``warning`` arm in the contract for future advisory codes. ``locator`` is the\nnode / port / edge id (a string) the finding is about.", "properties": {"code": {"enum": ["unsatisfied_input", "dangling_wire", "type_mismatch"], "title": "Code", "type": "string"}, "locator": {"title": "Locator", "type": "string"}, "message": {"title": "Message", "type": "string"}, "severity": {"enum": ["error", "warning"], "title": "Severity", "type": "string"}}, "required": ["code", "severity", "locator", "message"], "title": "Finding", "type": "object"}, "FlattenBoundaryDelta": {"description": "Flatten a boundary node: delete it and promote its children to its parent.", "properties": {"node_id": {"format": "uuid", "title": "Node Id", "type": "string"}, "type": {"const": "flatten_boundary", "title": "Type", "type": "string"}}, "required": ["type", "node_id"], "title": "FlattenBoundaryDelta", "type": "object"}, "GraphResponse": {"additionalProperties": false, "properties": {"branch": {"$ref": "#/components/schemas/BranchRef"}, "edges": {"items": {"$ref": "#/components/schemas/WireEdge"}, "title": "Edges", "type": "array"}, "nodes": {"items": {"$ref": "#/components/schemas/WireNode"}, "title": "Nodes", "type": "array"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branch", "nodes", "edges"], "title": "GraphResponse", "type": "object"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "title": "Detail", "type": "array"}}, "title": "HTTPValidationError", "type": "object"}, "HealthzResponse": {"additionalProperties": false, "properties": {"ok": {"title": "Ok", "type": "boolean"}}, "required": ["ok"], "title": "HealthzResponse", "type": "object"}, "MainBranchRef": {"additionalProperties": false, "description": "The seeded main branch as returned by project-create.\n\nDeliberately narrower than ``BranchMeta``: create returns only what the\nclient can't mint locally and needs for its next call. Pinning the real\nshape (not ``BranchMeta``) keeps the generated SDK honest.", "properties": {"id": {"format": "uuid", "title": "Id", "type": "string"}, "version": {"title": "Version", "type": "integer"}}, "required": ["id", "version"], "title": "MainBranchRef", "type": "object"}, "Node": {"description": "A behavior or boundary node.", "properties": {"data": {"$ref": "#/components/schemas/NodeData"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "kind": {"enum": ["behavior", "boundary", "state", "io", "interface"], "title": "Kind", "type": "string"}, "parent_id": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "default": null, "title": "Parent Id"}}, "required": ["id", "kind"], "title": "Node", "type": "object"}, "NodeData": {"description": "A node's data payload; its shape varies by kind and whether the node is\nexternal.\n\nOn update, key-presence semantics apply: a field present with a null value is\nset to null, while an omitted field is left unchanged. All fields are optional\nat the type level \u2014 cross-field rules (e.g. behavior nodes reject `user_kind`;\nexternal nodes require `external_kind`) are enforced server-side, so a partial\nupdate is not blocked by \"missing required field\" errors that don't apply to it.", "properties": {"aliases": {"items": {"maxLength": 500, "type": "string"}, "maxItems": 50, "title": "Aliases", "type": "array"}, "config": {"items": {"$ref": "#/components/schemas/Port"}, "title": "Config", "type": "array"}, "constraints": {"items": {"type": "string"}, "title": "Constraints", "type": "array"}, "description": {"default": "", "title": "Description", "type": "string"}, "documentation_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Documentation Url"}, "external_kind": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "External Kind"}, "inputs": {"items": {"$ref": "#/components/schemas/Port"}, "title": "Inputs", "type": "array"}, "is_external": {"default": false, "title": "Is External", "type": "boolean"}, "is_test_node": {"default": false, "title": "Is Test Node", "type": "boolean"}, "kind": {"anyOf": [{"enum": ["behavior", "boundary", "state", "io", "interface"], "type": "string"}, {"type": "null"}], "default": null, "title": "Kind"}, "language": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Language"}, "name": {"default": "", "title": "Name", "type": "string"}, "outputs": {"items": {"$ref": "#/components/schemas/Port"}, "title": "Outputs", "type": "array"}, "parent_id": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "default": null, "title": "Parent Id"}, "path_prefix": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Path Prefix"}, "protocol": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Protocol"}, "source_decisions": {"items": {"format": "uuid", "type": "string"}, "title": "Source Decisions", "type": "array"}, "status": {"default": "idle", "title": "Status", "type": "string"}, "user_kind": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "User Kind"}, "verifications": {"items": {"$ref": "#/components/schemas/Verification"}, "title": "Verifications", "type": "array"}}, "title": "NodeData", "type": "object"}, "NodeNeighborhoodResponse": {"additionalProperties": false, "properties": {"branch": {"$ref": "#/components/schemas/BranchRef"}, "edges": {"items": {"$ref": "#/components/schemas/WireEdge"}, "title": "Edges", "type": "array"}, "neighbors": {"items": {"$ref": "#/components/schemas/WireNode"}, "title": "Neighbors", "type": "array"}, "node": {"$ref": "#/components/schemas/WireNode"}, "paths": {"additionalProperties": {"type": "string"}, "description": "Dotted path for every node returned that has one, keyed by node id. Server-rendered because a scoped read returns a SLICE: the ancestors a path is built from are not in the payload, so a client cannot derive it. Every returned node appears in exactly one of `paths` or `unaddressable` \u2014 index this map for anything not listed there.", "propertyNames": {"format": "uuid"}, "title": "Paths", "type": "object"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "unaddressable": {"additionalProperties": {"type": "string"}, "description": "Nodes that could not be given a dotted path, keyed by node id, with the reason: `empty_name` (the node, or an ancestor, has no name \u2014 legal while designing), `reserved_separator` (a name contains `.`), or `ambiguous` (two returned nodes would render the same path, so neither is safe to act on). These are data conditions a user fixes by renaming, not errors.", "propertyNames": {"format": "uuid"}, "title": "Unaddressable", "type": "object"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branch", "node", "neighbors", "edges", "paths", "unaddressable"], "title": "NodeNeighborhoodResponse", "type": "object"}, "NotFoundBody": {"additionalProperties": false, "description": "Returned when a delta targets a node that doesn't exist (404). `node_id`\nidentifies the missing target.", "properties": {"error": {"const": "not_found", "title": "Error", "type": "string"}, "node_id": {"title": "Node Id", "type": "string"}, "reason": {"title": "Reason", "type": "string"}}, "required": ["error", "node_id", "reason"], "title": "NotFoundBody", "type": "object"}, "Port": {"description": "An input, output, or config port on a node.", "properties": {"contract_name": {"anyOf": [{"maxLength": 500, "type": "string"}, {"type": "null"}], "default": null, "title": "Contract Name"}, "description": {"default": "", "title": "Description", "type": "string"}, "external": {"default": false, "title": "External", "type": "boolean"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "name": {"default": "", "title": "Name", "type": "string"}, "type": {"default": "", "title": "Type", "type": "string"}}, "required": ["id"], "title": "Port", "type": "object"}, "Position": {"additionalProperties": false, "properties": {"x": {"title": "X", "type": "number"}, "y": {"title": "Y", "type": "number"}}, "required": ["x", "y"], "title": "Position", "type": "object"}, "ProjectCreateResponse": {"additionalProperties": false, "properties": {"main_branch": {"$ref": "#/components/schemas/MainBranchRef"}, "project": {"$ref": "#/components/schemas/ProjectOut"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project", "main_branch"], "title": "ProjectCreateResponse", "type": "object"}, "ProjectOut": {"additionalProperties": false, "properties": {"archived": {"title": "Archived", "type": "boolean"}, "created_at": {"title": "Created At", "type": "string"}, "h2o_schema_version": {"title": "H2O Schema Version", "type": "integer"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "intent": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Intent"}, "language": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Language"}, "last_opened_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Last Opened At"}, "name": {"title": "Name", "type": "string"}, "updated_at": {"title": "Updated At", "type": "string"}}, "required": ["id", "name", "language", "intent", "archived", "h2o_schema_version", "created_at", "updated_at", "last_opened_at"], "title": "ProjectOut", "type": "object"}, "ProjectsListResponse": {"additionalProperties": false, "properties": {"projects": {"items": {"$ref": "#/components/schemas/ProjectOut"}, "title": "Projects", "type": "array"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "projects"], "title": "ProjectsListResponse", "type": "object"}, "RecoveryHint": {"description": "The finite set of recovery hints a client may receive in\n`ApiError.recovery_hint`:\n\n - \"regenerate_uuid\" \u2014 mint a new UUID at `field` and retry\n - \"drop_field\" \u2014 delete `field` and retry\n - null \u2014 not auto-recoverable; user intervention needed\n\nA closed set, so integrations can switch over the value; a new hint is added\nhere deliberately, giving clients a chance to handle it.", "enum": ["regenerate_uuid", "drop_field"], "title": "RecoveryHint", "type": "string"}, "ReparentNodeDelta": {"description": "Move a node to a new parent (or to top-level).\n\n`parent_id` must be present on the wire (use null for top-level); an absent key\nis rejected, so a client bug surfaces as a clean parse error rather than an\nambiguous no-op.", "properties": {"node_id": {"format": "uuid", "title": "Node Id", "type": "string"}, "parent_id": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "title": "Parent Id"}, "type": {"const": "reparent_node", "title": "Type", "type": "string"}}, "required": ["type", "node_id", "parent_id"], "title": "ReparentNodeDelta", "type": "object"}, "SubtreeResponse": {"additionalProperties": false, "description": "A node plus its descendants to a bounded depth.\n\n``nodes`` EXCLUDES ``root``. ``truncated`` is true when the walk stopped\nwith descendants still below it \u2014 a caller that cannot distinguish a\ncomplete cell from a slice will treat a slice as complete, so the signal\nis explicit rather than inferred from ``depth``.\n\n``cross_boundary_edges`` carries edges with exactly one endpoint inside\nthe returned set: real dependencies of this slice, which a scoped read\nwould otherwise hide.", "properties": {"branch": {"$ref": "#/components/schemas/BranchRef"}, "cross_boundary_edges": {"items": {"$ref": "#/components/schemas/WireEdge"}, "title": "Cross Boundary Edges", "type": "array"}, "depth": {"title": "Depth", "type": "integer"}, "edges": {"items": {"$ref": "#/components/schemas/WireEdge"}, "title": "Edges", "type": "array"}, "nodes": {"items": {"$ref": "#/components/schemas/WireNode"}, "title": "Nodes", "type": "array"}, "paths": {"additionalProperties": {"type": "string"}, "description": "Dotted path for every node returned that has one, keyed by node id. Server-rendered because a scoped read returns a SLICE: the ancestors a path is built from are not in the payload, so a client cannot derive it. Every returned node appears in exactly one of `paths` or `unaddressable` \u2014 index this map for anything not listed there.", "propertyNames": {"format": "uuid"}, "title": "Paths", "type": "object"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "root": {"$ref": "#/components/schemas/WireNode"}, "truncated": {"title": "Truncated", "type": "boolean"}, "unaddressable": {"additionalProperties": {"type": "string"}, "description": "Nodes that could not be given a dotted path, keyed by node id, with the reason: `empty_name` (the node, or an ancestor, has no name \u2014 legal while designing), `reserved_separator` (a name contains `.`), or `ambiguous` (two returned nodes would render the same path, so neither is safe to act on). These are data conditions a user fixes by renaming, not errors.", "propertyNames": {"format": "uuid"}, "title": "Unaddressable", "type": "object"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branch", "root", "nodes", "edges", "cross_boundary_edges", "depth", "truncated", "paths", "unaddressable"], "title": "SubtreeResponse", "type": "object"}, "UpdateNodeDataDelta": {"description": "Update a node's data, with key-presence partial semantics.\n\n`after` may be empty; whether a field is present in `after` distinguishes\n\"set this field\" from \"leave it unchanged\".", "properties": {"after": {"$ref": "#/components/schemas/NodeData"}, "node_id": {"format": "uuid", "title": "Node Id", "type": "string"}, "type": {"const": "update_node_data", "title": "Type", "type": "string"}}, "required": ["type", "node_id", "after"], "title": "UpdateNodeDataDelta", "type": "object"}, "V1CreateBranchBody": {"description": "Body for ``POST /v1/projects/{pid}/branches``. ``name`` defaults to the\nnext ``main-work[-N]`` for the caller, so a bodyless POST works.", "properties": {"name": {"anyOf": [{"maxLength": 200, "minLength": 1, "type": "string"}, {"type": "null"}], "title": "Name"}}, "title": "V1CreateBranchBody", "type": "object"}, "V1CreateProjectBody": {"description": "Body for ``POST /v1/projects``. Only ``name`` is required; ``language``\nand ``intent`` default server-side to ``python``/``cli`` (mirrored by the\nCLI ``hydrate new`` defaults).", "properties": {"intent": {"anyOf": [{"maxLength": 40, "type": "string"}, {"type": "null"}], "default": "cli", "description": "What the project is for. Defaults to 'cli'.", "title": "Intent"}, "language": {"anyOf": [{"maxLength": 40, "type": "string"}, {"type": "null"}], "default": "python", "description": "Primary language. Defaults to 'python'.", "title": "Language"}, "name": {"description": "Project name. Unique per-user, case-insensitive.", "maxLength": 200, "minLength": 1, "title": "Name", "type": "string"}}, "required": ["name"], "title": "V1CreateProjectBody", "type": "object"}, "V1DeltasBody": {"description": "Request body for ``POST /v1/branches/{bid}/deltas``.", "properties": {"deltas": {"description": "Ordered list of delta operations to apply. Each must carry a `type` field in the closed write-surface vocabulary (add_node / delete_node / update_node_data / reparent_node / add_edge / delete_edge / flatten_boundary). Empty list returns 200 with `applied: false` and no version bump.", "items": {"discriminator": {"mapping": {"add_edge": "#/components/schemas/AddEdgeDelta", "add_node": "#/components/schemas/AddNodeDelta", "delete_edge": "#/components/schemas/DeleteEdgeDelta", "delete_node": "#/components/schemas/DeleteNodeDelta", "flatten_boundary": "#/components/schemas/FlattenBoundaryDelta", "reparent_node": "#/components/schemas/ReparentNodeDelta", "update_node_data": "#/components/schemas/UpdateNodeDataDelta"}, "propertyName": "type"}, "oneOf": [{"$ref": "#/components/schemas/AddNodeDelta"}, {"$ref": "#/components/schemas/DeleteNodeDelta"}, {"$ref": "#/components/schemas/UpdateNodeDataDelta"}, {"$ref": "#/components/schemas/ReparentNodeDelta"}, {"$ref": "#/components/schemas/AddEdgeDelta"}, {"$ref": "#/components/schemas/DeleteEdgeDelta"}, {"$ref": "#/components/schemas/FlattenBoundaryDelta"}]}, "title": "Deltas", "type": "array"}, "expected_version": {"description": "The branch.version the client believes is current. If it doesn't match the server's view at commit time, the route 409s with the actual current_version in the detail.", "minimum": 0.0, "title": "Expected Version", "type": "integer"}, "positions": {"anyOf": [{"additionalProperties": {"$ref": "#/components/schemas/V1NodePosition"}, "type": "object"}, {"type": "null"}], "description": "Optional explicit cell pins as `node_id -> {x, y}`, applied to the branch owner's view AFTER the deltas. **Additive**: a pin is introduced only for a node the owner hasn't already placed \u2014 it never overwrites a hand-placement and never deletes. Use only to override the derived grid placement for nodes you create.", "title": "Positions"}}, "required": ["expected_version"], "title": "V1DeltasBody", "type": "object"}, "V1NodePosition": {"description": "A single explicit pin coordinate on the `/v1/` write surface.", "properties": {"x": {"title": "X", "type": "number"}, "y": {"title": "Y", "type": "number"}}, "required": ["x", "y"], "title": "V1NodePosition", "type": "object"}, "V1ValidateBody": {"description": "Request body for ``POST /v1/branches/{bid}/validate``.", "properties": {"deltas": {"items": {"discriminator": {"mapping": {"add_edge": "#/components/schemas/AddEdgeDelta", "add_node": "#/components/schemas/AddNodeDelta", "delete_edge": "#/components/schemas/DeleteEdgeDelta", "delete_node": "#/components/schemas/DeleteNodeDelta", "flatten_boundary": "#/components/schemas/FlattenBoundaryDelta", "reparent_node": "#/components/schemas/ReparentNodeDelta", "update_node_data": "#/components/schemas/UpdateNodeDataDelta"}, "propertyName": "type"}, "oneOf": [{"$ref": "#/components/schemas/AddNodeDelta"}, {"$ref": "#/components/schemas/DeleteNodeDelta"}, {"$ref": "#/components/schemas/UpdateNodeDataDelta"}, {"$ref": "#/components/schemas/ReparentNodeDelta"}, {"$ref": "#/components/schemas/AddEdgeDelta"}, {"$ref": "#/components/schemas/DeleteEdgeDelta"}, {"$ref": "#/components/schemas/FlattenBoundaryDelta"}]}, "title": "Deltas", "type": "array"}}, "title": "V1ValidateBody", "type": "object"}, "ValidateResponse": {"additionalProperties": false, "properties": {"branch": {"$ref": "#/components/schemas/BranchRef"}, "findings": {"items": {"$ref": "#/components/schemas/Finding"}, "title": "Findings", "type": "array"}, "project_id": {"format": "uuid", "title": "Project Id", "type": "string"}, "valid": {"title": "Valid", "type": "boolean"}, "version": {"title": "Version", "type": "string"}}, "required": ["version", "project_id", "branch", "valid"], "title": "ValidateResponse", "type": "object"}, "ValidationError": {"properties": {"ctx": {"title": "Context", "type": "object"}, "input": {"title": "Input"}, "loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "title": "Location", "type": "array"}, "msg": {"title": "Message", "type": "string"}, "type": {"title": "Error Type", "type": "string"}}, "required": ["loc", "msg", "type"], "title": "ValidationError", "type": "object"}, "Verification": {"description": "A test specification attached to a behavior node.", "properties": {"author": {"enum": ["user", "agent"], "title": "Author", "type": "string"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "text": {"title": "Text", "type": "string"}, "type": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Type"}}, "required": ["id", "author", "text"], "title": "Verification", "type": "object"}, "VersionConflictBody": {"additionalProperties": false, "properties": {"current_version": {"title": "Current Version", "type": "integer"}, "error": {"const": "version_conflict", "title": "Error", "type": "string"}}, "required": ["error", "current_version"], "title": "VersionConflictBody", "type": "object"}, "WireEdge": {"additionalProperties": false, "description": "Read-path edge: `source`/`target` are server-derived NODE ids\n(absent from the client-authored `h2o.types.Edge`); `*_handle` are the\nPORT ids.", "properties": {"id": {"format": "uuid", "title": "Id", "type": "string"}, "source": {"format": "uuid", "title": "Source", "type": "string"}, "source_handle": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "title": "Source Handle"}, "target": {"format": "uuid", "title": "Target", "type": "string"}, "target_handle": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "title": "Target Handle"}}, "required": ["id", "source", "target", "source_handle", "target_handle"], "title": "WireEdge", "type": "object"}, "WireNode": {"additionalProperties": false, "properties": {"data": {"$ref": "#/components/schemas/WireNodeData"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "kind": {"enum": ["behavior", "boundary", "state", "io", "interface"], "title": "Kind", "type": "string"}, "parent_id": {"anyOf": [{"format": "uuid", "type": "string"}, {"type": "null"}], "title": "Parent Id"}, "position": {"$ref": "#/components/schemas/Position"}}, "required": ["id", "kind", "parent_id", "position", "data"], "title": "WireNode", "type": "object"}, "WireNodeData": {"additionalProperties": false, "description": "Read-path node `data`. NOT `h2o.NodeData`: the response omits\n`kind`/`parent_id` (those live on the wrapper) and emits the boundary/\nexternal scalars matrix-conditionally \u2014 so the matrix fields are\noptional (absent = not-applicable-to-kind).", "properties": {"config": {"items": {"$ref": "#/components/schemas/WirePort"}, "title": "Config", "type": "array"}, "constraints": {"items": {"type": "string"}, "title": "Constraints", "type": "array"}, "description": {"title": "Description", "type": "string"}, "documentation_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Documentation Url"}, "external_kind": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "External Kind"}, "inputs": {"items": {"$ref": "#/components/schemas/WirePort"}, "title": "Inputs", "type": "array"}, "is_external": {"title": "Is External", "type": "boolean"}, "is_test_node": {"title": "Is Test Node", "type": "boolean"}, "language": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Language"}, "name": {"title": "Name", "type": "string"}, "outputs": {"items": {"$ref": "#/components/schemas/WirePort"}, "title": "Outputs", "type": "array"}, "path_prefix": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Path Prefix"}, "protocol": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Protocol"}, "source_decisions": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "default": null, "title": "Source Decisions"}, "status": {"title": "Status", "type": "string"}, "user_kind": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "User Kind"}, "verifications": {"items": {"$ref": "#/components/schemas/WireVerification"}, "title": "Verifications", "type": "array"}}, "required": ["name", "description", "status", "is_test_node", "is_external"], "title": "WireNodeData", "type": "object"}, "WirePort": {"additionalProperties": false, "properties": {"contract_name": {"anyOf": [{"maxLength": 500, "type": "string"}, {"type": "null"}], "default": null, "title": "Contract Name"}, "description": {"default": "", "title": "Description", "type": "string"}, "external": {"default": false, "title": "External", "type": "boolean"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "name": {"default": "", "title": "Name", "type": "string"}, "type": {"default": "", "title": "Type", "type": "string"}}, "required": ["id"], "title": "WirePort", "type": "object"}, "WireVerification": {"additionalProperties": false, "properties": {"author": {"enum": ["user", "agent"], "title": "Author", "type": "string"}, "id": {"format": "uuid", "title": "Id", "type": "string"}, "text": {"title": "Text", "type": "string"}, "type": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Type"}}, "required": ["id", "author", "text"], "title": "WireVerification", "type": "object"}}, "securitySchemes": {"Bearer": {"description": "API key. Send as `Authorization: Bearer `.", "scheme": "bearer", "type": "http"}}}, "info": {"description": "The hydrate.sh public API. All routes are scoped to the `/v1/` prefix and require a Bearer API key (see Authentication).\n\n**Stability:** `v1` is the wire-stable surface. Breaking changes will rebase to `/v2/`.", "title": "hydrate.sh v1 API", "version": "1.0.0-rc.1"}, "openapi": "3.1.0", "paths": {"/v1/__healthz": {"get": {"description": "Returns `{\"ok\": true}` when the /v1/ surface is serving traffic. Unauthenticated. Use this for load-balancer and uptime-monitor checks; a 404 from this path means the /v1/ surface is disabled on the responding instance, not that the server is down.", "operationId": "healthz_v1___healthz_get", "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HealthzResponse"}}}, "description": "Successful Response"}}, "security": [], "summary": "Liveness probe", "tags": ["health"]}}, "/v1/branches/{branch_id}/boundary/{node_id}": {"get": {"description": "Returns a boundary node, its direct children, and the edges between those children \u2014 on this branch. Edges that cross the boundary belong to the parent cell's inspection and are not returned.", "operationId": "fetch_branch_boundary_v1_branches__branch_id__boundary__node_id__get", "parameters": [{"description": "The branch to read.", "in": "path", "name": "branch_id", "required": true, "schema": {"description": "The branch to read.", "format": "uuid", "title": "Branch Id", "type": "string"}}, {"description": "The boundary node to inspect.", "in": "path", "name": "node_id", "required": true, "schema": {"description": "The boundary node to inspect.", "format": "uuid", "title": "Node Id", "type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BoundaryResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Read a boundary's children and interior edges on a branch", "tags": ["branches"]}}, "/v1/branches/{branch_id}/deltas": {"post": {"description": "Apply an ordered batch of structural changes to a branch under optimistic concurrency. Pass the `version` from the branch read as `expected_version`; a 409 with `current_version` is returned if the branch moved underneath you. Pair with the `Idempotency-Key` header for at-most-once apply across retries.", "operationId": "apply_branch_deltas_v1_branches__branch_id__deltas_post", "parameters": [{"description": "The branch to apply to.", "in": "path", "name": "branch_id", "required": true, "schema": {"description": "The branch to apply to.", "format": "uuid", "title": "Branch Id", "type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/V1DeltasBody"}}}, "required": true}, "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeltaApplyResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"content": {"application/json": {"schema": {"properties": {"detail": {"oneOf": [{"properties": {"code": {"type": "string"}, "message": {"type": "string"}}, "required": ["code", "message"], "type": "object"}, {"$ref": "#/components/schemas/NotFoundBody"}]}}, "required": ["detail"], "type": "object"}}}, "description": "Branch/project not found or not accessible, OR a delta targets a node that doesn't exist."}, "409": {"content": {"application/json": {"schema": {"properties": {"detail": {"oneOf": [{"$ref": "#/components/schemas/VersionConflictBody"}, {"$ref": "#/components/schemas/BranchNotActiveBody"}]}}, "required": ["detail"], "type": "object"}}}, "description": "OCC version conflict, or the branch is not active."}, "422": {"content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/HTTPValidationError"}, {"properties": {"detail": {"$ref": "#/components/schemas/DeltaFieldErrorBody"}}, "required": ["detail"], "type": "object"}]}}}, "description": "Request body failed validation, OR a delta is malformed / unknown / not an object."}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Apply a delta batch to a branch (OCC)", "tags": ["branches"]}}, "/v1/branches/{branch_id}/graph": {"get": {"description": "Read the full graph (nodes, ports, edges, version) of a specific branch. Use the returned `version` as `expected_version` on a subsequent apply to that branch.", "operationId": "fetch_branch_graph_v1_branches__branch_id__graph_get", "parameters": [{"description": "The branch to read.", "in": "path", "name": "branch_id", "required": true, "schema": {"description": "The branch to read.", "format": "uuid", "title": "Branch Id", "type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GraphResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Read a specific branch's graph", "tags": ["branches"]}}, "/v1/branches/{branch_id}/node/{node_id}": {"get": {"description": "Returns a node, every node one edge away from it, and those edges \u2014 on this branch. The scoped read an agent uses to load just the context around what it is about to change.", "operationId": "fetch_branch_node_v1_branches__branch_id__node__node_id__get", "parameters": [{"description": "The branch to read.", "in": "path", "name": "branch_id", "required": true, "schema": {"description": "The branch to read.", "format": "uuid", "title": "Branch Id", "type": "string"}}, {"description": "The node to read.", "in": "path", "name": "node_id", "required": true, "schema": {"description": "The node to read.", "format": "uuid", "title": "Node Id", "type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/NodeNeighborhoodResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Read a node and its 1-hop neighborhood on a specific branch", "tags": ["branches"]}}, "/v1/branches/{branch_id}/subtree/{node_id}": {"get": {"description": "Returns a node and its descendants down to `depth` levels on this branch, with the edges among them. The scoped counterpart to `GET /v1/branches/{branch_id}/graph`: bounded by `depth` rather than by the size of the branch, so reading a slice of a large project never puts the whole graph on the wire.", "operationId": "fetch_branch_subtree_v1_branches__branch_id__subtree__node_id__get", "parameters": [{"description": "The branch to read.", "in": "path", "name": "branch_id", "required": true, "schema": {"description": "The branch to read.", "format": "uuid", "title": "Branch Id", "type": "string"}}, {"description": "The node to root the subtree at.", "in": "path", "name": "node_id", "required": true, "schema": {"description": "The node to root the subtree at.", "format": "uuid", "title": "Node Id", "type": "string"}}, {"description": "How many levels of descendants to include. 1 = direct children only.", "in": "query", "name": "depth", "required": false, "schema": {"default": 1, "description": "How many levels of descendants to include. 1 = direct children only.", "maximum": 32, "minimum": 1, "title": "Depth", "type": "integer"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SubtreeResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Read a node's subtree on a specific branch, to a bounded depth", "tags": ["branches"]}}, "/v1/branches/{branch_id}/validate": {"post": {"description": "Dry-run a delta batch and get back the full coherence report over the graph that would result \u2014 never mutating the branch. Returns 200 with `{valid, findings}`: `findings` is the complete list of coherence problems (an input port with no incoming edge, a wire pinned to a missing port, or an edge whose endpoint types differ), and `valid` is true only when there are no error-severity findings. The verdict is in the body, so an incoherent graph is still a 200. A 4xx is returned only for a request that can't be processed at all (a malformed body or an unknown delta type). An empty delta list reports the branch's current coherence.", "operationId": "validate_branch_deltas_v1_branches__branch_id__validate_post", "parameters": [{"description": "The branch to validate against.", "in": "path", "name": "branch_id", "required": true, "schema": {"description": "The branch to validate against.", "format": "uuid", "title": "Branch Id", "type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/V1ValidateBody"}}}, "required": true}, "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ValidateResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"content": {"application/json": {"schema": {"properties": {"detail": {"oneOf": [{"properties": {"code": {"type": "string"}, "message": {"type": "string"}}, "required": ["code", "message"], "type": "object"}, {"$ref": "#/components/schemas/NotFoundBody"}]}}, "required": ["detail"], "type": "object"}}}, "description": "Branch/project not found or not accessible, OR a delta targets a node that doesn't exist."}, "422": {"content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/HTTPValidationError"}, {"properties": {"detail": {"$ref": "#/components/schemas/DeltaFieldErrorBody"}}, "required": ["detail"], "type": "object"}]}}}, "description": "Request body failed validation, OR a delta is malformed / unknown / not an object."}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Dry-run a delta batch and report coherence findings", "tags": ["branches"]}}, "/v1/graph/{project_id}": {"get": {"description": "Returns the active main-branch graph (nodes, ports, edges, version) for the project. Read-only: the main branch is merge-only. To make changes, fork a branch (`POST /v1/projects/{project_id}/branches`) and apply deltas to it.", "operationId": "fetch_graph_v1_graph__project_id__get", "parameters": [{"description": "The project whose main-branch graph to fetch.", "in": "path", "name": "project_id", "required": true, "schema": {"description": "The project whose main-branch graph to fetch.", "format": "uuid", "title": "Project Id", "type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GraphResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Get the canonical graph for a project", "tags": ["graph"]}}, "/v1/graph/{project_id}/boundary/{node_id}": {"get": {"description": "Returns the boundary view for a single node: the inputs and outputs that cross the node's enforcement perimeter. Used by clients that visualize a node's contract surface in isolation from the rest of the graph.", "operationId": "fetch_boundary_v1_graph__project_id__boundary__node_id__get", "parameters": [{"description": "The project the boundary lives in.", "in": "path", "name": "project_id", "required": true, "schema": {"description": "The project the boundary lives in.", "format": "uuid", "title": "Project Id", "type": "string"}}, {"description": "The boundary node to inspect.", "in": "path", "name": "node_id", "required": true, "schema": {"description": "The boundary node to inspect.", "format": "uuid", "title": "Node Id", "type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BoundaryResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Get the boundary topology around a node", "tags": ["graph"]}}, "/v1/graph/{project_id}/node/{node_id}": {"get": {"description": "Returns a single node along with its directly connected neighbors and edges (the 1-hop topology). The node must belong to the project's active main branch.", "operationId": "fetch_node_with_neighbors_v1_graph__project_id__node__node_id__get", "parameters": [{"description": "The project the node lives in.", "in": "path", "name": "project_id", "required": true, "schema": {"description": "The project the node lives in.", "format": "uuid", "title": "Project Id", "type": "string"}}, {"description": "The node to fetch.", "in": "path", "name": "node_id", "required": true, "schema": {"description": "The node to fetch.", "format": "uuid", "title": "Node Id", "type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/NodeNeighborhoodResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Get a node with its 1-hop neighborhood", "tags": ["graph"]}}, "/v1/graph/{project_id}/subtree/{node_id}": {"get": {"description": "Returns a node and its descendants down to ``depth`` levels, with the edges among them. The scoped read for working inside a large project: unlike the full graph read, the response is bounded by ``depth`` rather than by the size of the branch, so a slice of a big graph never puts the whole graph on the wire.", "operationId": "fetch_subtree_v1_graph__project_id__subtree__node_id__get", "parameters": [{"description": "The project the node lives in.", "in": "path", "name": "project_id", "required": true, "schema": {"description": "The project the node lives in.", "format": "uuid", "title": "Project Id", "type": "string"}}, {"description": "The node to root the subtree at.", "in": "path", "name": "node_id", "required": true, "schema": {"description": "The node to root the subtree at.", "format": "uuid", "title": "Node Id", "type": "string"}}, {"description": "How many levels of descendants to include. 1 = direct children only. Levels below the cut are omitted and ``truncated`` reports that they exist.", "in": "query", "name": "depth", "required": false, "schema": {"default": 1, "description": "How many levels of descendants to include. 1 = direct children only. Levels below the cut are omitted and ``truncated`` reports that they exist.", "maximum": 32, "minimum": 1, "title": "Depth", "type": "integer"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SubtreeResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Get a node's subtree to a bounded depth", "tags": ["graph"]}}, "/v1/projects": {"get": {"description": "Returns the projects the caller can see. For API-key callers the list is further filtered by the per-key project allowlist (if configured). Archived projects are excluded. An empty list is a normal response when the caller has no visible projects.", "operationId": "list_projects_v1_projects_get", "parameters": [{"description": "Maximum projects to return. Defaults to 100; max 200.", "in": "query", "name": "limit", "required": false, "schema": {"default": 100, "description": "Maximum projects to return. Defaults to 100; max 200.", "maximum": 200, "minimum": 1, "title": "Limit", "type": "integer"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectsListResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "List your projects", "tags": ["projects"]}, "post": {"description": "Create a new project owned by the caller. Creating a project also seeds its protected `main` branch in the same transaction, so the returned `main_branch` can be forked immediately (via `POST /v1/projects/{project_id}/branches`) with no second round-trip. The name must be unique among your active projects (case-insensitive); a collision returns 409 `name_taken`. Requires the `graph:write` scope.", "operationId": "create_project_v1_projects_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/V1CreateProjectBody"}}}, "required": true}, "responses": {"201": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectCreateResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "409": {"$ref": "#/components/responses/ProjectCreateConflict"}, "422": {"$ref": "#/components/responses/ProjectCreateUnprocessable"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Create a project", "tags": ["projects"]}}, "/v1/projects/{project_id}/branches": {"get": {"description": "List your active working branches on this project. The protected main branch is not included. Each branch is a unit of work you can read and apply changes to before merging into main.", "operationId": "list_branches_v1_projects__project_id__branches_get", "parameters": [{"description": "The project to list branches for.", "in": "path", "name": "project_id", "required": true, "schema": {"description": "The project to list branches for.", "format": "uuid", "title": "Project Id", "type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BranchListResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "List the caller's working branches on a project", "tags": ["branches"]}, "post": {"description": "Fork a new working branch from the project's main branch. The branch is copied from main's current state and owned by you; your changes stay isolated on it until merged. Requires an editor role, and there is a per-project limit on how many active branches you can hold at once.", "operationId": "create_branch_v1_projects__project_id__branches_post", "parameters": [{"description": "The project to fork a branch in.", "in": "path", "name": "project_id", "required": true, "schema": {"description": "The project to fork a branch in.", "format": "uuid", "title": "Project Id", "type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/V1CreateBranchBody"}}}, "required": true}, "responses": {"201": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BranchCreateResponse"}}}, "description": "Successful Response"}, "401": {"$ref": "#/components/responses/Unauthenticated"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "422": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}, "description": "Validation Error"}, "429": {"$ref": "#/components/responses/RateLimited"}}, "summary": "Start a unit of work (fork a branch from main)", "tags": ["branches"]}}}, "security": [{"Bearer": []}], "servers": [{"description": "Production", "url": "https://api.hydrate.sh"}], "tags": [{"description": "Work on a branch (a unit of work) rather than the protected main branch: fork a branch from main, list your branches, and read / validate / apply deltas against a specific branch.", "name": "branches"}, {"description": "Read the canonical graph for a project, validate a delta batch (dry-run), or apply a delta batch through the OCC + idempotency middleware chain.", "name": "graph"}, {"description": "Liveness probes. Unauthenticated. Used by load balancers and uptime monitors.", "name": "health"}, {"description": "List the projects your principal can see. Filtered by membership + (for API keys) the per-key project allowlist.", "name": "projects"}]} \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs index d3ec936..db1088a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -135,6 +135,24 @@ pub struct ShowArgs { /// else the project's main branch. #[arg(long)] pub branch: Option, + + /// Read only this many levels below PATH, fetching just that slice instead + /// of the whole branch. 1 is direct children. Requires PATH and a working + /// copy that has been pulled (the local index is what turns the path into + /// the id the scoped read needs); without one, the whole graph is fetched + /// and filtered locally, and that is reported. + #[arg( + long, + value_name = "N", + // Without PATH there is nothing to root a slice at, and silently + // ignoring the flag would fetch the whole branch while the user + // believes they bounded it. + requires = "path", + // The server pins 1..=32; checking here turns a round trip and a raw + // 422 into an immediate, readable error. + value_parser = clap::value_parser!(u32).range(1..=32), + )] + pub depth: Option, } #[derive(Debug, Args)] diff --git a/src/client/mod.rs b/src/client/mod.rs index 3abceab..7213a56 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -137,6 +137,78 @@ impl Client { .map_err(CliError::from) } + /// Read one node's subtree on `branch_id`, to a bounded `depth` — a + /// SCOPED read. + /// + /// Unlike [`Client::fetch_branch_graph`], the response is bounded by the + /// depth requested rather than by the size of the branch, so reading a + /// slice of a large project does not put the whole graph on the wire. + /// `depth` 1 is direct children. + /// + /// Branch-addressed on purpose: the `/v1/graph/{project_id}/...` twins + /// resolve the project's MAIN branch, and edits happen on working + /// branches, so those cannot serve an authoring client at all. + pub fn fetch_branch_subtree( + &self, + branch_id: Uuid, + node_id: Uuid, + depth: u32, + ) -> Result { + let params = branches_api::FetchBranchSubtreeV1BranchesBranchIdSubtreeNodeIdGetParams { + branch_id: branch_id.to_string(), + node_id: node_id.to_string(), + depth: Some(depth), + }; + self.rt + .block_on( + branches_api::fetch_branch_subtree_v1_branches_branch_id_subtree_node_id_get( + &self.cfg, params, + ), + ) + .map_err(CliError::from) + } + + /// Read one node and its 1-hop neighborhood on `branch_id` — the scoped + /// read behind `walk`. Only the node, its immediate neighbors, and the + /// edges between them cross the wire. + pub fn fetch_branch_node( + &self, + branch_id: Uuid, + node_id: Uuid, + ) -> Result { + let params = branches_api::FetchBranchNodeV1BranchesBranchIdNodeNodeIdGetParams { + branch_id: branch_id.to_string(), + node_id: node_id.to_string(), + }; + self.rt + .block_on( + branches_api::fetch_branch_node_v1_branches_branch_id_node_node_id_get( + &self.cfg, params, + ), + ) + .map_err(CliError::from) + } + + /// Read a boundary's direct children and their interior edges on + /// `branch_id` — the scoped read behind `walk --boundary`. + pub fn fetch_branch_boundary( + &self, + branch_id: Uuid, + node_id: Uuid, + ) -> Result { + let params = branches_api::FetchBranchBoundaryV1BranchesBranchIdBoundaryNodeIdGetParams { + branch_id: branch_id.to_string(), + node_id: node_id.to_string(), + }; + self.rt + .block_on( + branches_api::fetch_branch_boundary_v1_branches_branch_id_boundary_node_id_get( + &self.cfg, params, + ), + ) + .map_err(CliError::from) + } + /// Dry-run a typed delta batch against `branch_id` and get back the coherence /// findings — never mutating the branch. Mirrors [`Client::apply_deltas`], but /// the server rolls back before commit, so it carries no optimistic-concurrency diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index e8db29f..b5a30d8 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -24,6 +24,7 @@ mod init; mod node; mod projects; mod pull; +mod scoped; mod show; mod status; mod validate; diff --git a/src/cmd/scoped.rs b/src/cmd/scoped.rs new file mode 100644 index 0000000..730160a --- /dev/null +++ b/src/cmd/scoped.rs @@ -0,0 +1,258 @@ +//! Shared plumbing for the SCOPED reads (`walk`, `show --depth`). +//! +//! A scoped read is addressed by node **id**, but the CLI addresses nodes by +//! dotted **path** — so the pulled index is what makes one possible without +//! first fetching the graph we are trying to avoid fetching. +//! +//! When the index can't answer, the caller falls back to the whole-graph read. +//! That is a real fallback, not a failure: asking for a slice should never +//! deny you your graph. It IS reported, because the whole point of a scoped +//! read is what crosses the wire, and silently fetching everything would look +//! identical to succeeding. + +use std::path::Path; + +use uuid::Uuid; + +use crate::error::CliError; +use crate::state::Index; + +/// What a read decided to do, before any network call. +/// +/// Separated from the I/O so the decision is testable on its own. Without +/// this the dispatch is only reachable through a live request, which means +/// "did we actually stop fetching the whole graph?" — the entire point of a +/// scoped read — cannot be asserted anywhere. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Plan { + /// Read just the slice rooted at this node. + Scoped(Uuid), + /// Fetch the whole branch and filter locally, for the stated reason. + WholeGraph(Fallback), +} + +/// Why a scoped read wasn't possible. Distinct variants because the remedies +/// differ: a pull fixes a missing index, and nothing fixes a typo but retyping. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Fallback { + /// Not inside a working copy — `show` may be driven by `--project`. + NoWorkingCopy, + /// A working copy with no index yet: never pulled. + NoIndex, + /// An index that doesn't know this path: stale, or a typo. + PathNotInIndex, + /// The read targets a branch other than the one this copy is bound to, so + /// the index — which records no branch identity — cannot be trusted for it. + NotTheBoundBranch, +} + +/// Decide how to read `path`, given the working copy root and whether the +/// request targets the bound branch. +/// +/// `on_bound_branch` matters because [`Index`] stores only `node:` → id +/// with no branch identity: it is implicitly the BOUND branch's. Resolving a +/// path through it and then reading a different branch can silently return a +/// different node, so that combination falls back instead. +pub(crate) fn plan( + base: Option<&Path>, + path: &str, + on_bound_branch: bool, +) -> Result { + let Some(base) = base else { + return Ok(Plan::WholeGraph(Fallback::NoWorkingCopy)); + }; + if !on_bound_branch { + return Ok(Plan::WholeGraph(Fallback::NotTheBoundBranch)); + } + let Some(index) = Index::load(base)? else { + return Ok(Plan::WholeGraph(Fallback::NoIndex)); + }; + match index.entries.get(&format!("node:{path}")) { + Some(id) => Ok(Plan::Scoped(*id)), + None => Ok(Plan::WholeGraph(Fallback::PathNotInIndex)), + } +} + +/// The working-copy root, or `None` when this directory is not one. `show` +/// deliberately works outside a working copy (it takes `--project`), so a +/// missing root is an ordinary state rather than an error. +pub(crate) fn base_dir() -> Option { + super::context::cwd() + .ok() + .and_then(|c| crate::state::find_root(&c)) +} + +/// The note printed when a scoped read wasn't possible. +/// +/// Names the actual cause. Telling someone to `hydrate pull` when they simply +/// mistyped a path sends them to the wrong fix, and the real error arrives a +/// moment later from the fallback read. +pub(crate) fn fallback_note(path: &str, why: Fallback) -> String { + match why { + Fallback::NoWorkingCopy => format!( + "note: not in a working copy, so the whole branch was fetched and \ + filtered here to find '{path}'." + ), + Fallback::NoIndex => format!( + "note: this working copy has no local index, so the whole branch \ + was fetched to find '{path}'. Run `hydrate pull` to read just the \ + slice next time." + ), + Fallback::PathNotInIndex => format!( + "note: '{path}' is not in this working copy's index, so the whole \ + branch was fetched. If it exists on the branch, run `hydrate pull` \ + — the index may be behind." + ), + Fallback::NotTheBoundBranch => format!( + "note: reading a branch other than the one bound here, so the local \ + index cannot resolve '{path}' and the whole branch was fetched." + ), + } +} + +/// How to display a node the server could not give a dotted path. +/// +/// The server returns such nodes deliberately — an unnamed node is legal while +/// designing — and reports why. Rendering the reason beats both a panic (the +/// path map is not total, so indexing it crashes on an ordinary graph) and a +/// fabricated path (which the caller would paste into the next command). +pub(crate) fn unaddressable_label(reason: &str) -> String { + match reason { + "empty_name" => "".to_string(), + "reserved_separator" => "".to_string(), + "ambiguous" => "".to_string(), + // An unrecognised reason is preserved rather than collapsed: a newer + // server may report a cause this build predates, and hiding it would + // leave the user with no way to learn what to fix. + other => format!("", sanitize(other)), + } +} + +/// Strip control characters from a server-supplied string before it reaches a +/// terminal. +/// +/// Node names have no charset validation server-side, and they flow into the +/// dotted paths and reasons rendered here. Without this, a name carrying ANSI +/// escapes or a newline can forge report lines or repaint the screen — and the +/// realistic source isn't a hostile collaborator, it's an LLM naming nodes from +/// imported third-party content. +pub(crate) fn sanitize(text: &str) -> String { + text.chars() + .map(|c| { + if c.is_control() { + char::REPLACEMENT_CHARACTER + } else { + c + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_index(entries: &[(&str, Uuid)]) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let hy = dir.path().join(".hydrate"); + std::fs::create_dir_all(&hy).unwrap(); + let map: std::collections::BTreeMap = entries + .iter() + .map(|(k, v)| ((*k).to_string(), *v)) + .collect(); + let index = serde_json::json!({ + "version": 2, "entries": map, "node_info": {}, "edges": {}, + }); + std::fs::write(hy.join("index.json"), index.to_string()).unwrap(); + dir + } + + #[test] + fn no_working_copy_falls_back() { + assert_eq!( + plan(None, "Api", true).unwrap(), + Plan::WholeGraph(Fallback::NoWorkingCopy), + ); + } + + #[test] + fn a_different_branch_falls_back_rather_than_trusting_the_index() { + // The index records no branch identity, so resolving a path through it + // and reading a DIFFERENT branch can return a different node entirely. + let dir = write_index(&[("node:Api", Uuid::from_u128(1))]); + assert_eq!( + plan(Some(dir.path()), "Api", false).unwrap(), + Plan::WholeGraph(Fallback::NotTheBoundBranch), + ); + } + + #[test] + fn a_working_copy_with_no_index_falls_back() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".hydrate")).unwrap(); + assert_eq!( + plan(Some(dir.path()), "Api", true).unwrap(), + Plan::WholeGraph(Fallback::NoIndex), + ); + } + + #[test] + fn a_path_the_index_does_not_know_falls_back() { + let dir = write_index(&[("node:Api", Uuid::from_u128(1))]); + assert_eq!( + plan(Some(dir.path()), "Nope", true).unwrap(), + Plan::WholeGraph(Fallback::PathNotInIndex), + ); + } + + #[test] + fn a_known_path_on_the_bound_branch_is_read_scoped() { + let id = Uuid::from_u128(7); + let dir = write_index(&[("node:Api.Rater", id)]); + assert_eq!( + plan(Some(dir.path()), "Api.Rater", true).unwrap(), + Plan::Scoped(id), + ); + } + + #[test] + fn every_fallback_reason_names_its_own_cause() { + // A note that blames the wrong cause sends the user to the wrong fix. + for (why, needle) in [ + (Fallback::NoWorkingCopy, "not in a working copy"), + (Fallback::NoIndex, "no local index"), + (Fallback::PathNotInIndex, "not in this working copy's index"), + (Fallback::NotTheBoundBranch, "other than the one bound"), + ] { + let note = fallback_note("Api", why); + assert!(note.contains(needle), "{why:?} -> {note}"); + assert!(note.contains("Api"), "{why:?} must name the path"); + } + } + + #[test] + fn every_unaddressable_reason_renders_something_actionable() { + for (reason, needle) in [ + ("empty_name", "give it a name"), + ("reserved_separator", "contains '.'"), + ("ambiguous", "share a name"), + ("something_new", "no path: something_new"), + ] { + let label = unaddressable_label(reason); + assert!(label.contains(needle), "{reason} -> {label}"); + } + } + + #[test] + fn an_unknown_reason_cannot_repaint_the_terminal() { + let label = unaddressable_label("\u{1b}[2Kforged"); + assert!(!label.contains('\u{1b}'), "{label:?}"); + } + + #[test] + fn sanitize_strips_controls_but_keeps_ordinary_text() { + assert_eq!(sanitize("Api.Rater"), "Api.Rater"); + assert_eq!(sanitize("a\nb"), "a\u{fffd}b"); + assert_eq!(sanitize("caf\u{e9}"), "caf\u{e9}"); + } +} diff --git a/src/cmd/show.rs b/src/cmd/show.rs index 20121e9..f0d487c 100644 --- a/src/cmd/show.rs +++ b/src/cmd/show.rs @@ -15,6 +15,7 @@ use hydrate_wire::models::{BranchMeta, GraphResponse}; use uuid::Uuid; use super::context::{choose_selection, current_binding, env_project, resolve_project}; +use super::scoped; use super::view::{self, EdgeView, NodeView}; use crate::client::Client; use crate::config::Config; @@ -48,6 +49,28 @@ pub fn run( let branches = client.list_branches(project.id)?.branches; let (branch_id, branch_name) = pick_branch(&branches, args.branch.as_deref(), bound)?; + // --depth asks for a SCOPED read: fetch only the slice, not the branch. + // Gated on `bound == Some(branch_id)` because the local index records no + // branch identity — it is implicitly the BOUND branch's, so resolving a + // path through it and then reading a different branch could return a + // different node under the name the user typed. + if let (Some(depth), Some(path)) = (args.depth, args.path.as_deref()) { + let on_bound_branch = bound == Some(branch_id); + match scoped::plan(scoped::base_dir().as_deref(), path, on_bound_branch)? { + scoped::Plan::Scoped(node_id) => { + let subtree = client.fetch_branch_subtree(branch_id, node_id, depth)?; + println!( + "{}", + render_subtree(&subtree, &project.name, &branch_name, path, mode)? + ); + return Ok(()); + } + scoped::Plan::WholeGraph(why) => { + eprintln!("{}", scoped::fallback_note(path, why)); + } + } + } + // The one and only network read of graph content — and it is a GET. let graph = client.fetch_branch_graph(branch_id)?; println!( @@ -120,8 +143,14 @@ fn render( filter: Option<&str>, mode: OutputMode, ) -> Result { - let view = build_view(graph, filter)?; - Ok(match mode { + let view = build_view(graph, filter, None, None)?; + Ok(render_view(&view, project_name, branch_name, mode)) +} + +/// Turn a built [`View`] into output. Split from [`render`] so the scoped read +/// can reuse it with server-supplied paths instead of local reconstruction. +fn render_view(view: &View, project_name: &str, branch_name: &str, mode: OutputMode) -> String { + match mode { OutputMode::Json => serde_json::json!({ "project": project_name, "branch": branch_name, @@ -130,16 +159,44 @@ fn render( "cross_boundary_edges": view.cross_boundary, }) .to_string(), - OutputMode::Human => human(&view, project_name, branch_name), - }) + OutputMode::Human => human(view, project_name, branch_name), + } } /// Build the display view from the fetched graph: reconstruct each node's dotted /// path, project its ports, translate edge handles back to dotted port paths, /// and (when `filter` is set) narrow to that node's subtree. -fn build_view(graph: &GraphResponse, filter: Option<&str>) -> Result { - // node id -> dotted path (shared reconstruction, also used by `walk`). - let paths = view::node_paths(&graph.nodes)?; +fn build_view( + graph: &GraphResponse, + filter: Option<&str>, + // Server-rendered paths, when the caller has them. A SCOPED read cannot + // reconstruct paths locally: the slice does not contain the ancestors a + // dotted path is built from, so `node_paths` fails with "references a + // missing parent". The server holds the whole branch and is the only party + // that can answer. + server_paths: Option<&HashMap>, + // Reasons for nodes the server could not address. A slice legitimately + // contains them (an unnamed node is legal while designing), so they must + // RENDER — skipping drops their ports from the port table and the next + // edge lookup then fails blaming the server. + unaddressable: Option<&HashMap>, +) -> Result { + // node id -> dotted path (local reconstruction for the whole-graph read). + let mut paths = match server_paths { + Some(p) => p.clone(), + None => view::node_paths(&graph.nodes)?, + }; + // Fill in a label for every node the server could not path, so the map is + // total from here down and nothing has to guess whether indexing is safe. + if let Some(un) = unaddressable { + for node in &graph.nodes { + paths.entry(node.id).or_insert_with(|| { + scoped::unaddressable_label( + un.get(&node.id).map(String::as_str).unwrap_or("unknown"), + ) + }); + } + } // port id -> (owning node's dotted path, port name). let mut port_owner: HashMap)> = HashMap::new(); @@ -253,8 +310,123 @@ fn human(view: &View, project_name: &str, branch_name: &str) -> String { out } +/// Re-key a server map by `Uuid`, failing loud on a key that isn't one. +/// +/// Dropping an unparseable key would silently turn "the server addressed this +/// node" into "this node has no path" — the same silent-drop the port resolver +/// two functions away explicitly refuses. +fn uuid_keyed( + map: &std::collections::HashMap, + field: &str, +) -> Result, CliError> { + map.iter() + .map(|(k, v)| { + Uuid::parse_str(k).map(|id| (id, v.clone())).map_err(|_| { + CliError::State(format!( + "the server sent `{field}` keyed by a non-uuid '{k}'" + )) + }) + }) + .collect() +} + +/// Render a scoped subtree through the SAME renderer the whole-graph view uses, +/// so the two are indistinguishable in shape to a reader or a parser. +/// +/// The subtree is repackaged as a `GraphResponse` carrying exactly the node set +/// the server returned. It is NOT re-filtered locally: the server already +/// scoped it, and filtering by path prefix would drop precisely the nodes it +/// could not give a path — the ones the user needs to see in order to fix them. +/// Only what the scoped read uniquely knows is added on top: which slice this +/// is, how deep, whether it was CUT, and what could not be addressed. +fn render_subtree( + subtree: &hydrate_wire::models::SubtreeResponse, + project_name: &str, + branch_name: &str, + path: &str, + mode: OutputMode, +) -> Result { + let mut nodes = vec![(*subtree.root).clone()]; + nodes.extend(subtree.nodes.iter().cloned()); + // NOT merged with cross_boundary_edges: by definition those have one + // endpoint outside the slice, so the port resolver cannot place them and + // errors blaming the server. The server already classified them; take its + // count rather than re-deriving one we cannot compute here. + let edges = subtree.edges.clone(); + + let graph = GraphResponse { + branch: subtree.branch.clone(), + edges, + nodes, + project_id: subtree.project_id, + version: subtree.version.clone(), + }; + // Server-rendered paths — the whole reason the scoped read returns them. + // Reconstructing here fails: the slice has no ancestors to walk. + let server_paths = uuid_keyed(&subtree.paths, "paths")?; + let unaddressable = uuid_keyed(&subtree.unaddressable, "unaddressable")?; + // No local filter: the SERVER already scoped this response to the slice, + // and filtering by path prefix would drop exactly the nodes it could not + // give a path — the ones the user most needs to see in order to fix them. + let mut view = build_view(&graph, None, Some(&server_paths), Some(&unaddressable))?; + // The server counted the edges leaving this slice; we cannot. + view.cross_boundary = subtree.cross_boundary_edges.len(); + let rendered = render_view(&view, project_name, branch_name, mode); + + match mode { + OutputMode::Json => { + // Augment rather than re-derive, so the scoped payload stays a + // superset of the familiar one. + let mut v: serde_json::Value = serde_json::from_str(&rendered) + .map_err(|e| CliError::Other(format!("rendering the subtree: {e}")))?; + // Says a slice was read, and which one. Without this a scoped + // read and a whole-graph fetch are indistinguishable on stdout — + // the one thing the flag exists to control. + v["scoped"] = serde_json::json!(true); + v["root"] = serde_json::json!(path); + v["depth"] = serde_json::json!(subtree.depth); + v["truncated"] = serde_json::json!(subtree.truncated); + // Same information `walk` reports: which nodes here cannot be + // addressed, and why. Labels, not ids — this CLI does not surface + // node ids, and an id joins to nothing else in the payload. + v["unaddressable"] = serde_json::json!(unaddressable + .values() + .map(|reason| serde_json::json!({ + "label": scoped::unaddressable_label(reason), + "reason": scoped::sanitize(reason), + })) + .collect::>()); + Ok(v.to_string()) + } + OutputMode::Human => { + let mut out = format!( + "slice of '{path}', {} level(s) deep\n{rendered}", + subtree.depth, + ); + if !unaddressable.is_empty() { + out.push_str(&format!( + "\n{} node(s) here have no addressable path — name them to \ + reference them in other commands.\n", + unaddressable.len() + )); + } + if subtree.truncated { + // Reported, never implied: a reader who cannot tell a complete + // cell from a slice will treat the slice as complete. + out.push_str(&format!( + "\n(cut at depth {} — there are more nodes below; raise --depth to see them)\n", + subtree.depth + )); + } + Ok(out) + } + } +} + #[cfg(test)] mod tests { + use hydrate_wire::models::SubtreeResponse; + use super::*; use hydrate_wire::models::{ self, BranchRef, Position, WireEdge, WireNode, WireNodeData, WirePort, @@ -315,7 +487,7 @@ mod tests { /// Api (boundary) { Maker (behavior, out dog:HotDog), Rater (behavior, in /// raw:HotDog, out score:Score) }, edge Maker.dog -> Rater.raw. - fn sample_graph() -> GraphResponse { + pub(super) fn sample_graph() -> GraphResponse { use models::wire_node::Kind; let maker_out = port(0xD0, "dog", "HotDog"); let rater_in = port(0xF0, "raw", "HotDog"); @@ -688,4 +860,198 @@ mod tests { "leaked node uuid" ); } + + fn subtree_of(sample: &GraphResponse, root_idx: usize, truncated: bool) -> SubtreeResponse { + SubtreeResponse { + branch: sample.branch.clone(), + cross_boundary_edges: vec![], + depth: 1, + edges: vec![], + nodes: vec![], + project_id: sample.project_id, + root: Box::new(sample.nodes[root_idx].clone()), + truncated, + version: sample.version.clone(), + // The server always sends a path for every returned node; an + // empty map would model a server that doesn't, which the contract + // no longer permits. + paths: sample + .nodes + .iter() + .map(|n| (n.id.to_string(), n.data.name.clone())) + .collect(), + unaddressable: Default::default(), + } + } + + #[test] + fn a_cut_subtree_says_so_in_both_modes() { + // The signal that separates "this is the cell" from "this is a slice". + // A reader who cannot tell them apart will act on a partial spec. + let g = sample_graph(); + let cut = subtree_of(&g, 0, true); + let human = render_subtree(&cut, "proj", "br", "Api", OutputMode::Human).unwrap(); + assert!(human.contains("cut at depth 1"), "{human}"); + + let json: serde_json::Value = serde_json::from_str( + &render_subtree(&cut, "proj", "br", "Api", OutputMode::Json).unwrap(), + ) + .unwrap(); + assert_eq!(json["truncated"], true); + assert_eq!(json["depth"], 1); + } + + #[test] + fn a_complete_subtree_makes_no_truncation_claim() { + let g = sample_graph(); + let whole = subtree_of(&g, 0, false); + let human = render_subtree(&whole, "proj", "br", "Api", OutputMode::Human).unwrap(); + assert!(!human.contains("cut at depth"), "{human}"); + + let json: serde_json::Value = serde_json::from_str( + &render_subtree(&whole, "proj", "br", "Api", OutputMode::Json).unwrap(), + ) + .unwrap(); + assert_eq!(json["truncated"], false); + } + + #[test] + fn the_scoped_json_is_a_superset_of_the_whole_graph_json() { + // The two views must be interchangeable to a parser — the scoped read + // adds fields, it does not reshape the payload. + let g = sample_graph(); + let scoped: serde_json::Value = serde_json::from_str( + &render_subtree( + &subtree_of(&g, 0, false), + "proj", + "br", + "Api", + OutputMode::Json, + ) + .unwrap(), + ) + .unwrap(); + let full: serde_json::Value = + serde_json::from_str(&render(&g, "proj", "br", Some("Api"), OutputMode::Json).unwrap()) + .unwrap(); + for key in full.as_object().unwrap().keys() { + assert!(scoped.get(key).is_some(), "scoped view dropped '{key}'"); + } + } + + #[test] + fn no_working_copy_means_no_scoped_read() { + // No index means no id, which must degrade to the whole-graph read + // rather than erroring — asking for a slice never denies you the graph. + assert_eq!( + scoped::plan(None, "Api", true).unwrap(), + scoped::Plan::WholeGraph(scoped::Fallback::NoWorkingCopy), + ); + } +} + +#[cfg(test)] +mod scoped_subtree_tests { + use super::tests::sample_graph; + use super::*; + use hydrate_wire::models::SubtreeResponse; + + /// A REAL slice: a root whose own parent is outside the slice, a child, an + /// interior edge, and an edge leaving the subtree. The previous fixture was + /// a single parentless node with no edges, which is why it could not reach + /// either crash — local reconstruction succeeds on it, so the server-path + /// branch was never load-bearing. + fn real_subtree(unaddressable: Vec<(uuid::Uuid, &str)>, crossing: usize) -> SubtreeResponse { + let g = sample_graph(); + let root = g.nodes[0].clone(); + let child = g.nodes[1].clone(); + let mut paths = std::collections::HashMap::new(); + // Server paths are FULL and absolute — note the ancestor `Outer` is + // not in the slice at all, which is exactly what local reconstruction + // cannot do. + paths.insert(root.id.to_string(), "Outer.Api".to_string()); + paths.insert(child.id.to_string(), "Outer.Api.Maker".to_string()); + let mut un = std::collections::HashMap::new(); + for (id, reason) in &unaddressable { + paths.remove(&id.to_string()); + un.insert(id.to_string(), (*reason).to_string()); + } + SubtreeResponse { + branch: g.branch.clone(), + cross_boundary_edges: g.edges.iter().take(crossing).cloned().collect(), + depth: 1, + edges: vec![], + nodes: vec![child], + project_id: g.project_id, + root: Box::new(root), + truncated: false, + version: g.version.clone(), + paths, + unaddressable: un, + } + } + + #[test] + fn a_slice_whose_ancestors_are_absent_renders_the_server_paths() { + // The bug this whole change exists to fix: local reconstruction here + // fails with "references a missing parent", because `Outer` is not in + // the payload. + let st = real_subtree(vec![], 0); + let out = render_subtree(&st, "proj", "br", "Outer.Api", OutputMode::Json).unwrap(); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + let rendered = v.to_string(); + assert!(rendered.contains("Outer.Api"), "{rendered}"); + } + + #[test] + fn an_unaddressable_node_renders_instead_of_panicking() { + // `paths` is deliberately not total; indexing it aborts the process. + let g = sample_graph(); + let child_id = g.nodes[1].id; + let st = real_subtree(vec![(child_id, "empty_name")], 0); + let out = render_subtree(&st, "proj", "br", "Outer.Api", OutputMode::Human) + .expect("must render, not panic"); + assert!(out.contains(" Result<(), CliError> let config = Config::load()?; let client = Client::new(&config)?; - // `walk` reads the bound branch — the same whole-graph read `show`/`pull` - // use — then slices it locally. Reading the project's main-branch graph - // instead would return stale or wrong data on any diverged branch. + // SCOPED read first: fetch only the slice. Before this, `walk` fetched the + // WHOLE branch graph and sliced it locally — "scoped" was true of the + // output and false of the request. It needs the node's id, which the + // pulled index supplies; `walk` always reads the branch it is bound to, so + // the index (which records no branch identity of its own) applies. + match scoped::plan(Some(&base), &args.path, true)? { + scoped::Plan::Scoped(node_id) => { + let out = if args.boundary { + let cell = client.fetch_branch_boundary(binding.branch_id, node_id)?; + render_boundary_scoped(&cell, &args.path, mode)? + } else { + let hood = client.fetch_branch_node(binding.branch_id, node_id)?; + render_neighborhood_scoped(&hood, &args.path, mode)? + }; + println!("{out}"); + return Ok(()); + } + scoped::Plan::WholeGraph(why) => { + eprintln!("{}", scoped::fallback_note(&args.path, why)); + } + } + + // Fallback: the whole-graph read, sliced locally. Reading the project's + // main-branch graph instead would return stale or wrong data on any + // diverged branch, so this stays branch-addressed too. let graph = client.fetch_branch_graph(binding.branch_id)?; let out = if args.boundary { @@ -72,6 +95,196 @@ fn locate<'g>( /// The focal node keeps the dotted `path` the caller queried; neighbors keep /// their own reconstructed dotted paths. Edges are translated to dotted /// `node.port` paths over exactly the returned node set. +/// Label a node from the server's `paths`, falling back to the reported reason. +/// +/// NEVER indexes `paths`: the map is deliberately not total — the server +/// returns unnamed nodes (legal while designing) and reports why in +/// `unaddressable`. Indexing it would panic on an ordinary graph. +fn label_of( + id: &str, + paths: &std::collections::HashMap, + unaddressable: &std::collections::HashMap, +) -> String { + paths + .get(id) + .map(|p| scoped::sanitize(p)) + .unwrap_or_else(|| { + scoped::unaddressable_label( + unaddressable + .get(id) + .map(String::as_str) + .unwrap_or("unknown"), + ) + }) +} + +/// The `unaddressable` map as something a consumer can act on. +/// +/// The server keys it by node id, but this CLI does not surface ids — and a +/// raw uuid is not joinable to anything else in the payload, since every other +/// node here is addressed by path. Emit the label the human sees plus the +/// reason, so both modes carry the same, usable information. +fn unaddressable_report(un: &std::collections::HashMap) -> Vec { + let mut out: Vec = un + .values() + .map(|reason| { + serde_json::json!({ + "label": scoped::unaddressable_label(reason), + "reason": scoped::sanitize(reason), + }) + }) + .collect(); + out.sort_by(|a, b| a["label"].as_str().cmp(&b["label"].as_str())); + out +} + +/// The human tail naming how many nodes could not be addressed. Shared by both +/// scoped renderers so they cannot disagree about whether to mention it. +fn unaddressable_summary(un: &std::collections::HashMap) -> String { + if un.is_empty() { + return String::new(); + } + format!( + "\n{} node(s) here have no addressable path — name them to reference \ + them in other commands.\n", + un.len() + ) +} + +/// Render a node + 1-hop neighborhood from the SCOPED read. Paths come from +/// the server, which is the only party holding the ancestors a dotted path is +/// built from — the slice does not contain them. +fn render_neighborhood_scoped( + hood: &models::NodeNeighborhoodResponse, + path: &str, + mode: OutputMode, +) -> Result { + let paths = &hood.paths; + let un = &hood.unaddressable; + let node_id = hood.node.id.to_string(); + + let hint = if hood.node.kind == models::wire_node::Kind::Boundary { + format!(" (it is a boundary — run `hydrate walk {path} --boundary` for its scope)") + } else { + String::new() + }; + + let mut neighbors: Vec<(String, &models::WireNode)> = hood + .neighbors + .iter() + .map(|n| (label_of(&n.id.to_string(), paths, un), n)) + .collect(); + neighbors.sort_by(|a, b| a.0.cmp(&b.0)); + + let focus = view::node_view(&hood.node, label_of(&node_id, paths, un)); + let neighbor_views: Vec = neighbors + .iter() + .map(|(p, n)| view::node_view(n, p.clone())) + .collect(); + + let mut labeled: Vec<(String, &models::WireNode)> = + vec![(label_of(&node_id, paths, un), &hood.node)]; + labeled.extend(neighbors.iter().map(|(p, n)| (p.clone(), *n))); + let edges = view::resolve_edges(&view::port_labels(&labeled), &hood.edges)?; + + Ok(match mode { + OutputMode::Json => serde_json::json!({ + "node": focus, + "neighbors": neighbor_views, + "edges": edges, + "version": hood.version, + // Surfaced, not swallowed: a node without a path is a thing the + // user can fix, and a consumer needs to know which ones. + "unaddressable": unaddressable_report(un), + }) + .to_string(), + OutputMode::Human => { + let mut out = format!("Node '{path}' (version {}):{hint}\n", hood.version); + out.push_str(&focus.human(0)); + if !neighbor_views.is_empty() { + out.push_str("\nNeighbors:\n"); + for n in &neighbor_views { + out.push_str(&n.human(1)); + } + } + if !edges.is_empty() { + out.push_str("\nEdges:\n"); + for e in &edges { + out.push_str(&format!(" {} -> {}\n", e.from, e.to)); + } + } + out.push_str(&unaddressable_summary(un)); + out + } + }) +} + +/// Render a boundary's children from the SCOPED read. +fn render_boundary_scoped( + cell: &models::BoundaryResponse, + path: &str, + mode: OutputMode, +) -> Result { + if cell.boundary.kind != models::wire_node::Kind::Boundary { + // Same guidance the whole-graph path gives. Without this the quality of + // the error depends on whether an index happens to exist locally. + return Err(CliError::InvalidArgument(format!( + "'{path}' is not a boundary (it is a {}); run `hydrate walk {path}` \ + for its neighborhood", + view::kind_str(cell.boundary.kind), + ))); + } + let paths = &cell.paths; + let un = &cell.unaddressable; + + let mut children: Vec<(String, &models::WireNode)> = cell + .children + .iter() + .map(|n| (label_of(&n.id.to_string(), paths, un), n)) + .collect(); + children.sort_by(|a, b| a.0.cmp(&b.0)); + + let boundary_label = label_of(&cell.boundary.id.to_string(), paths, un); + let focus = view::node_view(&cell.boundary, boundary_label.clone()); + let child_views: Vec = children + .iter() + .map(|(p, n)| view::node_view(n, p.clone())) + .collect(); + + let mut labeled: Vec<(String, &models::WireNode)> = vec![(boundary_label, &cell.boundary)]; + labeled.extend(children.iter().map(|(p, n)| (p.clone(), *n))); + let edges = view::resolve_edges(&view::port_labels(&labeled), &cell.edges)?; + + Ok(match mode { + OutputMode::Json => serde_json::json!({ + "boundary": focus, + "children": child_views, + "edges": edges, + "version": cell.version, + "unaddressable": un, + }) + .to_string(), + OutputMode::Human => { + let mut out = format!("Boundary '{path}' (version {}):\n", cell.version); + out.push_str(&focus.human(0)); + if !child_views.is_empty() { + out.push_str("\nChildren:\n"); + for c in &child_views { + out.push_str(&c.human(1)); + } + } + if !edges.is_empty() { + out.push_str("\nInterior edges:\n"); + for e in &edges { + out.push_str(&format!(" {} -> {}\n", e.from, e.to)); + } + } + out.push_str(&unaddressable_summary(un)); + out + } + }) +} + fn render_neighborhood( graph: &GraphResponse, path: &str, @@ -447,3 +660,150 @@ mod tests { assert!(matches!(err, CliError::State(_)), "got {err:?}"); } } + +#[cfg(test)] +mod scoped_tests { + use super::*; + use hydrate_wire::models::{ + BoundaryResponse, NodeNeighborhoodResponse, WireNode, WireNodeData, + }; + use std::collections::HashMap; + use uuid::Uuid; + + fn node(id: Uuid, name: &str) -> WireNode { + node_of_kind(id, name, models::wire_node::Kind::Behavior) + } + + fn node_of_kind(id: Uuid, name: &str, kind: models::wire_node::Kind) -> WireNode { + WireNode { + id, + kind, + parent_id: None, + position: Box::new(models::Position { x: 0.0, y: 0.0 }), + data: Box::new(WireNodeData { + name: name.to_string(), + description: String::new(), + status: "idle".to_string(), + inputs: None, + outputs: None, + config: None, + constraints: None, + verifications: None, + is_test_node: false, + is_external: false, + source_decisions: None, + user_kind: None, + path_prefix: None, + language: None, + external_kind: None, + protocol: None, + documentation_url: None, + }), + } + } + + fn hood( + paths: HashMap, + un: HashMap, + ) -> NodeNeighborhoodResponse { + let focus = Uuid::from_u128(1); + let neighbor = Uuid::from_u128(2); + NodeNeighborhoodResponse { + version: "v1".to_string(), + project_id: Uuid::from_u128(9), + branch: Box::new(models::BranchRef { + id: Uuid::from_u128(8), + version: 1, + }), + node: Box::new(node(focus, "Focus")), + neighbors: vec![node(neighbor, "")], + edges: vec![], + paths, + unaddressable: un, + } + } + + #[test] + fn an_unaddressable_neighbor_does_not_panic() { + // The server deliberately returns nodes with no path — an unnamed node + // is legal while designing. Indexing `paths` would panic on an + // ordinary graph, so indexing the map aborts the command. + let mut paths = HashMap::new(); + paths.insert(Uuid::from_u128(1).to_string(), "Focus".to_string()); + let mut un = HashMap::new(); + un.insert(Uuid::from_u128(2).to_string(), "empty_name".to_string()); + + let out = render_neighborhood_scoped(&hood(paths, un), "Focus", OutputMode::Human) + .expect("must render, not panic"); + assert!(out.contains("" + ); + // And the rendered neighbor path is the label, not an id. + let rendered = v.to_string(); + assert!( + !rendered.contains(&Uuid::from_u128(2).to_string()), + "node id leaked into walk --json: {rendered}" + ); + } + + #[test] + fn a_fully_addressable_slice_reports_nothing_unaddressable() { + let mut paths = HashMap::new(); + paths.insert(Uuid::from_u128(1).to_string(), "Focus".to_string()); + paths.insert(Uuid::from_u128(2).to_string(), "Other".to_string()); + let out = + render_neighborhood_scoped(&hood(paths, HashMap::new()), "Focus", OutputMode::Human) + .unwrap(); + assert!(!out.contains("no addressable path"), "{out}"); + assert!(out.contains("Other"), "{out}"); + } + + #[test] + fn a_boundary_child_without_a_path_does_not_panic() { + let b = Uuid::from_u128(1); + let child = Uuid::from_u128(2); + let mut paths = HashMap::new(); + paths.insert(b.to_string(), "Cell".to_string()); + let mut un = HashMap::new(); + un.insert(child.to_string(), "ambiguous".to_string()); + let cell = BoundaryResponse { + version: "v1".to_string(), + project_id: Uuid::from_u128(9), + branch: Box::new(models::BranchRef { + id: Uuid::from_u128(8), + version: 1, + }), + boundary: Box::new(node_of_kind(b, "Cell", models::wire_node::Kind::Boundary)), + children: vec![node(child, "dup")], + edges: vec![], + paths, + unaddressable: un, + }; + let out = render_boundary_scoped(&cell, "Cell", OutputMode::Human) + .expect("must render, not panic"); + assert!(out.contains("share a name"), "{out}"); + } +} diff --git a/tests/scoped_request.rs b/tests/scoped_request.rs new file mode 100644 index 0000000..f3ff88e --- /dev/null +++ b/tests/scoped_request.rs @@ -0,0 +1,161 @@ +//! Proves `walk` actually issues a SCOPED request. +//! +//! Every other test in this repo exercises the renderers with hand-built +//! responses, which means the dispatch itself — the entire point of the scoped +//! reads — is unverified: reverting `walk` to fetch the whole branch graph +//! leaves the unit suite green. This drives the real binary against a local +//! listener and asserts the request line, so "we stopped putting the whole +//! branch on the wire" is a checked claim rather than a described one. +//! +//! Uses a stdlib TcpListener rather than a mock-server crate: the assertion is +//! one request line, which does not justify a new dependency. + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::process::Command; +use std::sync::mpsc; + +const BOUND_BRANCH: &str = "11111111-1111-1111-1111-111111111111"; +const NODE_ID: &str = "22222222-2222-2222-2222-222222222222"; + +/// Serve one request, reply with `body`, and report the request line. +fn serve_once(body: &'static str) -> (String, mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = format!("http://{}", listener.local_addr().unwrap()); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + if let Ok((stream, _)) = listener.accept() { + let mut reader = BufReader::new(&stream); + let mut line = String::new(); + let _ = reader.read_line(&mut line); + let _ = tx.send(line.trim().to_string()); + let mut stream = &stream; + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.flush(); + } + }); + (addr, rx) +} + +/// A working copy bound to BOUND_BRANCH whose index knows `Api` -> NODE_ID. +fn workdir() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let hy = dir.path().join(".hydrate"); + std::fs::create_dir_all(&hy).unwrap(); + std::fs::write( + hy.join("config.toml"), + format!( + "project_id = \"33333333-3333-3333-3333-333333333333\"\n\ + project_name = \"p\"\n\ + branch_id = \"{BOUND_BRANCH}\"\n\ + branch_name = \"work\"\n" + ), + ) + .unwrap(); + std::fs::write( + hy.join("index.json"), + serde_json::json!({ + "version": 2, + "entries": { "node:Api": NODE_ID }, + "node_info": {}, + "edges": {}, + }) + .to_string(), + ) + .unwrap(); + dir +} + +fn run_walk(dir: &tempfile::TempDir, base_url: &str, args: &[&str]) { + Command::new(env!("CARGO_BIN_EXE_hydrate")) + .args(args) + .current_dir(dir.path()) + .env("HYD_BASE_URL", base_url) + .env("HYD_API_KEY", "test-key-not-a-real-credential") + .output() + .expect("binary should run"); +} + +#[test] +fn walk_requests_the_scoped_node_endpoint_on_the_bound_branch() { + let body = r#"{"version":"v1","project_id":"33333333-3333-3333-3333-333333333333", + "branch":{"id":"11111111-1111-1111-1111-111111111111","version":1}, + "node":{"id":"22222222-2222-2222-2222-222222222222","kind":"behavior", + "parent_id":null,"position":{"x":0,"y":0}, + "data":{"name":"Api","description":"","status":"idle", + "is_test_node":false,"is_external":false}}, + "neighbors":[],"edges":[], + "paths":{"22222222-2222-2222-2222-222222222222":"Api"}, + "unaddressable":{}}"#; + let (addr, rx) = serve_once(body); + let dir = workdir(); + run_walk(&dir, &addr, &["walk", "Api"]); + + let request = rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + assert!( + request.contains(&format!("/v1/branches/{BOUND_BRANCH}/node/{NODE_ID}")), + "walk must issue the SCOPED read on the bound branch, got: {request}" + ); + assert!( + !request.contains("/graph"), + "walk must NOT fetch the whole branch graph, got: {request}" + ); +} + +#[test] +fn walk_boundary_requests_the_scoped_boundary_endpoint() { + let body = r#"{"version":"v1","project_id":"33333333-3333-3333-3333-333333333333", + "branch":{"id":"11111111-1111-1111-1111-111111111111","version":1}, + "boundary":{"id":"22222222-2222-2222-2222-222222222222","kind":"boundary", + "parent_id":null,"position":{"x":0,"y":0}, + "data":{"name":"Api","description":"","status":"idle", + "is_test_node":false,"is_external":false}}, + "children":[],"edges":[], + "paths":{"22222222-2222-2222-2222-222222222222":"Api"}, + "unaddressable":{}}"#; + let (addr, rx) = serve_once(body); + let dir = workdir(); + run_walk(&dir, &addr, &["walk", "Api", "--boundary"]); + + let request = rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + assert!( + request.contains(&format!("/v1/branches/{BOUND_BRANCH}/boundary/{NODE_ID}")), + "got: {request}" + ); +} + +#[test] +fn without_an_index_walk_falls_back_to_the_whole_graph_read() { + // The fallback is a real behaviour, not an error path — and it is the one + // case where fetching everything is correct. Pin it so the dispatch can't + // silently lose the distinction. + let dir = tempfile::tempdir().unwrap(); + let hy = dir.path().join(".hydrate"); + std::fs::create_dir_all(&hy).unwrap(); + std::fs::write( + hy.join("config.toml"), + format!( + "project_id = \"33333333-3333-3333-3333-333333333333\"\n\ + project_name = \"p\"\n\ + branch_id = \"{BOUND_BRANCH}\"\n\ + branch_name = \"work\"\n" + ), + ) + .unwrap(); + let (addr, rx) = serve_once( + r#"{"version":"v1","project_id":"33333333-3333-3333-3333-333333333333","branch":{"id":"11111111-1111-1111-1111-111111111111","version":1},"nodes":[],"edges":[]}"#, + ); + run_walk(&dir, &addr, &["walk", "Api"]); + + let request = rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + assert!( + request.contains(&format!("/v1/branches/{BOUND_BRANCH}/graph")), + "with no index the whole-graph read is correct, got: {request}" + ); +} diff --git a/wire/.openapi-generator/FILES b/wire/.openapi-generator/FILES index e7b3ed9..a045b7f 100644 --- a/wire/.openapi-generator/FILES +++ b/wire/.openapi-generator/FILES @@ -35,13 +35,18 @@ src/models/inline_object.rs src/models/inline_object_1.rs src/models/inline_object_1_detail.rs src/models/inline_object_2.rs +src/models/inline_object_2_detail.rs src/models/inline_object_3.rs +src/models/inline_object_3_detail.rs src/models/inline_object_4.rs -src/models/inline_object_4_detail.rs -src/models/inline_object_4_detail_one_of.rs src/models/inline_object_5.rs -src/models/inline_object_5_detail.rs +src/models/inline_object_6.rs +src/models/inline_object_6_detail.rs +src/models/inline_object_6_detail_one_of.rs +src/models/inline_object_7.rs +src/models/inline_object_7_detail.rs src/models/inline_object_detail.rs +src/models/main_branch_ref.rs src/models/mod.rs src/models/node.rs src/models/node_data.rs @@ -49,12 +54,15 @@ src/models/node_neighborhood_response.rs src/models/not_found_body.rs src/models/port.rs src/models/position.rs +src/models/project_create_response.rs src/models/project_out.rs src/models/projects_list_response.rs src/models/recovery_hint.rs src/models/reparent_node_delta.rs +src/models/subtree_response.rs src/models/update_node_data_delta.rs src/models/v1_create_branch_body.rs +src/models/v1_create_project_body.rs src/models/v1_deltas_body.rs src/models/v1_deltas_body_deltas_inner.rs src/models/v1_node_position.rs diff --git a/wire/src/apis/branches_api.rs b/wire/src/apis/branches_api.rs index f6a8eca..88f2787 100644 --- a/wire/src/apis/branches_api.rs +++ b/wire/src/apis/branches_api.rs @@ -30,6 +30,15 @@ pub struct CreateBranchV1ProjectsProjectIdBranchesPostParams { pub v1_create_branch_body: models::V1CreateBranchBody } +/// struct for passing parameters to the method [`fetch_branch_boundary_v1_branches_branch_id_boundary_node_id_get`] +#[derive(Clone, Debug)] +pub struct FetchBranchBoundaryV1BranchesBranchIdBoundaryNodeIdGetParams { + /// The branch to read. + pub branch_id: String, + /// The boundary node to inspect. + pub node_id: String +} + /// struct for passing parameters to the method [`fetch_branch_graph_v1_branches_branch_id_graph_get`] #[derive(Clone, Debug)] pub struct FetchBranchGraphV1BranchesBranchIdGraphGetParams { @@ -37,6 +46,26 @@ pub struct FetchBranchGraphV1BranchesBranchIdGraphGetParams { pub branch_id: String } +/// struct for passing parameters to the method [`fetch_branch_node_v1_branches_branch_id_node_node_id_get`] +#[derive(Clone, Debug)] +pub struct FetchBranchNodeV1BranchesBranchIdNodeNodeIdGetParams { + /// The branch to read. + pub branch_id: String, + /// The node to read. + pub node_id: String +} + +/// struct for passing parameters to the method [`fetch_branch_subtree_v1_branches_branch_id_subtree_node_id_get`] +#[derive(Clone, Debug)] +pub struct FetchBranchSubtreeV1BranchesBranchIdSubtreeNodeIdGetParams { + /// The branch to read. + pub branch_id: String, + /// The node to root the subtree at. + pub node_id: String, + /// How many levels of descendants to include. 1 = direct children only. + pub depth: Option +} + /// struct for passing parameters to the method [`list_branches_v1_projects_project_id_branches_get`] #[derive(Clone, Debug)] pub struct ListBranchesV1ProjectsProjectIdBranchesGetParams { @@ -57,12 +86,12 @@ pub struct ValidateBranchDeltasV1BranchesBranchIdValidatePostParams { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum ApplyBranchDeltasV1BranchesBranchIdDeltasPostError { - Status401(models::InlineObject3), + Status401(models::InlineObject5), Status403(models::InlineObject), Status404(models::ApplyBranchDeltasV1BranchesBranchIdDeltasPost404Response), Status409(models::ApplyBranchDeltasV1BranchesBranchIdDeltasPost409Response), Status422(models::ApplyBranchDeltasV1BranchesBranchIdDeltasPost422Response), - Status429(models::InlineObject2), + Status429(models::InlineObject4), UnknownValue(serde_json::Value), } @@ -70,11 +99,23 @@ pub enum ApplyBranchDeltasV1BranchesBranchIdDeltasPostError { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum CreateBranchV1ProjectsProjectIdBranchesPostError { - Status401(models::InlineObject3), + Status401(models::InlineObject5), + Status403(models::InlineObject), + Status404(models::InlineObject1), + Status422(models::HttpValidationError), + Status429(models::InlineObject4), + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`fetch_branch_boundary_v1_branches_branch_id_boundary_node_id_get`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FetchBranchBoundaryV1BranchesBranchIdBoundaryNodeIdGetError { + Status401(models::InlineObject5), Status403(models::InlineObject), Status404(models::InlineObject1), Status422(models::HttpValidationError), - Status429(models::InlineObject2), + Status429(models::InlineObject4), UnknownValue(serde_json::Value), } @@ -82,11 +123,35 @@ pub enum CreateBranchV1ProjectsProjectIdBranchesPostError { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum FetchBranchGraphV1BranchesBranchIdGraphGetError { - Status401(models::InlineObject3), + Status401(models::InlineObject5), + Status403(models::InlineObject), + Status404(models::InlineObject1), + Status422(models::HttpValidationError), + Status429(models::InlineObject4), + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`fetch_branch_node_v1_branches_branch_id_node_node_id_get`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FetchBranchNodeV1BranchesBranchIdNodeNodeIdGetError { + Status401(models::InlineObject5), + Status403(models::InlineObject), + Status404(models::InlineObject1), + Status422(models::HttpValidationError), + Status429(models::InlineObject4), + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`fetch_branch_subtree_v1_branches_branch_id_subtree_node_id_get`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FetchBranchSubtreeV1BranchesBranchIdSubtreeNodeIdGetError { + Status401(models::InlineObject5), Status403(models::InlineObject), Status404(models::InlineObject1), Status422(models::HttpValidationError), - Status429(models::InlineObject2), + Status429(models::InlineObject4), UnknownValue(serde_json::Value), } @@ -94,11 +159,11 @@ pub enum FetchBranchGraphV1BranchesBranchIdGraphGetError { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum ListBranchesV1ProjectsProjectIdBranchesGetError { - Status401(models::InlineObject3), + Status401(models::InlineObject5), Status403(models::InlineObject), Status404(models::InlineObject1), Status422(models::HttpValidationError), - Status429(models::InlineObject2), + Status429(models::InlineObject4), UnknownValue(serde_json::Value), } @@ -106,11 +171,11 @@ pub enum ListBranchesV1ProjectsProjectIdBranchesGetError { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum ValidateBranchDeltasV1BranchesBranchIdValidatePostError { - Status401(models::InlineObject3), + Status401(models::InlineObject5), Status403(models::InlineObject), Status404(models::ApplyBranchDeltasV1BranchesBranchIdDeltasPost404Response), Status422(models::ApplyBranchDeltasV1BranchesBranchIdDeltasPost422Response), - Status429(models::InlineObject2), + Status429(models::InlineObject4), UnknownValue(serde_json::Value), } @@ -193,6 +258,44 @@ pub async fn create_branch_v1_projects_project_id_branches_post(configuration: & } } +/// Returns a boundary node, its direct children, and the edges between those children — on this branch. Edges that cross the boundary belong to the parent cell's inspection and are not returned. +pub async fn fetch_branch_boundary_v1_branches_branch_id_boundary_node_id_get(configuration: &configuration::Configuration, params: FetchBranchBoundaryV1BranchesBranchIdBoundaryNodeIdGetParams) -> Result> { + + let uri_str = format!("{}/v1/branches/{branch_id}/boundary/{node_id}", configuration.base_path, branch_id=crate::apis::urlencode(params.branch_id), node_id=crate::apis::urlencode(params.node_id)); + let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::BoundaryResponse`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::BoundaryResponse`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { status, content, entity })) + } +} + /// Read the full graph (nodes, ports, edges, version) of a specific branch. Use the returned `version` as `expected_version` on a subsequent apply to that branch. pub async fn fetch_branch_graph_v1_branches_branch_id_graph_get(configuration: &configuration::Configuration, params: FetchBranchGraphV1BranchesBranchIdGraphGetParams) -> Result> { @@ -231,6 +334,85 @@ pub async fn fetch_branch_graph_v1_branches_branch_id_graph_get(configuration: & } } +/// Returns a node, every node one edge away from it, and those edges — on this branch. The scoped read an agent uses to load just the context around what it is about to change. +pub async fn fetch_branch_node_v1_branches_branch_id_node_node_id_get(configuration: &configuration::Configuration, params: FetchBranchNodeV1BranchesBranchIdNodeNodeIdGetParams) -> Result> { + + let uri_str = format!("{}/v1/branches/{branch_id}/node/{node_id}", configuration.base_path, branch_id=crate::apis::urlencode(params.branch_id), node_id=crate::apis::urlencode(params.node_id)); + let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::NodeNeighborhoodResponse`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::NodeNeighborhoodResponse`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { status, content, entity })) + } +} + +/// Returns a node and its descendants down to `depth` levels on this branch, with the edges among them. The scoped counterpart to `GET /v1/branches/{branch_id}/graph`: bounded by `depth` rather than by the size of the branch, so reading a slice of a large project never puts the whole graph on the wire. +pub async fn fetch_branch_subtree_v1_branches_branch_id_subtree_node_id_get(configuration: &configuration::Configuration, params: FetchBranchSubtreeV1BranchesBranchIdSubtreeNodeIdGetParams) -> Result> { + + let uri_str = format!("{}/v1/branches/{branch_id}/subtree/{node_id}", configuration.base_path, branch_id=crate::apis::urlencode(params.branch_id), node_id=crate::apis::urlencode(params.node_id)); + let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); + + if let Some(ref param_value) = params.depth { + req_builder = req_builder.query(&[("depth", ¶m_value.to_string())]); + } + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::SubtreeResponse`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::SubtreeResponse`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { status, content, entity })) + } +} + /// List your active working branches on this project. The protected main branch is not included. Each branch is a unit of work you can read and apply changes to before merging into main. pub async fn list_branches_v1_projects_project_id_branches_get(configuration: &configuration::Configuration, params: ListBranchesV1ProjectsProjectIdBranchesGetParams) -> Result> { diff --git a/wire/src/apis/graph_api.rs b/wire/src/apis/graph_api.rs index a41cfb6..14ec635 100644 --- a/wire/src/apis/graph_api.rs +++ b/wire/src/apis/graph_api.rs @@ -39,16 +39,27 @@ pub struct FetchNodeWithNeighborsV1GraphProjectIdNodeNodeIdGetParams { pub node_id: String } +/// struct for passing parameters to the method [`fetch_subtree_v1_graph_project_id_subtree_node_id_get`] +#[derive(Clone, Debug)] +pub struct FetchSubtreeV1GraphProjectIdSubtreeNodeIdGetParams { + /// The project the node lives in. + pub project_id: String, + /// The node to root the subtree at. + pub node_id: String, + /// How many levels of descendants to include. 1 = direct children only. Levels below the cut are omitted and ``truncated`` reports that they exist. + pub depth: Option +} + /// struct for typed errors of method [`fetch_boundary_v1_graph_project_id_boundary_node_id_get`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum FetchBoundaryV1GraphProjectIdBoundaryNodeIdGetError { - Status401(models::InlineObject3), + Status401(models::InlineObject5), Status403(models::InlineObject), Status404(models::InlineObject1), Status422(models::HttpValidationError), - Status429(models::InlineObject2), + Status429(models::InlineObject4), UnknownValue(serde_json::Value), } @@ -56,11 +67,11 @@ pub enum FetchBoundaryV1GraphProjectIdBoundaryNodeIdGetError { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum FetchGraphV1GraphProjectIdGetError { - Status401(models::InlineObject3), + Status401(models::InlineObject5), Status403(models::InlineObject), Status404(models::InlineObject1), Status422(models::HttpValidationError), - Status429(models::InlineObject2), + Status429(models::InlineObject4), UnknownValue(serde_json::Value), } @@ -68,11 +79,23 @@ pub enum FetchGraphV1GraphProjectIdGetError { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum FetchNodeWithNeighborsV1GraphProjectIdNodeNodeIdGetError { - Status401(models::InlineObject3), + Status401(models::InlineObject5), Status403(models::InlineObject), Status404(models::InlineObject1), Status422(models::HttpValidationError), - Status429(models::InlineObject2), + Status429(models::InlineObject4), + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`fetch_subtree_v1_graph_project_id_subtree_node_id_get`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FetchSubtreeV1GraphProjectIdSubtreeNodeIdGetError { + Status401(models::InlineObject5), + Status403(models::InlineObject), + Status404(models::InlineObject1), + Status422(models::HttpValidationError), + Status429(models::InlineObject4), UnknownValue(serde_json::Value), } @@ -191,3 +214,44 @@ pub async fn fetch_node_with_neighbors_v1_graph_project_id_node_node_id_get(conf } } +/// Returns a node and its descendants down to ``depth`` levels, with the edges among them. The scoped read for working inside a large project: unlike the full graph read, the response is bounded by ``depth`` rather than by the size of the branch, so a slice of a big graph never puts the whole graph on the wire. +pub async fn fetch_subtree_v1_graph_project_id_subtree_node_id_get(configuration: &configuration::Configuration, params: FetchSubtreeV1GraphProjectIdSubtreeNodeIdGetParams) -> Result> { + + let uri_str = format!("{}/v1/graph/{project_id}/subtree/{node_id}", configuration.base_path, project_id=crate::apis::urlencode(params.project_id), node_id=crate::apis::urlencode(params.node_id)); + let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); + + if let Some(ref param_value) = params.depth { + req_builder = req_builder.query(&[("depth", ¶m_value.to_string())]); + } + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::SubtreeResponse`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::SubtreeResponse`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { status, content, entity })) + } +} + diff --git a/wire/src/apis/projects_api.rs b/wire/src/apis/projects_api.rs index b127eb9..0627955 100644 --- a/wire/src/apis/projects_api.rs +++ b/wire/src/apis/projects_api.rs @@ -14,6 +14,12 @@ use serde::{Deserialize, Serialize, de::Error as _}; use crate::{apis::ResponseContent, models}; use super::{Error, configuration, ContentType}; +/// struct for passing parameters to the method [`create_project_v1_projects_post`] +#[derive(Clone, Debug)] +pub struct CreateProjectV1ProjectsPostParams { + pub v1_create_project_body: models::V1CreateProjectBody +} + /// struct for passing parameters to the method [`list_projects_v1_projects_get`] #[derive(Clone, Debug)] pub struct ListProjectsV1ProjectsGetParams { @@ -22,18 +28,69 @@ pub struct ListProjectsV1ProjectsGetParams { } +/// struct for typed errors of method [`create_project_v1_projects_post`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CreateProjectV1ProjectsPostError { + Status401(models::InlineObject5), + Status403(models::InlineObject), + Status409(models::InlineObject2), + Status422(models::InlineObject3), + Status429(models::InlineObject4), + UnknownValue(serde_json::Value), +} + /// struct for typed errors of method [`list_projects_v1_projects_get`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum ListProjectsV1ProjectsGetError { - Status401(models::InlineObject3), + Status401(models::InlineObject5), Status403(models::InlineObject), Status422(models::HttpValidationError), - Status429(models::InlineObject2), + Status429(models::InlineObject4), UnknownValue(serde_json::Value), } +/// Create a new project owned by the caller. Creating a project also seeds its protected `main` branch in the same transaction, so the returned `main_branch` can be forked immediately (via `POST /v1/projects/{project_id}/branches`) with no second round-trip. The name must be unique among your active projects (case-insensitive); a collision returns 409 `name_taken`. Requires the `graph:write` scope. +pub async fn create_project_v1_projects_post(configuration: &configuration::Configuration, params: CreateProjectV1ProjectsPostParams) -> Result> { + + let uri_str = format!("{}/v1/projects", configuration.base_path); + let mut req_builder = configuration.client.request(reqwest::Method::POST, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + req_builder = req_builder.json(¶ms.v1_create_project_body); + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::ProjectCreateResponse`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::ProjectCreateResponse`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { status, content, entity })) + } +} + /// Returns the projects the caller can see. For API-key callers the list is further filtered by the per-key project allowlist (if configured). Archived projects are excluded. An empty list is a normal response when the caller has no visible projects. pub async fn list_projects_v1_projects_get(configuration: &configuration::Configuration, params: ListProjectsV1ProjectsGetParams) -> Result> { diff --git a/wire/src/models/boundary_response.rs b/wire/src/models/boundary_response.rs index c53906e..3f3e2a2 100644 --- a/wire/src/models/boundary_response.rs +++ b/wire/src/models/boundary_response.rs @@ -21,20 +21,28 @@ pub struct BoundaryResponse { pub children: Vec, #[serde(rename = "edges")] pub edges: Vec, + /// Dotted path for every node returned that has one, keyed by node id. Server-rendered because a scoped read returns a SLICE: the ancestors a path is built from are not in the payload, so a client cannot derive it. Every returned node appears in exactly one of `paths` or `unaddressable` — index this map for anything not listed there. + #[serde(rename = "paths")] + pub paths: std::collections::HashMap, #[serde(rename = "project_id")] pub project_id: uuid::Uuid, + /// Nodes that could not be given a dotted path, keyed by node id, with the reason: `empty_name` (the node, or an ancestor, has no name — legal while designing), `reserved_separator` (a name contains `.`), or `ambiguous` (two returned nodes would render the same path, so neither is safe to act on). These are data conditions a user fixes by renaming, not errors. + #[serde(rename = "unaddressable")] + pub unaddressable: std::collections::HashMap, #[serde(rename = "version")] pub version: String, } impl BoundaryResponse { - pub fn new(boundary: models::WireNode, branch: models::BranchRef, children: Vec, edges: Vec, project_id: uuid::Uuid, version: String) -> BoundaryResponse { + pub fn new(boundary: models::WireNode, branch: models::BranchRef, children: Vec, edges: Vec, paths: std::collections::HashMap, project_id: uuid::Uuid, unaddressable: std::collections::HashMap, version: String) -> BoundaryResponse { BoundaryResponse { boundary: Box::new(boundary), branch: Box::new(branch), children, edges, + paths, project_id, + unaddressable, version, } } diff --git a/wire/src/models/inline_object_2.rs b/wire/src/models/inline_object_2.rs index 97428f6..3872d33 100644 --- a/wire/src/models/inline_object_2.rs +++ b/wire/src/models/inline_object_2.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] pub struct InlineObject2 { #[serde(rename = "detail", skip_serializing_if = "Option::is_none")] - pub detail: Option, + pub detail: Option>, } impl InlineObject2 { diff --git a/wire/src/models/inline_object_2_detail.rs b/wire/src/models/inline_object_2_detail.rs new file mode 100644 index 0000000..8efca26 --- /dev/null +++ b/wire/src/models/inline_object_2_detail.rs @@ -0,0 +1,44 @@ +/* + * hydrate.sh v1 API + * + * The hydrate.sh public API. All routes are scoped to the `/v1/` prefix and require a Bearer API key (see Authentication). **Stability:** `v1` is the wire-stable surface. Breaking changes will rebase to `/v2/`. + * + * The version of the OpenAPI document: 1.0.0-rc.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct InlineObject2Detail { + #[serde(rename = "code")] + pub code: Code, + #[serde(rename = "message")] + pub message: String, +} + +impl InlineObject2Detail { + pub fn new(code: Code, message: String) -> InlineObject2Detail { + InlineObject2Detail { + code, + message, + } + } +} +/// +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Code { + #[serde(rename = "name_taken")] + NameTaken, + #[serde(rename = "project_limit_reached")] + ProjectLimitReached, +} + +impl Default for Code { + fn default() -> Code { + Self::NameTaken + } +} + diff --git a/wire/src/models/inline_object_3.rs b/wire/src/models/inline_object_3.rs index 7f464e7..119cd7d 100644 --- a/wire/src/models/inline_object_3.rs +++ b/wire/src/models/inline_object_3.rs @@ -13,14 +13,14 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] pub struct InlineObject3 { - #[serde(rename = "detail")] - pub detail: String, + #[serde(rename = "detail", skip_serializing_if = "Option::is_none")] + pub detail: Option>, } impl InlineObject3 { - pub fn new(detail: String) -> InlineObject3 { + pub fn new() -> InlineObject3 { InlineObject3 { - detail, + detail: None, } } } diff --git a/wire/src/models/inline_object_3_detail.rs b/wire/src/models/inline_object_3_detail.rs new file mode 100644 index 0000000..68834fa --- /dev/null +++ b/wire/src/models/inline_object_3_detail.rs @@ -0,0 +1,46 @@ +/* + * hydrate.sh v1 API + * + * The hydrate.sh public API. All routes are scoped to the `/v1/` prefix and require a Bearer API key (see Authentication). **Stability:** `v1` is the wire-stable surface. Breaking changes will rebase to `/v2/`. + * + * The version of the OpenAPI document: 1.0.0-rc.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct InlineObject3Detail { + #[serde(rename = "code")] + pub code: Code, + #[serde(rename = "message")] + pub message: String, +} + +impl InlineObject3Detail { + pub fn new(code: Code, message: String) -> InlineObject3Detail { + InlineObject3Detail { + code, + message, + } + } +} +/// +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Code { + #[serde(rename = "invalid_name")] + InvalidName, + #[serde(rename = "unsupported_language")] + UnsupportedLanguage, + #[serde(rename = "unsupported_intent")] + UnsupportedIntent, +} + +impl Default for Code { + fn default() -> Code { + Self::InvalidName + } +} + diff --git a/wire/src/models/inline_object_4.rs b/wire/src/models/inline_object_4.rs index 57f52de..8136803 100644 --- a/wire/src/models/inline_object_4.rs +++ b/wire/src/models/inline_object_4.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] pub struct InlineObject4 { #[serde(rename = "detail", skip_serializing_if = "Option::is_none")] - pub detail: Option>, + pub detail: Option, } impl InlineObject4 { diff --git a/wire/src/models/inline_object_5.rs b/wire/src/models/inline_object_5.rs index d2db635..a6b5af4 100644 --- a/wire/src/models/inline_object_5.rs +++ b/wire/src/models/inline_object_5.rs @@ -13,14 +13,14 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] pub struct InlineObject5 { - #[serde(rename = "detail", skip_serializing_if = "Option::is_none")] - pub detail: Option>, + #[serde(rename = "detail")] + pub detail: String, } impl InlineObject5 { - pub fn new() -> InlineObject5 { + pub fn new(detail: String) -> InlineObject5 { InlineObject5 { - detail: None, + detail, } } } diff --git a/wire/src/models/inline_object_6.rs b/wire/src/models/inline_object_6.rs new file mode 100644 index 0000000..ae5db00 --- /dev/null +++ b/wire/src/models/inline_object_6.rs @@ -0,0 +1,27 @@ +/* + * hydrate.sh v1 API + * + * The hydrate.sh public API. All routes are scoped to the `/v1/` prefix and require a Bearer API key (see Authentication). **Stability:** `v1` is the wire-stable surface. Breaking changes will rebase to `/v2/`. + * + * The version of the OpenAPI document: 1.0.0-rc.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct InlineObject6 { + #[serde(rename = "detail", skip_serializing_if = "Option::is_none")] + pub detail: Option>, +} + +impl InlineObject6 { + pub fn new() -> InlineObject6 { + InlineObject6 { + detail: None, + } + } +} + diff --git a/wire/src/models/inline_object_4_detail.rs b/wire/src/models/inline_object_6_detail.rs similarity index 81% rename from wire/src/models/inline_object_4_detail.rs rename to wire/src/models/inline_object_6_detail.rs index ed30027..d43eca3 100644 --- a/wire/src/models/inline_object_4_detail.rs +++ b/wire/src/models/inline_object_6_detail.rs @@ -13,12 +13,12 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(untagged)] -pub enum InlineObject4Detail { +pub enum InlineObject6Detail { String(String), - InlineObject4DetailOneOf(Box), + InlineObject6DetailOneOf(Box), } -impl Default for InlineObject4Detail { +impl Default for InlineObject6Detail { fn default() -> Self { Self::String(Default::default()) } diff --git a/wire/src/models/inline_object_4_detail_one_of.rs b/wire/src/models/inline_object_6_detail_one_of.rs similarity index 92% rename from wire/src/models/inline_object_4_detail_one_of.rs rename to wire/src/models/inline_object_6_detail_one_of.rs index 53c3fad..73515c6 100644 --- a/wire/src/models/inline_object_4_detail_one_of.rs +++ b/wire/src/models/inline_object_6_detail_one_of.rs @@ -12,7 +12,7 @@ use crate::models; use serde::{Deserialize, Serialize}; #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct InlineObject4DetailOneOf { +pub struct InlineObject6DetailOneOf { #[serde(rename = "delta_ordinal", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] pub delta_ordinal: Option>, #[serde(rename = "delta_type", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] @@ -29,9 +29,9 @@ pub struct InlineObject4DetailOneOf { pub recovery_hint: Option>, } -impl InlineObject4DetailOneOf { - pub fn new() -> InlineObject4DetailOneOf { - InlineObject4DetailOneOf { +impl InlineObject6DetailOneOf { + pub fn new() -> InlineObject6DetailOneOf { + InlineObject6DetailOneOf { delta_ordinal: None, delta_type: None, error: None, diff --git a/wire/src/models/inline_object_7.rs b/wire/src/models/inline_object_7.rs new file mode 100644 index 0000000..e8256b9 --- /dev/null +++ b/wire/src/models/inline_object_7.rs @@ -0,0 +1,27 @@ +/* + * hydrate.sh v1 API + * + * The hydrate.sh public API. All routes are scoped to the `/v1/` prefix and require a Bearer API key (see Authentication). **Stability:** `v1` is the wire-stable surface. Breaking changes will rebase to `/v2/`. + * + * The version of the OpenAPI document: 1.0.0-rc.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct InlineObject7 { + #[serde(rename = "detail", skip_serializing_if = "Option::is_none")] + pub detail: Option>, +} + +impl InlineObject7 { + pub fn new() -> InlineObject7 { + InlineObject7 { + detail: None, + } + } +} + diff --git a/wire/src/models/inline_object_5_detail.rs b/wire/src/models/inline_object_7_detail.rs similarity index 86% rename from wire/src/models/inline_object_5_detail.rs rename to wire/src/models/inline_object_7_detail.rs index 2364b06..5b14bd2 100644 --- a/wire/src/models/inline_object_5_detail.rs +++ b/wire/src/models/inline_object_7_detail.rs @@ -12,16 +12,16 @@ use crate::models; use serde::{Deserialize, Serialize}; #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct InlineObject5Detail { +pub struct InlineObject7Detail { #[serde(rename = "current_version", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] pub current_version: Option>, #[serde(rename = "error", skip_serializing_if = "Option::is_none")] pub error: Option, } -impl InlineObject5Detail { - pub fn new() -> InlineObject5Detail { - InlineObject5Detail { +impl InlineObject7Detail { + pub fn new() -> InlineObject7Detail { + InlineObject7Detail { current_version: None, error: None, } diff --git a/wire/src/models/main_branch_ref.rs b/wire/src/models/main_branch_ref.rs new file mode 100644 index 0000000..9d1a3c9 --- /dev/null +++ b/wire/src/models/main_branch_ref.rs @@ -0,0 +1,32 @@ +/* + * hydrate.sh v1 API + * + * The hydrate.sh public API. All routes are scoped to the `/v1/` prefix and require a Bearer API key (see Authentication). **Stability:** `v1` is the wire-stable surface. Breaking changes will rebase to `/v2/`. + * + * The version of the OpenAPI document: 1.0.0-rc.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +/// MainBranchRef : The seeded main branch as returned by project-create. Deliberately narrower than ``BranchMeta``: create returns only what the client can't mint locally and needs for its next call. Pinning the real shape (not ``BranchMeta``) keeps the generated SDK honest. +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct MainBranchRef { + #[serde(rename = "id")] + pub id: uuid::Uuid, + #[serde(rename = "version")] + pub version: i32, +} + +impl MainBranchRef { + /// The seeded main branch as returned by project-create. Deliberately narrower than ``BranchMeta``: create returns only what the client can't mint locally and needs for its next call. Pinning the real shape (not ``BranchMeta``) keeps the generated SDK honest. + pub fn new(id: uuid::Uuid, version: i32) -> MainBranchRef { + MainBranchRef { + id, + version, + } + } +} + diff --git a/wire/src/models/mod.rs b/wire/src/models/mod.rs index 9583b56..4aa2cf2 100644 --- a/wire/src/models/mod.rs +++ b/wire/src/models/mod.rs @@ -56,20 +56,30 @@ pub mod inline_object_1_detail; pub use self::inline_object_1_detail::InlineObject1Detail; pub mod inline_object_2; pub use self::inline_object_2::InlineObject2; +pub mod inline_object_2_detail; +pub use self::inline_object_2_detail::InlineObject2Detail; pub mod inline_object_3; pub use self::inline_object_3::InlineObject3; +pub mod inline_object_3_detail; +pub use self::inline_object_3_detail::InlineObject3Detail; pub mod inline_object_4; pub use self::inline_object_4::InlineObject4; -pub mod inline_object_4_detail; -pub use self::inline_object_4_detail::InlineObject4Detail; -pub mod inline_object_4_detail_one_of; -pub use self::inline_object_4_detail_one_of::InlineObject4DetailOneOf; pub mod inline_object_5; pub use self::inline_object_5::InlineObject5; -pub mod inline_object_5_detail; -pub use self::inline_object_5_detail::InlineObject5Detail; +pub mod inline_object_6; +pub use self::inline_object_6::InlineObject6; +pub mod inline_object_6_detail; +pub use self::inline_object_6_detail::InlineObject6Detail; +pub mod inline_object_6_detail_one_of; +pub use self::inline_object_6_detail_one_of::InlineObject6DetailOneOf; +pub mod inline_object_7; +pub use self::inline_object_7::InlineObject7; +pub mod inline_object_7_detail; +pub use self::inline_object_7_detail::InlineObject7Detail; pub mod inline_object_detail; pub use self::inline_object_detail::InlineObjectDetail; +pub mod main_branch_ref; +pub use self::main_branch_ref::MainBranchRef; pub mod node; pub use self::node::Node; pub mod node_data; @@ -82,6 +92,8 @@ pub mod port; pub use self::port::Port; pub mod position; pub use self::position::Position; +pub mod project_create_response; +pub use self::project_create_response::ProjectCreateResponse; pub mod project_out; pub use self::project_out::ProjectOut; pub mod projects_list_response; @@ -90,10 +102,14 @@ pub mod recovery_hint; pub use self::recovery_hint::RecoveryHint; pub mod reparent_node_delta; pub use self::reparent_node_delta::ReparentNodeDelta; +pub mod subtree_response; +pub use self::subtree_response::SubtreeResponse; pub mod update_node_data_delta; pub use self::update_node_data_delta::UpdateNodeDataDelta; pub mod v1_create_branch_body; pub use self::v1_create_branch_body::V1CreateBranchBody; +pub mod v1_create_project_body; +pub use self::v1_create_project_body::V1CreateProjectBody; pub mod v1_deltas_body; pub use self::v1_deltas_body::V1DeltasBody; pub mod v1_deltas_body_deltas_inner; diff --git a/wire/src/models/node_neighborhood_response.rs b/wire/src/models/node_neighborhood_response.rs index 2bb0a80..5e7654e 100644 --- a/wire/src/models/node_neighborhood_response.rs +++ b/wire/src/models/node_neighborhood_response.rs @@ -21,20 +21,28 @@ pub struct NodeNeighborhoodResponse { pub neighbors: Vec, #[serde(rename = "node")] pub node: Box, + /// Dotted path for every node returned that has one, keyed by node id. Server-rendered because a scoped read returns a SLICE: the ancestors a path is built from are not in the payload, so a client cannot derive it. Every returned node appears in exactly one of `paths` or `unaddressable` — index this map for anything not listed there. + #[serde(rename = "paths")] + pub paths: std::collections::HashMap, #[serde(rename = "project_id")] pub project_id: uuid::Uuid, + /// Nodes that could not be given a dotted path, keyed by node id, with the reason: `empty_name` (the node, or an ancestor, has no name — legal while designing), `reserved_separator` (a name contains `.`), or `ambiguous` (two returned nodes would render the same path, so neither is safe to act on). These are data conditions a user fixes by renaming, not errors. + #[serde(rename = "unaddressable")] + pub unaddressable: std::collections::HashMap, #[serde(rename = "version")] pub version: String, } impl NodeNeighborhoodResponse { - pub fn new(branch: models::BranchRef, edges: Vec, neighbors: Vec, node: models::WireNode, project_id: uuid::Uuid, version: String) -> NodeNeighborhoodResponse { + pub fn new(branch: models::BranchRef, edges: Vec, neighbors: Vec, node: models::WireNode, paths: std::collections::HashMap, project_id: uuid::Uuid, unaddressable: std::collections::HashMap, version: String) -> NodeNeighborhoodResponse { NodeNeighborhoodResponse { branch: Box::new(branch), edges, neighbors, node: Box::new(node), + paths, project_id, + unaddressable, version, } } diff --git a/wire/src/models/project_create_response.rs b/wire/src/models/project_create_response.rs new file mode 100644 index 0000000..910ef8e --- /dev/null +++ b/wire/src/models/project_create_response.rs @@ -0,0 +1,33 @@ +/* + * hydrate.sh v1 API + * + * The hydrate.sh public API. All routes are scoped to the `/v1/` prefix and require a Bearer API key (see Authentication). **Stability:** `v1` is the wire-stable surface. Breaking changes will rebase to `/v2/`. + * + * The version of the OpenAPI document: 1.0.0-rc.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct ProjectCreateResponse { + #[serde(rename = "main_branch")] + pub main_branch: Box, + #[serde(rename = "project")] + pub project: Box, + #[serde(rename = "version")] + pub version: String, +} + +impl ProjectCreateResponse { + pub fn new(main_branch: models::MainBranchRef, project: models::ProjectOut, version: String) -> ProjectCreateResponse { + ProjectCreateResponse { + main_branch: Box::new(main_branch), + project: Box::new(project), + version, + } + } +} + diff --git a/wire/src/models/subtree_response.rs b/wire/src/models/subtree_response.rs new file mode 100644 index 0000000..d7c7393 --- /dev/null +++ b/wire/src/models/subtree_response.rs @@ -0,0 +1,61 @@ +/* + * hydrate.sh v1 API + * + * The hydrate.sh public API. All routes are scoped to the `/v1/` prefix and require a Bearer API key (see Authentication). **Stability:** `v1` is the wire-stable surface. Breaking changes will rebase to `/v2/`. + * + * The version of the OpenAPI document: 1.0.0-rc.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +/// SubtreeResponse : A node plus its descendants to a bounded depth. ``nodes`` EXCLUDES ``root``. ``truncated`` is true when the walk stopped with descendants still below it — a caller that cannot distinguish a complete cell from a slice will treat a slice as complete, so the signal is explicit rather than inferred from ``depth``. ``cross_boundary_edges`` carries edges with exactly one endpoint inside the returned set: real dependencies of this slice, which a scoped read would otherwise hide. +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct SubtreeResponse { + #[serde(rename = "branch")] + pub branch: Box, + #[serde(rename = "cross_boundary_edges")] + pub cross_boundary_edges: Vec, + #[serde(rename = "depth")] + pub depth: i32, + #[serde(rename = "edges")] + pub edges: Vec, + #[serde(rename = "nodes")] + pub nodes: Vec, + /// Dotted path for every node returned that has one, keyed by node id. Server-rendered because a scoped read returns a SLICE: the ancestors a path is built from are not in the payload, so a client cannot derive it. Every returned node appears in exactly one of `paths` or `unaddressable` — index this map for anything not listed there. + #[serde(rename = "paths")] + pub paths: std::collections::HashMap, + #[serde(rename = "project_id")] + pub project_id: uuid::Uuid, + #[serde(rename = "root")] + pub root: Box, + #[serde(rename = "truncated")] + pub truncated: bool, + /// Nodes that could not be given a dotted path, keyed by node id, with the reason: `empty_name` (the node, or an ancestor, has no name — legal while designing), `reserved_separator` (a name contains `.`), or `ambiguous` (two returned nodes would render the same path, so neither is safe to act on). These are data conditions a user fixes by renaming, not errors. + #[serde(rename = "unaddressable")] + pub unaddressable: std::collections::HashMap, + #[serde(rename = "version")] + pub version: String, +} + +impl SubtreeResponse { + /// A node plus its descendants to a bounded depth. ``nodes`` EXCLUDES ``root``. ``truncated`` is true when the walk stopped with descendants still below it — a caller that cannot distinguish a complete cell from a slice will treat a slice as complete, so the signal is explicit rather than inferred from ``depth``. ``cross_boundary_edges`` carries edges with exactly one endpoint inside the returned set: real dependencies of this slice, which a scoped read would otherwise hide. + pub fn new(branch: models::BranchRef, cross_boundary_edges: Vec, depth: i32, edges: Vec, nodes: Vec, paths: std::collections::HashMap, project_id: uuid::Uuid, root: models::WireNode, truncated: bool, unaddressable: std::collections::HashMap, version: String) -> SubtreeResponse { + SubtreeResponse { + branch: Box::new(branch), + cross_boundary_edges, + depth, + edges, + nodes, + paths, + project_id, + root: Box::new(root), + truncated, + unaddressable, + version, + } + } +} + diff --git a/wire/src/models/v1_create_project_body.rs b/wire/src/models/v1_create_project_body.rs new file mode 100644 index 0000000..d5ef55d --- /dev/null +++ b/wire/src/models/v1_create_project_body.rs @@ -0,0 +1,36 @@ +/* + * hydrate.sh v1 API + * + * The hydrate.sh public API. All routes are scoped to the `/v1/` prefix and require a Bearer API key (see Authentication). **Stability:** `v1` is the wire-stable surface. Breaking changes will rebase to `/v2/`. + * + * The version of the OpenAPI document: 1.0.0-rc.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +/// V1CreateProjectBody : Body for ``POST /v1/projects``. Only ``name`` is required; ``language`` and ``intent`` default server-side to ``python``/``cli`` (mirrored by the CLI ``hydrate new`` defaults). +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct V1CreateProjectBody { + #[serde(rename = "intent", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] + pub intent: Option>, + #[serde(rename = "language", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] + pub language: Option>, + /// Project name. Unique per-user, case-insensitive. + #[serde(rename = "name")] + pub name: String, +} + +impl V1CreateProjectBody { + /// Body for ``POST /v1/projects``. Only ``name`` is required; ``language`` and ``intent`` default server-side to ``python``/``cli`` (mirrored by the CLI ``hydrate new`` defaults). + pub fn new(name: String) -> V1CreateProjectBody { + V1CreateProjectBody { + intent: None, + language: None, + name, + } + } +} +