| 1 | //! Persisted domain types, defined as Toasty models. Tables are created from |
| 2 | //! these definitions via [`crate::db`]'s `push_schema`. |
| 3 | //! |
| 4 | //! Foreign keys are kept as plain scalar fields (`owner_id`, `user_id`) and |
| 5 | //! queried explicitly, rather than declaring Toasty relations — simpler and a |
| 6 | //! good fit for our small schema. |
| 7 | |
| 8 | /// A registered user account. |
| 9 | #[derive(Debug, toasty::Model)] |
| 10 | pub struct User { |
| 11 | #[key] |
| 12 | #[auto] |
| 13 | pub id: i64, |
| 14 | #[unique] |
| 15 | pub username: String, |
| 16 | pub email: String, |
| 17 | /// Argon2 PHC-format password hash. Empty for an account that has only |
| 18 | /// ever signed in through the identity provider — no password can hash to |
| 19 | /// it, so [`crate::users::verify_password`] refuses every guess. |
| 20 | pub password_hash: String, |
| 21 | pub is_admin: bool, |
| 22 | /// Unix timestamp (seconds) of account creation. |
| 23 | pub created_at: i64, |
| 24 | /// The OIDC `sub` claim this account is linked to, or empty if it isn't. |
| 25 | /// Accounts are keyed on `sub` rather than email because `sub` is the one |
| 26 | /// claim the provider promises never changes. New columns go last so |
| 27 | /// `ALTER TABLE ADD COLUMN` on existing databases agrees with the |
| 28 | /// fresh-schema column order. |
| 29 | pub sso_sub: String, |
| 30 | } |
| 31 | |
| 32 | /// A hosted repository, owned by a [`User`]. |
| 33 | #[derive(Debug, toasty::Model)] |
| 34 | pub struct Repository { |
| 35 | #[key] |
| 36 | #[auto] |
| 37 | pub id: i64, |
| 38 | #[index] |
| 39 | pub owner_id: i64, |
| 40 | pub name: String, |
| 41 | pub description: String, |
| 42 | pub is_private: bool, |
| 43 | /// Short name of the default branch, e.g. `main`. |
| 44 | pub default_branch: String, |
| 45 | pub created_at: i64, |
| 46 | /// Push-mirror remote: after every successful push, refs are mirrored to |
| 47 | /// this git URL (`git push --mirror`). Empty disables mirroring. New |
| 48 | /// columns go last so `ALTER TABLE ADD COLUMN` on existing databases |
| 49 | /// agrees with the fresh-schema column order. |
| 50 | pub mirror_url: String, |
| 51 | /// SHA-256 hash of the preview image extracted from README (empty if none). |
| 52 | pub preview_image_hash: String, |
| 53 | /// Primary language detected in the repository (e.g. "Rust", empty if no files). |
| 54 | pub primary_language: String, |
| 55 | /// JSON array of language percentages: [{"lang": "Rust", "percent": 75.5}, ...]. |
| 56 | pub languages_json: String, |
| 57 | } |
| 58 | |
| 59 | /// A CI run for a pushed commit. |
| 60 | /// |
| 61 | /// `status` is one of `queued`, `running`, `success`, `failure` (a step exited |
| 62 | /// non-zero), or `error` (the runner itself failed). `started_at`/`finished_at` |
| 63 | /// are 0 until they occur. |
| 64 | #[derive(Clone, Debug, toasty::Model)] |
| 65 | pub struct CiRun { |
| 66 | #[key] |
| 67 | #[auto] |
| 68 | pub id: i64, |
| 69 | #[index] |
| 70 | pub repo_id: i64, |
| 71 | /// Full commit SHA the run is for. |
| 72 | pub commit: String, |
| 73 | /// Short branch name that was pushed (e.g. `main`). |
| 74 | pub ref_name: String, |
| 75 | pub status: String, |
| 76 | /// Accumulated run log. |
| 77 | pub log: String, |
| 78 | pub created_at: i64, |
| 79 | pub started_at: i64, |
| 80 | pub finished_at: i64, |
| 81 | } |
| 82 | |
| 83 | /// One artifact produced by a CI run, stored on disk under |
| 84 | /// `data_dir/artifacts/{repo_id}/{commit}/` (see `docs/ci-artifacts.md`). |
| 85 | /// |
| 86 | /// `commit` is denormalized from the run so per-commit lookups (the |
| 87 | /// latest-on-branch alias) don't join through runs. |
| 88 | #[derive(Clone, Debug, toasty::Model)] |
| 89 | pub struct CiArtifact { |
| 90 | #[key] |
| 91 | #[auto] |
| 92 | pub id: i64, |
| 93 | #[index] |
| 94 | pub run_id: i64, |
| 95 | #[index] |
| 96 | pub repo_id: i64, |
| 97 | /// Full commit SHA the producing run was for. |
| 98 | pub commit: String, |
| 99 | /// Declared artifact name (unique within a pipeline, not globally). |
| 100 | pub name: String, |
| 101 | /// Total size in bytes (summed over files for directory artifacts). |
| 102 | pub size: i64, |
| 103 | /// Directory artifact (stored as a tarball, or extracted when `browse`). |
| 104 | pub is_dir: bool, |
| 105 | /// Served as a browsable static site rather than a download. |
| 106 | pub browse: bool, |
| 107 | /// JSON object of metadata-extractor key → output. |
| 108 | pub meta: String, |
| 109 | pub created_at: i64, |
| 110 | } |
| 111 | |
| 112 | /// One agent session: a long-lived container running tmux plus an agent CLI |
| 113 | /// against a repository, attachable from the browser. |
| 114 | /// |
| 115 | /// `status` is one of `starting`, `running`, `exited` (the agent finished on |
| 116 | /// its own), `failed` (the supervisor could not start or keep it), or `reaped` |
| 117 | /// (a timeout, an operator stop, or a server restart took it). The transcript |
| 118 | /// is *not* a column — it lives at |
| 119 | /// [`storage::session_transcript_path`](crate::storage::session_transcript_path), |
| 120 | /// because a terminal stream grows continuously and [`CiRun::log`] is rewritten |
| 121 | /// whole on every append. |
| 122 | #[derive(Clone, Debug, toasty::Model)] |
| 123 | pub struct AgentSession { |
| 124 | #[key] |
| 125 | #[auto] |
| 126 | pub id: i64, |
| 127 | #[index] |
| 128 | pub repo_id: i64, |
| 129 | /// Account that started the session; its access decides who may attach. |
| 130 | pub user_id: i64, |
| 131 | #[index] |
| 132 | pub status: String, |
| 133 | /// `interactive` (a human drives it) or `autonomous` (started with a |
| 134 | /// prompt and left to run). |
| 135 | pub kind: String, |
| 136 | /// Branch name the session was started from, e.g. `main`. |
| 137 | pub base_ref: String, |
| 138 | /// Full commit SHA the workspace was seeded at. |
| 139 | pub base_commit: String, |
| 140 | /// Branch the agent's work lands on (`agent/{id}`). Empty until the |
| 141 | /// session has something to push. |
| 142 | pub branch: String, |
| 143 | /// Opening prompt for an autonomous session; empty for an interactive one. |
| 144 | pub prompt: String, |
| 145 | /// Docker container id, empty before it is created. |
| 146 | pub container_id: String, |
| 147 | /// Image the container was created from, recorded so a session's |
| 148 | /// provenance survives a config change. |
| 149 | pub image: String, |
| 150 | pub created_at: i64, |
| 151 | pub started_at: i64, |
| 152 | pub finished_at: i64, |
| 153 | /// Last time a viewer was attached, driving the idle sweep. |
| 154 | pub last_attach_at: i64, |
| 155 | /// Container exit code once it stops; 0 until then. |
| 156 | pub exit_code: i64, |
| 157 | /// Supervisor-facing failure detail, empty when there is none. |
| 158 | pub error: String, |
| 159 | /// Comma-separated names of user secrets (`UserSecret`) this session |
| 160 | /// opted into at start time. May name one that isn't currently |
| 161 | /// unlocked — launch fails with a clear error rather than silently |
| 162 | /// starting without it. Recorded (not just consumed) so a session's page |
| 163 | /// can show what it could reach. New column: goes last, see `User.sso_sub`. |
| 164 | pub secret_names: String, |
| 165 | } |
| 166 | |
| 167 | /// An issue on a repository. `number` is the user-facing per-repo sequence |
| 168 | /// (`#1`, `#2`, …); `id` stays the global key. `state` is `open` or `closed`. |
| 169 | /// |
| 170 | /// Numbering is assigned as max+1 at creation; with a single server process |
| 171 | /// (our deployment shape) that cannot race. |
| 172 | #[derive(Clone, Debug, toasty::Model)] |
| 173 | pub struct Issue { |
| 174 | #[key] |
| 175 | #[auto] |
| 176 | pub id: i64, |
| 177 | #[index] |
| 178 | pub repo_id: i64, |
| 179 | pub number: i64, |
| 180 | pub title: String, |
| 181 | /// Markdown body (may be empty). |
| 182 | pub body: String, |
| 183 | pub author_id: i64, |
| 184 | pub state: String, |
| 185 | pub created_at: i64, |
| 186 | /// Bumped on comments and state changes, for "recently active" ordering. |
| 187 | pub updated_at: i64, |
| 188 | } |
| 189 | |
| 190 | /// A comment on an [`Issue`]. |
| 191 | #[derive(Clone, Debug, toasty::Model)] |
| 192 | pub struct IssueComment { |
| 193 | #[key] |
| 194 | #[auto] |
| 195 | pub id: i64, |
| 196 | #[index] |
| 197 | pub issue_id: i64, |
| 198 | pub author_id: i64, |
| 199 | /// Markdown body. |
| 200 | pub body: String, |
| 201 | pub created_at: i64, |
| 202 | } |
| 203 | |
| 204 | /// A web login session, keyed by an opaque random token stored in a cookie. |
| 205 | #[derive(Debug, toasty::Model)] |
| 206 | pub struct Session { |
| 207 | #[key] |
| 208 | pub token: String, |
| 209 | #[index] |
| 210 | pub user_id: i64, |
| 211 | pub created_at: i64, |
| 212 | /// Unix timestamp (seconds) after which the session is invalid. |
| 213 | pub expires_at: i64, |
| 214 | } |
| 215 | |
| 216 | /// An uploaded file (e.g. an image pasted into the file editor), stored |
| 217 | /// outside git at `data_dir/attachments/{repo_id}/{hash}` so large binaries |
| 218 | /// never enter the repository's history. Markdown carries only the serve URL. |
| 219 | /// |
| 220 | /// Content-addressed: `hash` is the lowercase hex SHA-256 of the bytes, so the |
| 221 | /// same content uploaded twice to a repo dedupes to one file. Lookups and GC |
| 222 | /// scope by `repo_id`, which also gates serving by the repo's read access. |
| 223 | #[derive(Clone, Debug, toasty::Model)] |
| 224 | pub struct Attachment { |
| 225 | #[key] |
| 226 | #[auto] |
| 227 | pub id: i64, |
| 228 | #[index] |
| 229 | pub repo_id: i64, |
| 230 | /// Lowercase hex SHA-256 of the content — both the dedup key and the path |
| 231 | /// component under the repo's attachment directory. |
| 232 | pub hash: String, |
| 233 | /// MIME type to serve the bytes with (e.g. `image/png`). |
| 234 | pub content_type: String, |
| 235 | pub size: i64, |
| 236 | /// The user who first uploaded this content to the repo. |
| 237 | pub uploader_id: i64, |
| 238 | pub created_at: i64, |
| 239 | } |
| 240 | |
| 241 | /// A personal access token: a long-lived, scoped bearer credential for |
| 242 | /// non-browser API clients (e.g. tooling that fetches attachments). Only the |
| 243 | /// SHA-256 hash of the token is stored; the plaintext is shown once at |
| 244 | /// creation. A PAT is least-privilege by design — its `scopes` bound what it |
| 245 | /// can do, and the only scope today (`read`) authenticates safe (GET/HEAD) |
| 246 | /// requests only, so a leaked token can never mutate. |
| 247 | #[derive(Clone, Debug, toasty::Model)] |
| 248 | pub struct ApiToken { |
| 249 | #[key] |
| 250 | #[auto] |
| 251 | pub id: i64, |
| 252 | #[index] |
| 253 | pub user_id: i64, |
| 254 | /// A human label for the token (e.g. "claude"), for listing/revoking. |
| 255 | pub name: String, |
| 256 | /// Lowercase hex SHA-256 of the token; the lookup key. |
| 257 | #[unique] |
| 258 | pub token_hash: String, |
| 259 | /// Comma-separated scopes granted to this token (e.g. `read`). |
| 260 | pub scopes: String, |
| 261 | pub created_at: i64, |
| 262 | } |
| 263 | |
| 264 | /// A registered SSH public key, used to authenticate git-over-SSH connections. |
| 265 | #[derive(Debug, toasty::Model)] |
| 266 | pub struct SshKey { |
| 267 | #[key] |
| 268 | #[auto] |
| 269 | pub id: i64, |
| 270 | #[index] |
| 271 | pub user_id: i64, |
| 272 | pub title: String, |
| 273 | /// Canonical SHA256 fingerprint, e.g. `SHA256:…`. |
| 274 | #[unique] |
| 275 | pub fingerprint: String, |
| 276 | /// Normalized OpenSSH public-key line. |
| 277 | pub content: String, |
| 278 | pub created_at: i64, |
| 279 | } |
| 280 | |
| 281 | /// A per-repository secret, stored only as a sealed envelope. |
| 282 | /// |
| 283 | /// The server cannot read `envelope`: it is encrypted to the owner's |
| 284 | /// ssh-ed25519 keys by the client that set it (see [`crate::secrets`]). |
| 285 | /// `recipients` denormalizes the envelope's fingerprints so the UI can tell, |
| 286 | /// without opening anything, which secrets a newly registered key still cannot |
| 287 | /// decrypt — those need `anvild secret rekey`. |
| 288 | #[derive(Clone, Debug, toasty::Model)] |
| 289 | pub struct RepoSecret { |
| 290 | #[key] |
| 291 | #[auto] |
| 292 | pub id: i64, |
| 293 | #[index] |
| 294 | pub repo_id: i64, |
| 295 | /// Environment variable name, e.g. `DEPLOY_TOKEN`. Unique per repository. |
| 296 | pub name: String, |
| 297 | /// The sealed envelope, as JSON (`anvil-secret-v1`). |
| 298 | pub envelope: String, |
| 299 | /// Comma-separated SSH fingerprints the envelope is sealed to. |
| 300 | pub recipients: String, |
| 301 | pub created_at: i64, |
| 302 | pub updated_at: i64, |
| 303 | } |
| 304 | |
| 305 | /// A per-account secret, stored only as a sealed envelope, sealed to *its |
| 306 | /// own owner's* ssh-ed25519 keys rather than a repository's — see |
| 307 | /// [`RepoSecret`] for the shared envelope shape and the crypto notes. |
| 308 | /// |
| 309 | /// Consumed by that same account's agent sessions, which opt in to specific |
| 310 | /// names by name at start time (`AgentSession::secret_names`) — never |
| 311 | /// injected blanket into every session, since a session can be steered by |
| 312 | /// repo content it reads (prompt injection). |
| 313 | #[derive(Clone, Debug, toasty::Model)] |
| 314 | pub struct UserSecret { |
| 315 | #[key] |
| 316 | #[auto] |
| 317 | pub id: i64, |
| 318 | #[index] |
| 319 | pub user_id: i64, |
| 320 | /// Unique per account. Also the environment variable name for `kind == |
| 321 | /// "env"`; just an identifier otherwise. |
| 322 | pub name: String, |
| 323 | /// `"env"`, `"file"`, or `"json"` — see [`crate::secrets::kind`]. |
| 324 | pub kind: String, |
| 325 | /// Destination under the session's `$HOME` for `"file"`/`"json"` |
| 326 | /// (e.g. `.claude/.credentials.json`); empty for `"env"`. Named |
| 327 | /// `dest_path` rather than `path`: toasty's derive macro reserves `path` |
| 328 | /// as a generated identifier on every model, and collides with a field |
| 329 | /// of that name. |
| 330 | pub dest_path: String, |
| 331 | /// jq-style assignment path within `dest_path`'s JSON (e.g. |
| 332 | /// `.oauthAccount.token`); only set, and only meaningful, for `"json"`. |
| 333 | pub field: String, |
| 334 | /// The sealed envelope, as JSON (`anvil-secret-v1`). |
| 335 | pub envelope: String, |
| 336 | /// Comma-separated SSH fingerprints the envelope is sealed to. |
| 337 | pub recipients: String, |
| 338 | pub created_at: i64, |
| 339 | pub updated_at: i64, |
| 340 | } |
| 341 | |
| 342 | /// Cached admin metrics computed periodically (e.g., disk usage snapshot). |
| 343 | #[derive(Clone, Debug, toasty::Model)] |
| 344 | pub struct AdminCache { |
| 345 | #[key] |
| 346 | #[auto] |
| 347 | pub id: i64, |
| 348 | /// Cache key (e.g., "disk_usage"). |
| 349 | pub key: String, |
| 350 | /// JSON-encoded cached data. |
| 351 | pub value: String, |
| 352 | /// Unix timestamp (seconds) of when this snapshot was taken. |
| 353 | pub computed_at: i64, |
| 354 | } |