The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.
| 113 | 113 | at least every 30 minutes and SEV2 (a core part broken for many) at least | |
| 114 | 114 | hourly; both email subscribers and need a blameless postmortem within | |
| 115 | 115 | five working days. | |
| 116 | − | - **An Artifacts outage:** `scripts/ops/artifacts-namespaces.mjs` shows a | |
| 116 | + | - **An Artifacts outage:** `scripts/ops/gitstore-namespaces.mjs` shows a | |
| 117 | 117 | failing namespace. After 15 minutes, serve it read-only from the fallback | |
| 118 | 118 | git store (`scripts/ops/restore-to-gitstore.mjs restore`, then the repos | |
| 119 | 119 | Worker's secret `GIT_FALLBACK_NAMESPACES`) and open an incident. To |
| 130 | 130 | | `services/context` | The context hub: catalog, search and scorecards. | TypeScript | | |
| 131 | 131 | | `services/chat` | Channels, direct messages, threads and their live sockets. | TypeScript | | |
| 132 | 132 | | `services/agents` | The workspace's agents: definitions, templates, desks, memory and runs. | TypeScript | | |
| 133 | − | | `services/docs` | Artifacts: documents, their spaces, sharing and live editing. | TypeScript | | |
| 133 | + | | `services/artifacts` | Artifacts: docs and the other kinds to come, their spaces, sharing and live editing. | TypeScript | | |
| 134 | 134 | | `services/notify` | Notifications: each person's feed, live counts and browser push. | TypeScript | | |
| 135 | 135 | | `services/packages` | The package registries and container images. | Rust | | |
| 136 | 136 | | `services/og` | Social cards at `og.g1t.sh`: a PNG per page, showing only what anyone may see. | TypeScript | |
| 1 | − | //! Workflow run artifacts over REST and MCP, in GitHub's shapes: listing a | |
| 2 | − | //! repository's or a run's, one by id, a link to download it, deleting it, | |
| 3 | − | //! and how long a repository keeps them. | |
| 4 | − | //! | |
| 5 | − | //! The actions service keeps them and decides who may see and change | |
| 6 | − | //! them (`g1t_contracts::actions`); their bytes are in R2, downloaded | |
| 7 | − | //! through the toolkit's blob endpoint (toolkit.rs) with a link signed for | |
| 8 | − | //! a few minutes. `GET …/artifacts/{id}/zip` answers with a redirect to | |
| 9 | − | //! that link, as GitHub's does. | |
| 10 | − | ||
| 11 | − | use g1t_contracts::actions::{Artifact, ArtifactArgs, ArtifactBlob, ArtifactList, ArtifactRetention, ArtifactRetentionArgs, ArtifactsArgs, DeleteArtifactArgs}; | |
| 12 | − | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 13 | − | use serde_json::{Value, json}; | |
| 14 | − | use worker::Result; | |
| 15 | − | ||
| 16 | − | use crate::operations::{Services, repo_path}; | |
| 17 | − | ||
| 18 | − | /// One operation on artifacts. | |
| 19 | − | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 20 | − | pub enum ArtifactsOp { | |
| 21 | − | ListArtifacts, | |
| 22 | − | ListRunArtifacts, | |
| 23 | − | GetArtifact, | |
| 24 | − | DownloadArtifact, | |
| 25 | − | DeleteArtifact, | |
| 26 | − | GetArtifactRetention, | |
| 27 | − | SetArtifactRetention, | |
| 28 | − | } | |
| 29 | − | ||
| 30 | − | impl ArtifactsOp { | |
| 31 | − | /// Every one: `Op::ALL` lists each as `Op::Artifacts(…)`, which a test | |
| 32 | − | /// checks against this. | |
| 33 | − | #[cfg(test)] | |
| 34 | − | pub const ALL: [ArtifactsOp; 7] = [ | |
| 35 | − | ArtifactsOp::ListArtifacts, | |
| 36 | − | ArtifactsOp::ListRunArtifacts, | |
| 37 | − | ArtifactsOp::GetArtifact, | |
| 38 | − | ArtifactsOp::DownloadArtifact, | |
| 39 | − | ArtifactsOp::DeleteArtifact, | |
| 40 | − | ArtifactsOp::GetArtifactRetention, | |
| 41 | − | ArtifactsOp::SetArtifactRetention, | |
| 42 | − | ]; | |
| 43 | − | ||
| 44 | − | pub fn name(self) -> &'static str { | |
| 45 | − | match self { | |
| 46 | − | ArtifactsOp::ListArtifacts => "list_artifacts", | |
| 47 | − | ArtifactsOp::ListRunArtifacts => "list_workflow_run_artifacts", | |
| 48 | − | ArtifactsOp::GetArtifact => "get_artifact", | |
| 49 | − | ArtifactsOp::DownloadArtifact => "download_artifact", | |
| 50 | − | ArtifactsOp::DeleteArtifact => "delete_artifact", | |
| 51 | − | ArtifactsOp::GetArtifactRetention => "get_artifact_retention", | |
| 52 | − | ArtifactsOp::SetArtifactRetention => "set_artifact_retention", | |
| 53 | − | } | |
| 54 | − | } | |
| 55 | − | ||
| 56 | − | /// For the API reference. | |
| 57 | − | pub fn title(self) -> &'static str { | |
| 58 | − | match self { | |
| 59 | − | ArtifactsOp::ListArtifacts => "List a repository's artifacts", | |
| 60 | − | ArtifactsOp::ListRunArtifacts => "List a workflow run's artifacts", | |
| 61 | − | ArtifactsOp::GetArtifact => "Get an artifact", | |
| 62 | − | ArtifactsOp::DownloadArtifact => "Download an artifact", | |
| 63 | − | ArtifactsOp::DeleteArtifact => "Delete an artifact", | |
| 64 | − | ArtifactsOp::GetArtifactRetention => "Get artifact retention", | |
| 65 | − | ArtifactsOp::SetArtifactRetention => "Set artifact retention", | |
| 66 | − | } | |
| 67 | − | } | |
| 68 | − | ||
| 69 | − | pub fn description(self) -> &'static str { | |
| 70 | − | match self { | |
| 71 | − | ArtifactsOp::ListArtifacts => "List a repository's artifacts that have not expired, newest first: each with its id (a number), name, size_in_bytes, digest (sha256:… of its zip), created_at, expires_at, archive_download_url, and workflow_run (its run's id, head_branch and head_sha). Narrow with name; page with page and per_page (30 by default, at most 100). total_count counts every match. Needs the Read role; a public repository's are open to anyone.", | |
| 72 | − | ArtifactsOp::ListRunArtifacts => "List one workflow run's artifacts that have not expired, oldest first, in the same shape as list_artifacts. Narrow with name. Needs the Read role.", | |
| 73 | − | ArtifactsOp::GetArtifact => "Get one artifact by its id: its name, size_in_bytes, digest, when it was made and when it expires, and its run. Needs the Read role.", | |
| 74 | − | ArtifactsOp::DownloadArtifact => "A link to download an artifact as a zip file (an artifact an older runner kept is a .tar.gz), good for 10 minutes and needing no token. Over REST, GET …/zip answers 302 with the link in Location, as GitHub does: `curl -L` follows it. Over MCP the link is returned as url, with expires_at. Needs the Read role.", | |
| 75 | − | ArtifactsOp::DeleteArtifact => "Delete an artifact before it expires: its bytes go at once and its id stops resolving. Needs the Write role.", | |
| 76 | − | ArtifactsOp::GetArtifactRetention => "How many days the repository keeps artifacts (days), and the most it may choose (maximum_allowed_days, 90). A workflow's retention-days can ask for fewer days, never more. Needs the Read role.", | |
| 77 | − | ArtifactsOp::SetArtifactRetention => "Set how many days the repository keeps artifacts by default, and at most: days, from 1 to 90. Artifacts already uploaded keep the expiry they were given. Needs the Maintain role.", | |
| 78 | − | } | |
| 79 | − | } | |
| 80 | − | ||
| 81 | − | /// Whether it changes anything. | |
| 82 | − | pub fn writes(self) -> bool { | |
| 83 | − | matches!(self, ArtifactsOp::DeleteArtifact | ArtifactsOp::SetArtifactRetention) | |
| 84 | − | } | |
| 85 | − | ||
| 86 | − | pub fn input(self) -> Value { | |
| 87 | − | let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); | |
| 88 | − | let id = json!({ "type": ["integer", "string"], "description": "The artifact's id, a number." }); | |
| 89 | − | let (properties, required): (Value, &[&str]) = match self { | |
| 90 | − | ArtifactsOp::ListArtifacts => ( | |
| 91 | − | json!({ | |
| 92 | − | "repo": repo, | |
| 93 | − | "name": { "type": "string", "description": "Only artifacts with exactly this name." }, | |
| 94 | − | "page": { "type": "integer", "description": "The page, from 1." }, | |
| 95 | − | "per_page": { "type": "integer", "description": "Artifacts a page: 30 unless you say, at most 100." }, | |
| 96 | − | }), | |
| 97 | − | &["repo"], | |
| 98 | − | ), | |
| 99 | − | ArtifactsOp::ListRunArtifacts => ( | |
| 100 | − | json!({ | |
| 101 | − | "repo": repo, | |
| 102 | − | "id": { "type": "string", "description": "The run's id (run_…)." }, | |
| 103 | − | "name": { "type": "string", "description": "Only the artifact with exactly this name." }, | |
| 104 | − | }), | |
| 105 | − | &["repo", "id"], | |
| 106 | − | ), | |
| 107 | − | ArtifactsOp::GetArtifact | ArtifactsOp::DownloadArtifact | ArtifactsOp::DeleteArtifact => (json!({ "repo": repo, "id": id }), &["repo", "id"]), | |
| 108 | − | ArtifactsOp::GetArtifactRetention => (json!({ "repo": repo }), &["repo"]), | |
| 109 | − | ArtifactsOp::SetArtifactRetention => ( | |
| 110 | − | json!({ | |
| 111 | − | "repo": repo, | |
| 112 | − | "days": { "type": "integer", "minimum": 1, "maximum": 90, "description": "Days to keep artifacts, by default and at most." }, | |
| 113 | − | }), | |
| 114 | − | &["repo", "days"], | |
| 115 | − | ), | |
| 116 | − | }; | |
| 117 | − | json!({ "type": "object", "properties": properties, "required": required }) | |
| 118 | − | } | |
| 119 | − | } | |
| 120 | − | ||
| 121 | − | /// A number from a path segment or a JSON number. | |
| 122 | − | fn number(input: &Value, key: &str) -> Option<u64> { | |
| 123 | − | match &input[key] { | |
| 124 | − | Value::Number(n) => n.as_u64(), | |
| 125 | − | Value::String(s) => s.trim().parse().ok(), | |
| 126 | − | _ => None, | |
| 127 | − | } | |
| 128 | − | } | |
| 129 | − | ||
| 130 | − | /// An outcome's value made into what is sent; its failure as it is. | |
| 131 | − | fn mapped<T>(outcome: Outcome<T>, f: impl FnOnce(T) -> Value) -> Outcome<Value> { | |
| 132 | − | match outcome { | |
| 133 | − | Outcome::Ok(value) => Outcome::Ok(f(value)), | |
| 134 | − | Outcome::Fail(refused) => Outcome::Fail(refused), | |
| 135 | − | } | |
| 136 | − | } | |
| 137 | − | ||
| 138 | − | fn text(input: &Value, key: &str) -> Option<String> { | |
| 139 | − | input[key].as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned) | |
| 140 | − | } | |
| 141 | − | ||
| 142 | − | /// An artifact as GitHub's REST API shows one. | |
| 143 | − | pub fn shown(artifact: &Artifact, api: &str, repository: &str) -> Value { | |
| 144 | − | let url = format!("{api}/repos/{repository}/actions/artifacts/{}", artifact.id); | |
| 145 | − | json!({ | |
| 146 | − | "id": artifact.id, | |
| 147 | − | "node_id": format!("artifact_{}", artifact.id), | |
| 148 | − | "name": artifact.name, | |
| 149 | − | "size_in_bytes": artifact.size, | |
| 150 | − | "url": url, | |
| 151 | − | "archive_download_url": format!("{url}/zip"), | |
| 152 | − | "expired": artifact.expired, | |
| 153 | − | "digest": artifact.digest, | |
| 154 | − | "created_at": artifact.created_at, | |
| 155 | − | "updated_at": artifact.updated_at, | |
| 156 | − | "expires_at": artifact.expires_at, | |
| 157 | − | "workflow_run": { | |
| 158 | − | "id": artifact.run_id, | |
| 159 | − | "repository_id": artifact.repo_id, | |
| 160 | − | "head_repository_id": artifact.repo_id, | |
| 161 | − | "head_branch": artifact.head_branch, | |
| 162 | − | "head_sha": artifact.head_sha, | |
| 163 | − | }, | |
| 164 | − | }) | |
| 165 | − | } | |
| 166 | − | ||
| 167 | − | /// Where a signed blob token downloads from. | |
| 168 | − | pub fn blob_url(api: &str, blob: &str) -> String { | |
| 169 | − | format!("{api}/actions/toolkit/blobs/{blob}") | |
| 170 | − | } | |
| 171 | − | ||
| 172 | − | fn list(found: ArtifactList, api: &str, repository: &str) -> Value { | |
| 173 | − | json!({ | |
| 174 | − | "total_count": found.total_count, | |
| 175 | − | "artifacts": found.artifacts.iter().map(|a| shown(a, api, repository)).collect::<Vec<_>>(), | |
| 176 | − | }) | |
| 177 | − | } | |
| 178 | − | ||
| 179 | − | pub async fn run(op: ArtifactsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 180 | − | let Some(repo) = repo_path(input) else { | |
| 181 | − | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); | |
| 182 | − | }; | |
| 183 | − | let repository = format!("{}/{}", repo.namespace, repo.name); | |
| 184 | − | let api = services.addresses.api.clone(); | |
| 185 | − | let actions = &services.actions; | |
| 186 | − | let id = number(input, "id"); | |
| 187 | − | let by_id = || ArtifactArgs { repo: repo.clone(), viewer: viewer.clone(), id, run: None, name: None }; | |
| 188 | − | if matches!(op, ArtifactsOp::GetArtifact | ArtifactsOp::DownloadArtifact | ArtifactsOp::DeleteArtifact) && id.is_none() { | |
| 189 | − | return Ok(Outcome::fail(FailureCode::Invalid, "Name the artifact by its id, a number.")); | |
| 190 | − | } | |
| 191 | − | Ok(match op { | |
| 192 | − | ArtifactsOp::ListArtifacts | ArtifactsOp::ListRunArtifacts => { | |
| 193 | − | let run = (op == ArtifactsOp::ListRunArtifacts).then(|| text(input, "id")).flatten(); | |
| 194 | − | let args = ArtifactsArgs { | |
| 195 | − | repo: repo.clone(), | |
| 196 | − | viewer: viewer.clone(), | |
| 197 | − | run, | |
| 198 | − | name: text(input, "name"), | |
| 199 | − | page: number(input, "page").map(|n| n as u32), | |
| 200 | − | per_page: number(input, "per_page").map(|n| n as u32), | |
| 201 | − | }; | |
| 202 | − | let found: Outcome<ArtifactList> = g1t_kit::call(actions, "artifacts", &args).await?; | |
| 203 | − | mapped(found, |mut found| { | |
| 204 | − | // A run's are listed in the order they were made. | |
| 205 | − | if op == ArtifactsOp::ListRunArtifacts { | |
| 206 | − | found.artifacts.reverse(); | |
| 207 | − | } | |
| 208 | − | list(found, &api, &repository) | |
| 209 | − | }) | |
| 210 | − | } | |
| 211 | − | ArtifactsOp::GetArtifact => { | |
| 212 | − | let found: Outcome<Artifact> = g1t_kit::call(actions, "artifact", &by_id()).await?; | |
| 213 | − | mapped(found, |a| shown(&a, &api, &repository)) | |
| 214 | − | } | |
| 215 | − | ArtifactsOp::DownloadArtifact => { | |
| 216 | − | let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "artifact_download", &by_id()).await?; | |
| 217 | − | match found { | |
| 218 | − | Outcome::Ok(found) if found.blob.is_empty() => Outcome::fail(FailureCode::Invalid, "Download links are not set up on this installation."), | |
| 219 | − | Outcome::Ok(found) => Outcome::Ok(json!({ | |
| 220 | − | "url": blob_url(&api, &found.blob), | |
| 221 | − | "expires_at": g1t_contracts::time::rfc3339(g1t_kit::now_ms() + 10 * 60 * 1000), | |
| 222 | − | "artifact": shown(&found.artifact, &api, &repository), | |
| 223 | − | })), | |
| 224 | − | Outcome::Fail(refused) => Outcome::Fail(refused), | |
| 225 | − | } | |
| 226 | − | } | |
| 227 | − | ArtifactsOp::DeleteArtifact => { | |
| 228 | − | let Some(actor) = viewer.clone() else { | |
| 229 | − | return Ok(Outcome::fail(FailureCode::Unauthenticated, "Deleting an artifact needs a g1t access token.")); | |
| 230 | − | }; | |
| 231 | − | let done: Outcome<Artifact> = g1t_kit::call(actions, "delete_artifact", &DeleteArtifactArgs { actor, repo, id: id.unwrap_or_default() }).await?; | |
| 232 | − | mapped(done, |a| json!({ "deleted": true, "id": a.id, "name": a.name })) | |
| 233 | − | } | |
| 234 | − | ArtifactsOp::GetArtifactRetention | ArtifactsOp::SetArtifactRetention => { | |
| 235 | − | let days = if op == ArtifactsOp::SetArtifactRetention { | |
| 236 | − | match number(input, "days") { | |
| 237 | − | Some(days) => Some(days.min(u64::from(u32::MAX)) as u32), | |
| 238 | − | None => return Ok(Outcome::fail(FailureCode::Invalid, "Give days, from 1 to 90.")), | |
| 239 | − | } | |
| 240 | − | } else { | |
| 241 | − | None | |
| 242 | − | }; | |
| 243 | − | let found: Outcome<ArtifactRetention> = g1t_kit::call(actions, "artifact_retention", &ArtifactRetentionArgs { repo, viewer: viewer.clone(), days }).await?; | |
| 244 | − | mapped(found, |r| json!({ "days": r.days, "maximum_allowed_days": r.maximum_allowed_days })) | |
| 245 | − | } | |
| 246 | − | }) | |
| 247 | − | } | |
| 248 | − | ||
| 249 | − | #[cfg(test)] | |
| 250 | − | mod tests { | |
| 251 | − | use super::*; | |
| 252 | − | ||
| 253 | − | fn artifact() -> Artifact { | |
| 254 | − | Artifact { | |
| 255 | − | id: 42, | |
| 256 | − | name: "dist".into(), | |
| 257 | − | size: 1024, | |
| 258 | − | digest: Some(format!("sha256:{}", "a".repeat(64))), | |
| 259 | − | format: "zip".into(), | |
| 260 | − | run_id: "run_1".into(), | |
| 261 | − | job_id: "job_1".into(), | |
| 262 | − | repo_id: "repo_1".into(), | |
| 263 | − | expired: false, | |
| 264 | − | created_at: "2026-10-08T12:00:00.000Z".into(), | |
| 265 | − | updated_at: "2026-10-08T12:00:00.000Z".into(), | |
| 266 | − | expires_at: "2026-10-22T12:00:00.000Z".into(), | |
| 267 | − | head_branch: Some("main".into()), | |
| 268 | − | head_sha: Some("abc".into()), | |
| 269 | − | } | |
| 270 | − | } | |
| 271 | − | ||
| 272 | − | #[test] | |
| 273 | − | fn an_artifact_is_shown_as_github_shows_one() { | |
| 274 | − | let shown = shown(&artifact(), "https://api.g1t.sh", "acme/web"); | |
| 275 | − | assert_eq!(shown["id"], 42); | |
| 276 | − | assert_eq!(shown["size_in_bytes"], 1024); | |
| 277 | − | assert_eq!(shown["url"], "https://api.g1t.sh/repos/acme/web/actions/artifacts/42"); | |
| 278 | − | assert_eq!(shown["archive_download_url"], "https://api.g1t.sh/repos/acme/web/actions/artifacts/42/zip"); | |
| 279 | − | assert_eq!(shown["workflow_run"]["head_branch"], "main"); | |
| 280 | − | assert!(g1t_kit::wire::camel_case_keys(&shown).is_empty()); | |
| 281 | − | } | |
| 282 | − | ||
| 283 | − | #[test] | |
| 284 | − | fn ids_are_read_from_a_path_or_a_number() { | |
| 285 | − | assert_eq!(number(&json!({ "id": "42" }), "id"), Some(42)); | |
| 286 | − | assert_eq!(number(&json!({ "id": 42 }), "id"), Some(42)); | |
| 287 | − | assert_eq!(number(&json!({ "id": "run_1" }), "id"), None); | |
| 288 | − | } | |
| 289 | − | ||
| 290 | − | #[test] | |
| 291 | − | fn each_operation_is_described_with_a_schema() { | |
| 292 | − | for op in ArtifactsOp::ALL { | |
| 293 | − | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Artifacts(op)), "{}", op.name()); | |
| 294 | − | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 295 | − | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); | |
| 296 | − | let level = g1t_contracts::scopes::scope_for(op.name()).unwrap().level(); | |
| 297 | − | assert_eq!(op.writes(), level == g1t_contracts::scopes::Level::Write, "{}", op.name()); | |
| 298 | − | } | |
| 299 | − | } | |
| 300 | − | } |
| 4 | 4 | //! | |
| 5 | 5 | //! Artifacts are kept in R2 (ACTIONS_CACHE, under `a/`), uploaded in | |
| 6 | 6 | //! parts; the actions service lists them and decides names, sizes and how | |
| 7 | − | //! long each is kept (services/actions/src/artifacts.rs). Artifacts older | |
| 7 | + | //! long each is kept (services/actions/src/run_artifacts.rs). Artifacts older | |
| 8 | 8 | //! runners kept in Workers KV are still listed and found there until KV | |
| 9 | 9 | //! expires them. Cache entries are kept in R2 too, up to 2 GB each, | |
| 10 | 10 | //! uploaded in parts; the actions service decides what is found, what | |
| ⋯ | |||
| 26 | 26 | //! - `DELETE .../cache/uploads/{id}?upload=`: gives the upload up | |
| 27 | 27 | //! - `PUT .../cache?key=`: a whole entry of at most 60 MB at once (older runners) | |
| 28 | 28 | //! | |
| 29 | − | //! People download an artifact through the REST API (artifacts.rs), or by | |
| 29 | + | //! People download an artifact through the REST API (run_artifacts.rs), or by | |
| 30 | 30 | //! name at `/repos/{owner}/{repo}/actions/runs/{run}/artifacts/{name}`. | |
| 31 | 31 | ||
| 32 | 32 | use serde::{Deserialize, Serialize}; | |
| ⋯ | |||
| 60 | 60 | /// and none has been made there since artifacts moved to R2 on 2026-10-08: | |
| 61 | 61 | /// from 2026-10-22T00:00Z every one is gone, and KV is not asked (a list is | |
| 62 | 62 | /// the dearest thing KV does). Delete the KV artifact code after that date, | |
| 63 | − | /// with its twin in apps/web/app/lib/artifacts.server.ts. | |
| 63 | + | /// with its twin in apps/web/app/lib/run-artifacts.server.ts. | |
| 64 | 64 | const LEGACY_KV_UNTIL_MS: u64 = 1_792_627_200_000; | |
| 65 | 65 | ||
| 66 | 66 | /// Whether artifacts kept in KV may still be there at `now`. | |
| ⋯ | |||
| 624 | 624 | if let Outcome::Ok(found) = found | |
| 625 | 625 | && !found.blob.is_empty() | |
| 626 | 626 | { | |
| 627 | − | return Response::redirect_with_status(Url::parse(&crate::artifacts::blob_url(&services.addresses.api, &found.blob))?, 302); | |
| 627 | + | return Response::redirect_with_status(Url::parse(&crate::run_artifacts::blob_url(&services.addresses.api, &found.blob))?, 302); | |
| 628 | 628 | } | |
| 629 | 629 | // Kept in KV by an older runner. | |
| 630 | 630 | if !legacy_kv(g1t_kit::now_ms()) { | |
| 2 | 2 | //! dashboards (Artifacts mode), at | |
| 3 | 3 | //! `/workspaces/{workspace}/artifacts` and as the `artifact` MCP tool. | |
| 4 | 4 | //! Code calls them folios; people, URLs, the tool and the scopes say | |
| 5 | − | //! "artifact". Workflow runs' artifacts are something else (artifacts.rs). | |
| 5 | + | //! "artifact". Workflow runs' artifacts are something else (run_artifacts.rs). | |
| 6 | 6 | //! | |
| 7 | − | //! The docs service (`services/docs`, the `DOCS` binding) decides who may | |
| 7 | + | //! The artifacts service (`services/artifacts`, the `ARTIFACTS` binding) decides who may | |
| 8 | 8 | //! do what with each one, from the person's own role on it: this checks | |
| 9 | 9 | //! the input, names people for the service (a username becomes | |
| 10 | 10 | //! `user:<id>`), and gives each answer its public shape, in snake_case. | |
| ⋯ | |||
| 306 | 306 | } | |
| 307 | 307 | } | |
| 308 | 308 | ||
| 309 | − | /// A doc edit's target, as the docs service reads it: a string is the | |
| 309 | + | /// A doc edit's target, as the artifacts service reads it: a string is the | |
| 310 | 310 | /// kind, an object passes as given. Left out, append. | |
| 311 | 311 | fn edit_target(input: &Value) -> Value { | |
| 312 | 312 | match &input["target"] { | |
| ⋯ | |||
| 403 | 403 | ||
| 404 | 404 | // --- Answers, as the API shows them ------------------------------------------- | |
| 405 | 405 | ||
| 406 | − | /// A person, agent or team, from a docs service profile. | |
| 406 | + | /// A person, agent or team, from an artifacts service profile. | |
| 407 | 407 | pub(crate) fn person_json(profile: &Value) -> Value { | |
| 408 | 408 | if !profile.is_object() { | |
| 409 | 409 | return Value::Null; | |
| ⋯ | |||
| 609 | 609 | // --- Running ------------------------------------------------------------------ | |
| 610 | 610 | ||
| 611 | 611 | async fn call<T: DeserializeOwned>(services: &Services, method: &str, args: &impl Serialize) -> Result<Outcome<T>> { | |
| 612 | − | g1t_kit::call(&services.docs, method, args).await | |
| 612 | + | g1t_kit::call(&services.artifacts, method, args).await | |
| 613 | 613 | } | |
| 614 | 614 | ||
| 615 | 615 | /// The person acting, or the refusal: nobody, or a workspace's own token. | |
| ⋯ | |||
| 649 | 649 | }) | |
| 650 | 650 | } | |
| 651 | 651 | ||
| 652 | − | /// Who a share is for, as the docs service names them. | |
| 652 | + | /// Who a share is for, as the artifacts service names them. | |
| 653 | 653 | async fn principal(services: &Services, input: &Value) -> Result<std::result::Result<Option<String>, Outcome<Value>>> { | |
| 654 | 654 | if let Some(principal) = text(input, "principal") { | |
| 655 | 655 | return Ok(Ok(Some(principal))); | |
| ⋯ | |||
| 943 | 943 | assert_eq!(edit["target"]["heading"], "Risks"); | |
| 944 | 944 | assert_eq!(agent_edit(&json!({ "markdown": "x", "target": { "kind": "section" } })), Err("A section target names its heading.".to_owned())); | |
| 945 | 945 | assert!(agent_edit(&json!({})).unwrap_err().contains("markdown")); | |
| 946 | − | // Other kinds carry ops, which the docs service checks (and refuses | |
| 946 | + | // Other kinds carry ops, which the artifacts service checks (and refuses | |
| 947 | 947 | // until each kind ships). | |
| 948 | 948 | let edit = agent_edit(&json!({ "kind": "slides", "ops": [{ "op": "add_slide" }] })).unwrap(); | |
| 949 | 949 | assert_eq!(edit["kind"], "slides"); | |
| 5 | 5 | //! the data. This Worker holds none. | |
| 6 | 6 | ||
| 7 | 7 | mod about; | |
| 8 | − | mod artifacts; | |
| 8 | + | mod run_artifacts; | |
| 9 | 9 | mod addresses; | |
| 10 | 10 | mod alerts; | |
| 11 | 11 | mod audit; | |
| ⋯ | |||
| 840 | 840 | }; | |
| 841 | 841 | match audit::run(route.op, &services, &viewer, &input).await? { | |
| 842 | 842 | // A download is a redirect to its signed link, as GitHub's is. | |
| 843 | − | Outcome::Ok(value) if route.op == operations::Op::Artifacts(artifacts::ArtifactsOp::DownloadArtifact) => { | |
| 843 | + | Outcome::Ok(value) if route.op == operations::Op::Artifacts(run_artifacts::ArtifactsOp::DownloadArtifact) => { | |
| 844 | 844 | match value["url"].as_str().and_then(|url| worker::Url::parse(url).ok()) { | |
| 845 | 845 | Some(url) => Response::redirect_with_status(url, 302), | |
| 846 | 846 | None => reply(&value), | |
| 8 | 8 | use serde_json::{Map, Value, json}; | |
| 9 | 9 | ||
| 10 | 10 | use crate::about::AboutOp; | |
| 11 | − | use crate::artifacts::ArtifactsOp; | |
| 11 | + | use crate::run_artifacts::ArtifactsOp; | |
| 12 | 12 | use crate::deploy_keys::DeployKeysOp; | |
| 13 | 13 | use crate::mirrors::MirrorsOp; | |
| 14 | 14 | use crate::deployments::DeploymentsOp; |
| 28 | 28 | use crate::alerts::{AlertKind, SecurityAlert}; | |
| 29 | 29 | use crate::checks::ChecksOp; | |
| 30 | 30 | use crate::about::AboutOp; | |
| 31 | − | use crate::artifacts::ArtifactsOp; | |
| 31 | + | use crate::run_artifacts::ArtifactsOp; | |
| 32 | 32 | use crate::deploy_keys::DeployKeysOp; | |
| 33 | 33 | use crate::mirrors::MirrorsOp; | |
| 34 | 34 | use crate::deployments::DeploymentsOp; | |
| ⋯ | |||
| 69 | 69 | pub deployments: Fetcher, | |
| 70 | 70 | /// Packages: their settings, versions, deleting and restoring them. | |
| 71 | 71 | pub packages: Fetcher, | |
| 72 | − | /// The docs service: Artifacts mode's docs, slides, designs and | |
| 72 | + | /// The artifacts service (services/artifacts): docs, slides, designs and | |
| 73 | 73 | /// dashboards (folios), for the artifact routes and tool. | |
| 74 | − | pub docs: Fetcher, | |
| 74 | + | pub artifacts: Fetcher, | |
| 75 | 75 | /// Where the request came in, for its audit entries. | |
| 76 | 76 | pub audit: crate::audit::AuditContext, | |
| 77 | 77 | /// Set for a request made with an agent's token: all it may do. | |
| ⋯ | |||
| 98 | 98 | projects: env.service("PROJECTS")?, | |
| 99 | 99 | deployments: env.service("DEPLOYMENTS")?, | |
| 100 | 100 | packages: env.service("PACKAGES")?, | |
| 101 | − | docs: env.service("DOCS")?, | |
| 101 | + | artifacts: env.service("ARTIFACTS")?, | |
| 102 | 102 | scope: None, | |
| 103 | 103 | audit: crate::audit::AuditContext::default(), | |
| 104 | 104 | addresses: crate::addresses::Addresses::from_env(env), | |
| ⋯ | |||
| 316 | 316 | /// A workspace's rules for personal access tokens, its members' | |
| 317 | 317 | /// tokens and approving them: token_policy.rs. | |
| 318 | 318 | Tokens(TokenOp), | |
| 319 | − | /// Workflow run artifacts, and how long they are kept: artifacts.rs. | |
| 319 | + | /// Workflow run artifacts, and how long they are kept: run_artifacts.rs. | |
| 320 | 320 | Artifacts(ArtifactsOp), | |
| 321 | 321 | /// A repository's deploy keys: deploy_keys.rs. | |
| 322 | 322 | DeployKeys(DeployKeysOp), | |
| ⋯ | |||
| 327 | 327 | /// them, and who may use them: packages.rs. | |
| 328 | 328 | Packages(PackagesOp), | |
| 329 | 329 | /// Artifacts mode's docs, slides, designs and dashboards, kept by the | |
| 330 | − | /// docs service: folios.rs. | |
| 330 | + | /// artifacts service: folios.rs. | |
| 331 | 331 | Folios(FoliosOp), | |
| 332 | 332 | } | |
| 333 | 333 | ||
| ⋯ | |||
| 5624 | 5624 | Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await, | |
| 5625 | 5625 | Op::Protection(op) => crate::protection::run(op, services, viewer, input).await, | |
| 5626 | 5626 | Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await, | |
| 5627 | − | Op::Artifacts(op) => crate::artifacts::run(op, services, viewer, input).await, | |
| 5627 | + | Op::Artifacts(op) => crate::run_artifacts::run(op, services, viewer, input).await, | |
| 5628 | 5628 | Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await, | |
| 5629 | 5629 | Op::Mirrors(op) => crate::mirrors::run(op, services, viewer, input).await, | |
| 5630 | 5630 | Op::Packages(op) => crate::packages::run(op, services, viewer, input).await, | |
| 3 | 3 | use serde_json::{Map, Value}; | |
| 4 | 4 | ||
| 5 | 5 | use crate::about::AboutOp; | |
| 6 | − | use crate::artifacts::ArtifactsOp; | |
| 6 | + | use crate::run_artifacts::ArtifactsOp; | |
| 7 | 7 | use crate::deploy_keys::DeployKeysOp; | |
| 8 | 8 | use crate::mirrors::MirrorsOp; | |
| 9 | 9 | use crate::deployments::DeploymentsOp; |
| 1 | + | //! Workflow run artifacts over REST and MCP, in GitHub's shapes: listing a | |
| 2 | + | //! repository's or a run's, one by id, a link to download it, deleting it, | |
| 3 | + | //! and how long a repository keeps them. | |
| 4 | + | //! | |
| 5 | + | //! The actions service keeps them and decides who may see and change | |
| 6 | + | //! them (`g1t_contracts::actions`); their bytes are in R2, downloaded | |
| 7 | + | //! through the toolkit's blob endpoint (toolkit.rs) with a link signed for | |
| 8 | + | //! a few minutes. `GET …/artifacts/{id}/zip` answers with a redirect to | |
| 9 | + | //! that link, as GitHub's does. | |
| 10 | + | ||
| 11 | + | use g1t_contracts::actions::{Artifact, ArtifactArgs, ArtifactBlob, ArtifactList, ArtifactRetention, ArtifactRetentionArgs, ArtifactsArgs, DeleteArtifactArgs}; | |
| 12 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 13 | + | use serde_json::{Value, json}; | |
| 14 | + | use worker::Result; | |
| 15 | + | ||
| 16 | + | use crate::operations::{Services, repo_path}; | |
| 17 | + | ||
| 18 | + | /// One operation on artifacts. | |
| 19 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 20 | + | pub enum ArtifactsOp { | |
| 21 | + | ListArtifacts, | |
| 22 | + | ListRunArtifacts, | |
| 23 | + | GetArtifact, | |
| 24 | + | DownloadArtifact, | |
| 25 | + | DeleteArtifact, | |
| 26 | + | GetArtifactRetention, | |
| 27 | + | SetArtifactRetention, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | impl ArtifactsOp { | |
| 31 | + | /// Every one: `Op::ALL` lists each as `Op::Artifacts(…)`, which a test | |
| 32 | + | /// checks against this. | |
| 33 | + | #[cfg(test)] | |
| 34 | + | pub const ALL: [ArtifactsOp; 7] = [ | |
| 35 | + | ArtifactsOp::ListArtifacts, | |
| 36 | + | ArtifactsOp::ListRunArtifacts, | |
| 37 | + | ArtifactsOp::GetArtifact, | |
| 38 | + | ArtifactsOp::DownloadArtifact, | |
| 39 | + | ArtifactsOp::DeleteArtifact, | |
| 40 | + | ArtifactsOp::GetArtifactRetention, | |
| 41 | + | ArtifactsOp::SetArtifactRetention, | |
| 42 | + | ]; | |
| 43 | + | ||
| 44 | + | pub fn name(self) -> &'static str { | |
| 45 | + | match self { | |
| 46 | + | ArtifactsOp::ListArtifacts => "list_artifacts", | |
| 47 | + | ArtifactsOp::ListRunArtifacts => "list_workflow_run_artifacts", | |
| 48 | + | ArtifactsOp::GetArtifact => "get_artifact", | |
| 49 | + | ArtifactsOp::DownloadArtifact => "download_artifact", | |
| 50 | + | ArtifactsOp::DeleteArtifact => "delete_artifact", | |
| 51 | + | ArtifactsOp::GetArtifactRetention => "get_artifact_retention", | |
| 52 | + | ArtifactsOp::SetArtifactRetention => "set_artifact_retention", | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// For the API reference. | |
| 57 | + | pub fn title(self) -> &'static str { | |
| 58 | + | match self { | |
| 59 | + | ArtifactsOp::ListArtifacts => "List a repository's artifacts", | |
| 60 | + | ArtifactsOp::ListRunArtifacts => "List a workflow run's artifacts", | |
| 61 | + | ArtifactsOp::GetArtifact => "Get an artifact", | |
| 62 | + | ArtifactsOp::DownloadArtifact => "Download an artifact", | |
| 63 | + | ArtifactsOp::DeleteArtifact => "Delete an artifact", | |
| 64 | + | ArtifactsOp::GetArtifactRetention => "Get artifact retention", | |
| 65 | + | ArtifactsOp::SetArtifactRetention => "Set artifact retention", | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | pub fn description(self) -> &'static str { | |
| 70 | + | match self { | |
| 71 | + | ArtifactsOp::ListArtifacts => "List a repository's artifacts that have not expired, newest first: each with its id (a number), name, size_in_bytes, digest (sha256:… of its zip), created_at, expires_at, archive_download_url, and workflow_run (its run's id, head_branch and head_sha). Narrow with name; page with page and per_page (30 by default, at most 100). total_count counts every match. Needs the Read role; a public repository's are open to anyone.", | |
| 72 | + | ArtifactsOp::ListRunArtifacts => "List one workflow run's artifacts that have not expired, oldest first, in the same shape as list_artifacts. Narrow with name. Needs the Read role.", | |
| 73 | + | ArtifactsOp::GetArtifact => "Get one artifact by its id: its name, size_in_bytes, digest, when it was made and when it expires, and its run. Needs the Read role.", | |
| 74 | + | ArtifactsOp::DownloadArtifact => "A link to download an artifact as a zip file (an artifact an older runner kept is a .tar.gz), good for 10 minutes and needing no token. Over REST, GET …/zip answers 302 with the link in Location, as GitHub does: `curl -L` follows it. Over MCP the link is returned as url, with expires_at. Needs the Read role.", | |
| 75 | + | ArtifactsOp::DeleteArtifact => "Delete an artifact before it expires: its bytes go at once and its id stops resolving. Needs the Write role.", | |
| 76 | + | ArtifactsOp::GetArtifactRetention => "How many days the repository keeps artifacts (days), and the most it may choose (maximum_allowed_days, 90). A workflow's retention-days can ask for fewer days, never more. Needs the Read role.", | |
| 77 | + | ArtifactsOp::SetArtifactRetention => "Set how many days the repository keeps artifacts by default, and at most: days, from 1 to 90. Artifacts already uploaded keep the expiry they were given. Needs the Maintain role.", | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// Whether it changes anything. | |
| 82 | + | pub fn writes(self) -> bool { | |
| 83 | + | matches!(self, ArtifactsOp::DeleteArtifact | ArtifactsOp::SetArtifactRetention) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | pub fn input(self) -> Value { | |
| 87 | + | let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); | |
| 88 | + | let id = json!({ "type": ["integer", "string"], "description": "The artifact's id, a number." }); | |
| 89 | + | let (properties, required): (Value, &[&str]) = match self { | |
| 90 | + | ArtifactsOp::ListArtifacts => ( | |
| 91 | + | json!({ | |
| 92 | + | "repo": repo, | |
| 93 | + | "name": { "type": "string", "description": "Only artifacts with exactly this name." }, | |
| 94 | + | "page": { "type": "integer", "description": "The page, from 1." }, | |
| 95 | + | "per_page": { "type": "integer", "description": "Artifacts a page: 30 unless you say, at most 100." }, | |
| 96 | + | }), | |
| 97 | + | &["repo"], | |
| 98 | + | ), | |
| 99 | + | ArtifactsOp::ListRunArtifacts => ( | |
| 100 | + | json!({ | |
| 101 | + | "repo": repo, | |
| 102 | + | "id": { "type": "string", "description": "The run's id (run_…)." }, | |
| 103 | + | "name": { "type": "string", "description": "Only the artifact with exactly this name." }, | |
| 104 | + | }), | |
| 105 | + | &["repo", "id"], | |
| 106 | + | ), | |
| 107 | + | ArtifactsOp::GetArtifact | ArtifactsOp::DownloadArtifact | ArtifactsOp::DeleteArtifact => (json!({ "repo": repo, "id": id }), &["repo", "id"]), | |
| 108 | + | ArtifactsOp::GetArtifactRetention => (json!({ "repo": repo }), &["repo"]), | |
| 109 | + | ArtifactsOp::SetArtifactRetention => ( | |
| 110 | + | json!({ | |
| 111 | + | "repo": repo, | |
| 112 | + | "days": { "type": "integer", "minimum": 1, "maximum": 90, "description": "Days to keep artifacts, by default and at most." }, | |
| 113 | + | }), | |
| 114 | + | &["repo", "days"], | |
| 115 | + | ), | |
| 116 | + | }; | |
| 117 | + | json!({ "type": "object", "properties": properties, "required": required }) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// A number from a path segment or a JSON number. | |
| 122 | + | fn number(input: &Value, key: &str) -> Option<u64> { | |
| 123 | + | match &input[key] { | |
| 124 | + | Value::Number(n) => n.as_u64(), | |
| 125 | + | Value::String(s) => s.trim().parse().ok(), | |
| 126 | + | _ => None, | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /// An outcome's value made into what is sent; its failure as it is. | |
| 131 | + | fn mapped<T>(outcome: Outcome<T>, f: impl FnOnce(T) -> Value) -> Outcome<Value> { | |
| 132 | + | match outcome { | |
| 133 | + | Outcome::Ok(value) => Outcome::Ok(f(value)), | |
| 134 | + | Outcome::Fail(refused) => Outcome::Fail(refused), | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | fn text(input: &Value, key: &str) -> Option<String> { | |
| 139 | + | input[key].as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// An artifact as GitHub's REST API shows one. | |
| 143 | + | pub fn shown(artifact: &Artifact, api: &str, repository: &str) -> Value { | |
| 144 | + | let url = format!("{api}/repos/{repository}/actions/artifacts/{}", artifact.id); | |
| 145 | + | json!({ | |
| 146 | + | "id": artifact.id, | |
| 147 | + | "node_id": format!("artifact_{}", artifact.id), | |
| 148 | + | "name": artifact.name, | |
| 149 | + | "size_in_bytes": artifact.size, | |
| 150 | + | "url": url, | |
| 151 | + | "archive_download_url": format!("{url}/zip"), | |
| 152 | + | "expired": artifact.expired, | |
| 153 | + | "digest": artifact.digest, | |
| 154 | + | "created_at": artifact.created_at, | |
| 155 | + | "updated_at": artifact.updated_at, | |
| 156 | + | "expires_at": artifact.expires_at, | |
| 157 | + | "workflow_run": { | |
| 158 | + | "id": artifact.run_id, | |
| 159 | + | "repository_id": artifact.repo_id, | |
| 160 | + | "head_repository_id": artifact.repo_id, | |
| 161 | + | "head_branch": artifact.head_branch, | |
| 162 | + | "head_sha": artifact.head_sha, | |
| 163 | + | }, | |
| 164 | + | }) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// Where a signed blob token downloads from. | |
| 168 | + | pub fn blob_url(api: &str, blob: &str) -> String { | |
| 169 | + | format!("{api}/actions/toolkit/blobs/{blob}") | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | fn list(found: ArtifactList, api: &str, repository: &str) -> Value { | |
| 173 | + | json!({ | |
| 174 | + | "total_count": found.total_count, | |
| 175 | + | "artifacts": found.artifacts.iter().map(|a| shown(a, api, repository)).collect::<Vec<_>>(), | |
| 176 | + | }) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | pub async fn run(op: ArtifactsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 180 | + | let Some(repo) = repo_path(input) else { | |
| 181 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); | |
| 182 | + | }; | |
| 183 | + | let repository = format!("{}/{}", repo.namespace, repo.name); | |
| 184 | + | let api = services.addresses.api.clone(); | |
| 185 | + | let actions = &services.actions; | |
| 186 | + | let id = number(input, "id"); | |
| 187 | + | let by_id = || ArtifactArgs { repo: repo.clone(), viewer: viewer.clone(), id, run: None, name: None }; | |
| 188 | + | if matches!(op, ArtifactsOp::GetArtifact | ArtifactsOp::DownloadArtifact | ArtifactsOp::DeleteArtifact) && id.is_none() { | |
| 189 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Name the artifact by its id, a number.")); | |
| 190 | + | } | |
| 191 | + | Ok(match op { | |
| 192 | + | ArtifactsOp::ListArtifacts | ArtifactsOp::ListRunArtifacts => { | |
| 193 | + | let run = (op == ArtifactsOp::ListRunArtifacts).then(|| text(input, "id")).flatten(); | |
| 194 | + | let args = ArtifactsArgs { | |
| 195 | + | repo: repo.clone(), | |
| 196 | + | viewer: viewer.clone(), | |
| 197 | + | run, | |
| 198 | + | name: text(input, "name"), | |
| 199 | + | page: number(input, "page").map(|n| n as u32), | |
| 200 | + | per_page: number(input, "per_page").map(|n| n as u32), | |
| 201 | + | }; | |
| 202 | + | let found: Outcome<ArtifactList> = g1t_kit::call(actions, "artifacts", &args).await?; | |
| 203 | + | mapped(found, |mut found| { | |
| 204 | + | // A run's are listed in the order they were made. | |
| 205 | + | if op == ArtifactsOp::ListRunArtifacts { | |
| 206 | + | found.artifacts.reverse(); | |
| 207 | + | } | |
| 208 | + | list(found, &api, &repository) | |
| 209 | + | }) | |
| 210 | + | } | |
| 211 | + | ArtifactsOp::GetArtifact => { | |
| 212 | + | let found: Outcome<Artifact> = g1t_kit::call(actions, "artifact", &by_id()).await?; | |
| 213 | + | mapped(found, |a| shown(&a, &api, &repository)) | |
| 214 | + | } | |
| 215 | + | ArtifactsOp::DownloadArtifact => { | |
| 216 | + | let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "artifact_download", &by_id()).await?; | |
| 217 | + | match found { | |
| 218 | + | Outcome::Ok(found) if found.blob.is_empty() => Outcome::fail(FailureCode::Invalid, "Download links are not set up on this installation."), | |
| 219 | + | Outcome::Ok(found) => Outcome::Ok(json!({ | |
| 220 | + | "url": blob_url(&api, &found.blob), | |
| 221 | + | "expires_at": g1t_contracts::time::rfc3339(g1t_kit::now_ms() + 10 * 60 * 1000), | |
| 222 | + | "artifact": shown(&found.artifact, &api, &repository), | |
| 223 | + | })), | |
| 224 | + | Outcome::Fail(refused) => Outcome::Fail(refused), | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ArtifactsOp::DeleteArtifact => { | |
| 228 | + | let Some(actor) = viewer.clone() else { | |
| 229 | + | return Ok(Outcome::fail(FailureCode::Unauthenticated, "Deleting an artifact needs a g1t access token.")); | |
| 230 | + | }; | |
| 231 | + | let done: Outcome<Artifact> = g1t_kit::call(actions, "delete_artifact", &DeleteArtifactArgs { actor, repo, id: id.unwrap_or_default() }).await?; | |
| 232 | + | mapped(done, |a| json!({ "deleted": true, "id": a.id, "name": a.name })) | |
| 233 | + | } | |
| 234 | + | ArtifactsOp::GetArtifactRetention | ArtifactsOp::SetArtifactRetention => { | |
| 235 | + | let days = if op == ArtifactsOp::SetArtifactRetention { | |
| 236 | + | match number(input, "days") { | |
| 237 | + | Some(days) => Some(days.min(u64::from(u32::MAX)) as u32), | |
| 238 | + | None => return Ok(Outcome::fail(FailureCode::Invalid, "Give days, from 1 to 90.")), | |
| 239 | + | } | |
| 240 | + | } else { | |
| 241 | + | None | |
| 242 | + | }; | |
| 243 | + | let found: Outcome<ArtifactRetention> = g1t_kit::call(actions, "artifact_retention", &ArtifactRetentionArgs { repo, viewer: viewer.clone(), days }).await?; | |
| 244 | + | mapped(found, |r| json!({ "days": r.days, "maximum_allowed_days": r.maximum_allowed_days })) | |
| 245 | + | } | |
| 246 | + | }) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | #[cfg(test)] | |
| 250 | + | mod tests { | |
| 251 | + | use super::*; | |
| 252 | + | ||
| 253 | + | fn artifact() -> Artifact { | |
| 254 | + | Artifact { | |
| 255 | + | id: 42, | |
| 256 | + | name: "dist".into(), | |
| 257 | + | size: 1024, | |
| 258 | + | digest: Some(format!("sha256:{}", "a".repeat(64))), | |
| 259 | + | format: "zip".into(), | |
| 260 | + | run_id: "run_1".into(), | |
| 261 | + | job_id: "job_1".into(), | |
| 262 | + | repo_id: "repo_1".into(), | |
| 263 | + | expired: false, | |
| 264 | + | created_at: "2026-10-08T12:00:00.000Z".into(), | |
| 265 | + | updated_at: "2026-10-08T12:00:00.000Z".into(), | |
| 266 | + | expires_at: "2026-10-22T12:00:00.000Z".into(), | |
| 267 | + | head_branch: Some("main".into()), | |
| 268 | + | head_sha: Some("abc".into()), | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | #[test] | |
| 273 | + | fn an_artifact_is_shown_as_github_shows_one() { | |
| 274 | + | let shown = shown(&artifact(), "https://api.g1t.sh", "acme/web"); | |
| 275 | + | assert_eq!(shown["id"], 42); | |
| 276 | + | assert_eq!(shown["size_in_bytes"], 1024); | |
| 277 | + | assert_eq!(shown["url"], "https://api.g1t.sh/repos/acme/web/actions/artifacts/42"); | |
| 278 | + | assert_eq!(shown["archive_download_url"], "https://api.g1t.sh/repos/acme/web/actions/artifacts/42/zip"); | |
| 279 | + | assert_eq!(shown["workflow_run"]["head_branch"], "main"); | |
| 280 | + | assert!(g1t_kit::wire::camel_case_keys(&shown).is_empty()); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | #[test] | |
| 284 | + | fn ids_are_read_from_a_path_or_a_number() { | |
| 285 | + | assert_eq!(number(&json!({ "id": "42" }), "id"), Some(42)); | |
| 286 | + | assert_eq!(number(&json!({ "id": 42 }), "id"), Some(42)); | |
| 287 | + | assert_eq!(number(&json!({ "id": "run_1" }), "id"), None); | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | #[test] | |
| 291 | + | fn each_operation_is_described_with_a_schema() { | |
| 292 | + | for op in ArtifactsOp::ALL { | |
| 293 | + | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Artifacts(op)), "{}", op.name()); | |
| 294 | + | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 295 | + | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); | |
| 296 | + | let level = g1t_contracts::scopes::scope_for(op.name()).unwrap().level(); | |
| 297 | + | assert_eq!(op.writes(), level == g1t_contracts::scopes::Level::Write, "{}", op.name()); | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | } |
| 26 | 26 | //! which clients that sign their requests with it need. | |
| 27 | 27 | //! | |
| 28 | 28 | //! Every call carries the job's runtime token; the actions service checks | |
| 29 | − | //! it and keeps the entries (cache.rs, artifacts.rs, runtime.rs there). | |
| 29 | + | //! it and keeps the entries (cache.rs, run_artifacts.rs, runtime.rs there). | |
| 30 | 30 | ||
| 31 | 31 | use base64::Engine; | |
| 32 | 32 | use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; | |
| ⋯ | |||
| 39 | 39 | use serde_json::{Map, Value, json}; | |
| 40 | 40 | use worker::{Bucket, Env, Request, Response, Result, UploadedPart}; | |
| 41 | 41 | ||
| 42 | − | use crate::artifacts::blob_url; | |
| 42 | + | use crate::run_artifacts::blob_url; | |
| 43 | 43 | use crate::operations::Services; | |
| 44 | 44 | ||
| 45 | 45 | /// The Twirp services, by name. | |
| 19 | 19 | use serde_json::{Map, Value, json}; | |
| 20 | 20 | ||
| 21 | 21 | use crate::about::AboutOp; | |
| 22 | − | use crate::artifacts::ArtifactsOp; | |
| 22 | + | use crate::run_artifacts::ArtifactsOp; | |
| 23 | 23 | use crate::deploy_keys::DeployKeysOp; | |
| 24 | 24 | use crate::mirrors::MirrorsOp; | |
| 25 | 25 | use crate::deployments::DeploymentsOp; |
| 35 | 35 | // Packages: list_packages, their settings, deleting and restoring them. | |
| 36 | 36 | { "binding": "PACKAGES", "service": "g1t-packages" }, | |
| 37 | 37 | // Artifacts (folios): the artifact tool and /workspaces/{ws}/artifacts. | |
| 38 | − | { "binding": "DOCS", "service": "g1t-docs-service" } | |
| 38 | + | { "binding": "ARTIFACTS", "service": "g1t-artifacts" } | |
| 39 | 39 | ], | |
| 40 | 40 | // GitHub Actions artifacts older runners kept, in chunks, with KV's own | |
| 41 | 41 | // expiry, read until it passes (and cache |
| 62 | 62 | A unit binds only to units in its own stage or an earlier one, so new code | |
| 63 | 63 | never calls a service that has not shipped. `npm run test:deploy` checks | |
| 64 | 64 | this, and that every `wrangler.jsonc` has a unit whose names match it. | |
| 65 | + | Cloudflare refuses a Worker bound to a Worker that does not exist, so | |
| 66 | + | inside a stage a unit whose Worker has never been deployed (a new or | |
| 67 | + | renamed one) goes first, and the units of its stage that bind to it go | |
| 68 | + | after it succeeds. | |
| 65 | 69 | ||
| 66 | 70 | ## The tool | |
| 67 | 71 | ||
| ⋯ | |||
| 205 | 209 | `node scripts/deploy.mjs doctor` lists what is missing. | |
| 206 | 210 | 9. For [Deployments](/guides/deployments/), which need the Workers for | |
| 207 | 211 | Platforms add-on and a zone for apps: `scripts/setup-deployments.sh`. | |
| 208 | − | 10. `node scripts/deploy.mjs deploy --all`. A Worker bound to a service | |
| 209 | − | that does not exist yet may be refused: deploy that service first with | |
| 210 | − | `--only`. | |
| 211 | − | 11. Register on your site to make the first account, or run | |
| 212 | + | 10. Durable Objects: the artifacts service's v3 migration | |
| 213 | + | (`services/artifacts/wrangler.jsonc`) moves g1t.sh's live rooms from | |
| 214 | + | the Worker it was renamed from, which a new account does not have. | |
| 215 | + | Replace it with | |
| 216 | + | `{ "tag": "v3", "new_sqlite_classes": ["PageRoom", "FolioRoom"] }`. | |
| 217 | + | 11. `node scripts/deploy.mjs deploy --all`. Inside a stage, Workers that do | |
| 218 | + | not exist yet go before those bound to them; a Worker bound to one in a | |
| 219 | + | later stage, or to one that failed, is refused until it exists. | |
| 220 | + | 12. Register on your site to make the first account, or run | |
| 212 | 221 | `node services/identity/scripts/create-user.mjs <username>`. | |
| 213 | 222 | ||
| 214 | 223 | ### Adding a unit | |
| ⋯ | |||
| 224 | 233 | 4. `npm run test:deploy` and `node scripts/deploy.mjs manifest --check` | |
| 225 | 234 | say what is missing. | |
| 226 | 235 | ||
| 236 | + | ### Renaming a Worker | |
| 237 | + | ||
| 238 | + | A Worker's name is its identity on Cloudflare, so a renamed Worker is a | |
| 239 | + | new one. Keep its resources: D1 databases, R2 buckets, Vectorize indexes | |
| 240 | + | and queues are bound by id or name and keep theirs. Durable Objects | |
| 241 | + | belong to a Worker, so the new one takes them with a | |
| 242 | + | [transfer migration](https://developers.cloudflare.com/durable-objects/reference/durable-object-class-migrations-legacy/#transfer-migration) | |
| 243 | + | (`transferred_classes`, with `from_script` the old name), every object's | |
| 244 | + | storage with them; until the old Worker is deleted, its own bindings to | |
| 245 | + | them reach the new one. A queue has one consumer, so the old Worker lets | |
| 246 | + | go of it first. | |
| 247 | + | ||
| 248 | + | The artifacts service was `g1t-docs-service` (`services/docs`) and is | |
| 249 | + | `g1t-artifacts` (`services/artifacts`). An installation that ran the old | |
| 250 | + | one moves once, in this order: | |
| 251 | + | ||
| 252 | + | 1. Let go of the queue. Its messages wait for the new consumer: | |
| 253 | + | ||
| 254 | + | ```sh | |
| 255 | + | npx wrangler queues consumer remove g1t-events-docs g1t-docs-service | |
| 256 | + | ``` | |
| 257 | + | ||
| 258 | + | 2. Deploy: push to `main`, or `node scripts/deploy.mjs deploy`. | |
| 259 | + | `g1t-artifacts` goes first in `core`, takes `PageRoom` and `FolioRoom` | |
| 260 | + | and the queue; then `agents`, then `api` (edge) and `web` (front), | |
| 261 | + | each bound to it. Until each has gone out it still calls | |
| 262 | + | `g1t-docs-service`, which answers from the same database, bucket and | |
| 263 | + | (forwarded) rooms. | |
| 264 | + | 3. Check: an artifact opens with its content and edits live, a file | |
| 265 | + | uploads, and `npx wrangler queues info g1t-events-docs` names | |
| 266 | + | `g1t-artifacts` as the consumer. Cloudflare's | |
| 267 | + | `GET /accounts/{account_id}/workers/durable_objects/namespaces` lists | |
| 268 | + | `PageRoom` and `FolioRoom` under `g1t-artifacts`. | |
| 269 | + | 4. After a day with no requests to it (`npx wrangler tail g1t-docs-service` | |
| 270 | + | shows only its 03:17 UTC cron), delete it by hand: | |
| 271 | + | `npx wrangler delete g1t-docs-service`. Never with `--force`: a refusal | |
| 272 | + | means something still binds to it. | |
| 273 | + | ||
| 274 | + | If step 2 fails before `g1t-artifacts` deploys, nothing moved: put the | |
| 275 | + | consumer back with `npx wrangler deploy` in `services/docs` at the | |
| 276 | + | previous commit. Once it has deployed, the rooms are its own: fix forward. | |
| 277 | + | ||
| 227 | 278 | ## The runner's images | |
| 228 | 279 | ||
| 229 | 280 | `services/runner` runs every sandbox (agents, checks, the merge queue, | |
| 30 | 30 | ||
| 31 | 31 | | Binding | Stands in for | Self-hosted | | |
| 32 | 32 | | --- | --- | --- | | |
| 33 | − | | `ARTIFACTS` | Cloudflare Artifacts, where g1t.sh keeps repositories | `deploy/self-host/workers/artifacts`, in front of the git store: bare repositories on the `g1t-git` volume, served with `git http-backend`. Access tokens are HMAC-signed, scoped and expire. Forks are clones with hard-linked objects. | | |
| 33 | + | | `GITSTORE` | Cloudflare Artifacts, where g1t.sh keeps repositories (the repos service's git store) | `deploy/self-host/workers/gitstore`, in front of the git store: bare repositories on the `g1t-git` volume, served with `git http-backend`. Access tokens are HMAC-signed, scoped and expire. Forks are clones with hard-linked objects. | | |
| 34 | 34 | | `EMAIL` | Cloudflare Email Sending | `deploy/self-host/workers/mail`: logs each message and hands it to Mailpit, which can relay through your SMTP server | | |
| 35 | 35 | | The runner, the context hub and the model proxy | Containers, Vectorize, Workers AI and AI Gateway | `deploy/self-host/workers/off`: answers that the feature is off, which every page that uses them shows | | |
| 36 | 36 | ||
| ⋯ | |||
| 63 | 63 | | `services/projects` | Runs | | |
| 64 | 64 | | `services/search` | Runs: site search is SQLite full-text search | | |
| 65 | 65 | | `services/chat` | Runs, with its Durable Objects; custom emoji are kept with avatars | | |
| 66 | − | | `services/docs` | Runs: pages and [artifacts](/guides/artifacts/), with their live rooms. Files people add are kept in the `g1t-docs-files` bucket. Search matches words, not meaning, because there is no embedding model. | | |
| 66 | + | | `services/artifacts` | Runs: [artifacts](/guides/artifacts/) and their spaces, with their live rooms. Files people add are kept in the `g1t-docs-files` bucket. Search matches words, not meaning, because there is no embedding model. | | |
| 67 | 67 | | `services/notify` | Runs. Notifications are live in open tabs; browser push needs a VAPID key pair, which an installation does not make. | | |
| 68 | 68 | | `services/agents` | Runs, but replies need the model proxy, which is off, so an agent answers with a short apology | | |
| 69 | 69 | | `services/billing` | Runs with nothing charged and no usage limit | | |
| 4 | 4 | * request, a channel, a project, another page); and inline mentions of | |
| 5 | 5 | * people, agents and pages, dates, and citations of code (a file, folder | |
| 6 | 6 | * or pattern in a repository, and the symbol, endpoint or environment | |
| 7 | − | * variable there the page describes). The docs service reads the same | |
| 8 | − | * names and attributes when it writes Markdown (services/docs | |
| 7 | + | * variable there the page describes). The artifacts service reads the same | |
| 8 | + | * names and attributes when it writes Markdown (services/artifacts | |
| 9 | 9 | * src/markdown.ts), so keep the two in step. Browser-only: loaded with | |
| 10 | 10 | * the editor. | |
| 11 | 11 | */ | |
| ⋯ | |||
| 299 | 299 | * Code the page cites, inline: what it names (the path, or the symbol, | |
| 300 | 300 | * endpoint or variable there), linking to it in Code at the commit it was | |
| 301 | 301 | * cited at. When a merge changes it, the page is marked possibly out of | |
| 302 | − | * date (services/docs src/staleness.ts). | |
| 302 | + | * date (services/artifacts src/staleness.ts). | |
| 303 | 303 | */ | |
| 304 | 304 | export const Citation = createReactInlineContentSpec( | |
| 305 | 305 | { | |
| 2 | 2 | * An artifact's live connection, whatever its kind: the Yjs document and | |
| 3 | 3 | * everyone's presence, synced over | |
| 4 | 4 | * `wss://<site>/<workspace>/-/artifacts/live?folio=<id>` with the folio's | |
| 5 | − | * room (services/docs src/folios/room.ts). Binary frames speak the | |
| 5 | + | * room (services/artifacts src/folios/room.ts). Binary frames speak the | |
| 6 | 6 | * y-protocols sync and awareness messages; text frames are the service's | |
| 7 | 7 | * own notices (`FoliosLiveEvent`): a rename, a new suggestion, a version, | |
| 8 | 8 | * a change of access. |
| 1 | − | import { env } from "cloudflare:workers"; | |
| 2 | − | ||
| 3 | − | import type { RepoPath, Viewer } from "@g1t/contracts"; | |
| 4 | − | ||
| 5 | − | import { LEGACY_KV_UNTIL } from "./artifacts"; | |
| 6 | − | import { actions } from "./services.server"; | |
| 7 | − | ||
| 8 | − | /** | |
| 9 | − | * A run's artifacts, for its page. The actions service lists them (their | |
| 10 | − | * bytes are in R2, downloaded through the API's signed links); artifacts an | |
| 11 | − | * older runner kept in KV (`a/{run}/{name}`, its bytes in chunks `…#0`, | |
| 12 | − | * `…#1`) are listed too until KV expires them, for runs the caller has | |
| 13 | − | * already been shown (`legacyArtifactsWorthAsking` in artifacts.ts). | |
| 14 | − | */ | |
| 15 | − | export type ArtifactRow = { | |
| 16 | − | /** The artifact's number; null for one kept in KV. */ | |
| 17 | − | id: number | null; | |
| 18 | − | name: string; | |
| 19 | − | size: number; | |
| 20 | − | /** When it goes, RFC 3339; null for one kept in KV (14 days after it was made). */ | |
| 21 | − | expiresAt: string | null; | |
| 22 | − | createdAt: string; | |
| 23 | − | }; | |
| 24 | − | ||
| 25 | − | type Meta = { size: number; chunks: number; at: number; name: string }; | |
| 26 | − | ||
| 27 | − | async function kvArtifacts(run: string): Promise<ArtifactRow[]> { | |
| 28 | − | const listed = await env.BLOBS.list<Meta>({ prefix: `a/${run}/` }); | |
| 29 | − | return listed.keys | |
| 30 | − | .filter((key) => !key.name.includes("#") && key.metadata) | |
| 31 | − | .map((key) => ({ | |
| 32 | − | id: null, | |
| 33 | − | name: key.metadata!.name, | |
| 34 | − | size: key.metadata!.size, | |
| 35 | − | expiresAt: new Date(key.metadata!.at + 14 * 86_400_000).toISOString(), | |
| 36 | − | createdAt: new Date(key.metadata!.at).toISOString(), | |
| 37 | − | })); | |
| 38 | − | } | |
| 39 | − | ||
| 40 | − | /** A run's artifacts the actions service keeps, which checks who may see them. */ | |
| 41 | − | export async function listArtifacts(repo: RepoPath, viewer: Viewer, run: string): Promise<ArtifactRow[]> { | |
| 42 | − | const kept = await actions.artifacts(repo, viewer, { run, per_page: 100 }); | |
| 43 | − | const rows: ArtifactRow[] = kept.ok | |
| 44 | − | ? kept.value.artifacts.map((a) => ({ id: a.id, name: a.name, size: a.size, expiresAt: a.expires_at, createdAt: a.created_at })) | |
| 45 | − | : []; | |
| 46 | − | return rows.sort((a, b) => a.name.localeCompare(b.name)); | |
| 47 | − | } | |
| 48 | − | ||
| 49 | − | /** | |
| 50 | − | * `rows` with the artifacts an older runner kept in KV for `run` added. | |
| 51 | − | * Only for a run the viewer was allowed to see. Delete after | |
| 52 | − | * 2026-10-22T00:00Z (`LEGACY_KV_UNTIL`). | |
| 53 | − | */ | |
| 54 | − | export async function withLegacyArtifacts(rows: ArtifactRow[], run: string): Promise<ArtifactRow[]> { | |
| 55 | − | if (Date.now() >= LEGACY_KV_UNTIL) return rows; | |
| 56 | − | const legacy = await kvArtifacts(run).catch(() => []); | |
| 57 | − | const all = [...rows]; | |
| 58 | − | for (const row of legacy) { | |
| 59 | − | if (!all.some((r) => r.name === row.name)) all.push(row); | |
| 60 | − | } | |
| 61 | − | return all.sort((a, b) => a.name.localeCompare(b.name)); | |
| 62 | − | } | |
| 63 | − | ||
| 64 | − | export async function readArtifact(run: string, name: string): Promise<Uint8Array | null> { | |
| 65 | − | // Every artifact kept in KV has expired (artifacts.ts). | |
| 66 | − | if (Date.now() >= LEGACY_KV_UNTIL) return null; | |
| 67 | − | const base = `a/${run}/${name}`; | |
| 68 | − | const meta = await env.BLOBS.get<Meta>(base, "json"); | |
| 69 | − | if (!meta) return null; | |
| 70 | − | const parts = await Promise.all( | |
| 71 | − | Array.from({ length: meta.chunks }, (_, index) => env.BLOBS.get(`${base}#${index}`, "arrayBuffer")), | |
| 72 | − | ); | |
| 73 | − | if (parts.some((part) => part == null)) return null; | |
| 74 | − | const out = new Uint8Array(meta.size); | |
| 75 | − | let offset = 0; | |
| 76 | − | for (const part of parts as ArrayBuffer[]) { | |
| 77 | − | out.set(new Uint8Array(part), offset); | |
| 78 | − | offset += part.byteLength; | |
| 79 | − | } | |
| 80 | − | return out; | |
| 81 | − | } |
| 1 | − | import assert from "node:assert/strict"; | |
| 2 | − | import { test } from "node:test"; | |
| 3 | − | ||
| 4 | − | import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "./artifacts.ts"; | |
| 5 | − | ||
| 6 | − | test("sizes read as KB, MB or GB", () => { | |
| 7 | − | assert.equal(formatBytes(10), "1 KB"); | |
| 8 | − | assert.equal(formatBytes(412_870), "403 KB"); | |
| 9 | − | assert.equal(formatBytes(18_734_120), "17.9 MB"); | |
| 10 | − | assert.equal(formatBytes(5 * 1024 ** 3), "5.00 GB"); | |
| 11 | − | }); | |
| 12 | − | ||
| 13 | − | test("expiry reads from now", () => { | |
| 14 | − | const now = Date.parse("2026-10-08T12:00:00Z"); | |
| 15 | − | assert.equal(expiresIn("2026-10-22T12:00:00Z", now), "expires in 14 days"); | |
| 16 | − | assert.equal(expiresIn("2026-10-09T13:00:00Z", now), "expires tomorrow"); | |
| 17 | − | assert.equal(expiresIn("2026-10-08T20:00:00Z", now), "expires today"); | |
| 18 | − | assert.equal(expiresIn("2026-10-08T11:00:00Z", now), "expired"); | |
| 19 | − | assert.equal(expiresIn(null, now), ""); | |
| 20 | − | }); | |
| 21 | − | ||
| 22 | − | test("KV is asked for an old, finished run's artifacts only until they have expired", () => { | |
| 23 | − | const old = { createdAt: "2026-10-07T12:00:00Z", status: "completed" }; | |
| 24 | − | const now = Date.parse("2026-10-10T00:00:00Z"); | |
| 25 | − | assert.ok(legacyArtifactsWorthAsking(old, now)); | |
| 26 | − | // Still going: its artifacts are in R2, and its page refreshes. | |
| 27 | − | assert.ok(!legacyArtifactsWorthAsking({ ...old, status: "in_progress" }, now)); | |
| 28 | − | // Made after the move to R2. | |
| 29 | − | assert.ok(!legacyArtifactsWorthAsking({ ...old, createdAt: "2026-10-09T08:00:00Z" }, now)); | |
| 30 | − | // Every KV artifact has expired. | |
| 31 | − | assert.ok(!legacyArtifactsWorthAsking(old, Date.parse("2026-10-22T00:00:00Z"))); | |
| 32 | − | }); |
| 1 | − | /** How big an artifact is, as people read sizes. */ | |
| 2 | − | export function formatBytes(bytes: number): string { | |
| 3 | − | if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; | |
| 4 | − | if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; | |
| 5 | − | return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; | |
| 6 | − | } | |
| 7 | − | ||
| 8 | − | /** When an artifact goes, from now: "expires today", "expires in 13 days". */ | |
| 9 | − | export function expiresIn(at: string | null, now: number = Date.now()): string { | |
| 10 | − | if (!at) return ""; | |
| 11 | − | const ms = Date.parse(at) - now; | |
| 12 | − | if (Number.isNaN(ms)) return ""; | |
| 13 | − | if (ms <= 0) return "expired"; | |
| 14 | − | const days = Math.floor(ms / 86_400_000); | |
| 15 | − | if (days === 0) return "expires today"; | |
| 16 | − | if (days === 1) return "expires tomorrow"; | |
| 17 | − | return `expires in ${days} days`; | |
| 18 | − | } | |
| 19 | − | ||
| 20 | − | /** | |
| 21 | − | * Artifacts older runners kept in Workers KV. None has been made there | |
| 22 | − | * since artifacts moved to R2 on 2026-10-08, and KV expires each 14 days | |
| 23 | − | * after it was made: from 2026-10-22T00:00Z every one is gone. Delete the | |
| 24 | − | * KV artifact code (here, artifacts.server.ts, and apps/api/src/blobs.rs) | |
| 25 | − | * after that date. | |
| 26 | − | */ | |
| 27 | − | export const LEGACY_KV_UNTIL = Date.parse("2026-10-22T00:00:00Z"); | |
| 28 | − | /** Runs made from this time on kept their artifacts in R2 only. */ | |
| 29 | − | export const LEGACY_KV_BEFORE = Date.parse("2026-10-09T00:00:00Z"); | |
| 30 | − | ||
| 31 | − | /** | |
| 32 | − | * Whether a run's page asks KV for artifacts an older runner kept there: | |
| 33 | − | * only for a finished run made before the move, and only until they have | |
| 34 | − | * all expired. A KV list is the dearest thing KV does, and a run still | |
| 35 | − | * going refreshes its page every few seconds. | |
| 36 | − | */ | |
| 37 | − | export function legacyArtifactsWorthAsking(run: { createdAt: string; status: string }, now: number = Date.now()): boolean { | |
| 38 | − | return now < LEGACY_KV_UNTIL && run.status === "completed" && Date.parse(run.createdAt) < LEGACY_KV_BEFORE; | |
| 39 | − | } |
| 177 | 177 | /** | |
| 178 | 178 | * Where a citation links: the file or folder in Code at the commit it was | |
| 179 | 179 | * cited at, or the default branch (`HEAD`). A glob links to the folder it | |
| 180 | − | * starts from. Mirrors `citationHref` in services/docs src/citations.ts. | |
| 180 | + | * starts from. Mirrors `citationHref` in services/artifacts src/citations.ts. | |
| 181 | 181 | */ | |
| 182 | 182 | export function citationHref(c: { repo: string; path: string; ref: string | null }): string { | |
| 183 | 183 | const parts = c.path.split("/").filter(Boolean); |
| 1 | + | import { env } from "cloudflare:workers"; | |
| 2 | + | ||
| 3 | + | import type { RepoPath, Viewer } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import { LEGACY_KV_UNTIL } from "./run-artifacts"; | |
| 6 | + | import { actions } from "./services.server"; | |
| 7 | + | ||
| 8 | + | /** | |
| 9 | + | * A run's artifacts, for its page. The actions service lists them (their | |
| 10 | + | * bytes are in R2, downloaded through the API's signed links); artifacts an | |
| 11 | + | * older runner kept in KV (`a/{run}/{name}`, its bytes in chunks `…#0`, | |
| 12 | + | * `…#1`) are listed too until KV expires them, for runs the caller has | |
| 13 | + | * already been shown (`legacyArtifactsWorthAsking` in run-artifacts.ts). | |
| 14 | + | */ | |
| 15 | + | export type ArtifactRow = { | |
| 16 | + | /** The artifact's number; null for one kept in KV. */ | |
| 17 | + | id: number | null; | |
| 18 | + | name: string; | |
| 19 | + | size: number; | |
| 20 | + | /** When it goes, RFC 3339; null for one kept in KV (14 days after it was made). */ | |
| 21 | + | expiresAt: string | null; | |
| 22 | + | createdAt: string; | |
| 23 | + | }; | |
| 24 | + | ||
| 25 | + | type Meta = { size: number; chunks: number; at: number; name: string }; | |
| 26 | + | ||
| 27 | + | async function kvArtifacts(run: string): Promise<ArtifactRow[]> { | |
| 28 | + | const listed = await env.BLOBS.list<Meta>({ prefix: `a/${run}/` }); | |
| 29 | + | return listed.keys | |
| 30 | + | .filter((key) => !key.name.includes("#") && key.metadata) | |
| 31 | + | .map((key) => ({ | |
| 32 | + | id: null, | |
| 33 | + | name: key.metadata!.name, | |
| 34 | + | size: key.metadata!.size, | |
| 35 | + | expiresAt: new Date(key.metadata!.at + 14 * 86_400_000).toISOString(), | |
| 36 | + | createdAt: new Date(key.metadata!.at).toISOString(), | |
| 37 | + | })); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /** A run's artifacts the actions service keeps, which checks who may see them. */ | |
| 41 | + | export async function listArtifacts(repo: RepoPath, viewer: Viewer, run: string): Promise<ArtifactRow[]> { | |
| 42 | + | const kept = await actions.artifacts(repo, viewer, { run, per_page: 100 }); | |
| 43 | + | const rows: ArtifactRow[] = kept.ok | |
| 44 | + | ? kept.value.artifacts.map((a) => ({ id: a.id, name: a.name, size: a.size, expiresAt: a.expires_at, createdAt: a.created_at })) | |
| 45 | + | : []; | |
| 46 | + | return rows.sort((a, b) => a.name.localeCompare(b.name)); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /** | |
| 50 | + | * `rows` with the artifacts an older runner kept in KV for `run` added. | |
| 51 | + | * Only for a run the viewer was allowed to see. Delete after | |
| 52 | + | * 2026-10-22T00:00Z (`LEGACY_KV_UNTIL`). | |
| 53 | + | */ | |
| 54 | + | export async function withLegacyArtifacts(rows: ArtifactRow[], run: string): Promise<ArtifactRow[]> { | |
| 55 | + | if (Date.now() >= LEGACY_KV_UNTIL) return rows; | |
| 56 | + | const legacy = await kvArtifacts(run).catch(() => []); | |
| 57 | + | const all = [...rows]; | |
| 58 | + | for (const row of legacy) { | |
| 59 | + | if (!all.some((r) => r.name === row.name)) all.push(row); | |
| 60 | + | } | |
| 61 | + | return all.sort((a, b) => a.name.localeCompare(b.name)); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | export async function readArtifact(run: string, name: string): Promise<Uint8Array | null> { | |
| 65 | + | // Every artifact kept in KV has expired (run-artifacts.ts). | |
| 66 | + | if (Date.now() >= LEGACY_KV_UNTIL) return null; | |
| 67 | + | const base = `a/${run}/${name}`; | |
| 68 | + | const meta = await env.BLOBS.get<Meta>(base, "json"); | |
| 69 | + | if (!meta) return null; | |
| 70 | + | const parts = await Promise.all( | |
| 71 | + | Array.from({ length: meta.chunks }, (_, index) => env.BLOBS.get(`${base}#${index}`, "arrayBuffer")), | |
| 72 | + | ); | |
| 73 | + | if (parts.some((part) => part == null)) return null; | |
| 74 | + | const out = new Uint8Array(meta.size); | |
| 75 | + | let offset = 0; | |
| 76 | + | for (const part of parts as ArrayBuffer[]) { | |
| 77 | + | out.set(new Uint8Array(part), offset); | |
| 78 | + | offset += part.byteLength; | |
| 79 | + | } | |
| 80 | + | return out; | |
| 81 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "./run-artifacts.ts"; | |
| 5 | + | ||
| 6 | + | test("sizes read as KB, MB or GB", () => { | |
| 7 | + | assert.equal(formatBytes(10), "1 KB"); | |
| 8 | + | assert.equal(formatBytes(412_870), "403 KB"); | |
| 9 | + | assert.equal(formatBytes(18_734_120), "17.9 MB"); | |
| 10 | + | assert.equal(formatBytes(5 * 1024 ** 3), "5.00 GB"); | |
| 11 | + | }); | |
| 12 | + | ||
| 13 | + | test("expiry reads from now", () => { | |
| 14 | + | const now = Date.parse("2026-10-08T12:00:00Z"); | |
| 15 | + | assert.equal(expiresIn("2026-10-22T12:00:00Z", now), "expires in 14 days"); | |
| 16 | + | assert.equal(expiresIn("2026-10-09T13:00:00Z", now), "expires tomorrow"); | |
| 17 | + | assert.equal(expiresIn("2026-10-08T20:00:00Z", now), "expires today"); | |
| 18 | + | assert.equal(expiresIn("2026-10-08T11:00:00Z", now), "expired"); | |
| 19 | + | assert.equal(expiresIn(null, now), ""); | |
| 20 | + | }); | |
| 21 | + | ||
| 22 | + | test("KV is asked for an old, finished run's artifacts only until they have expired", () => { | |
| 23 | + | const old = { createdAt: "2026-10-07T12:00:00Z", status: "completed" }; | |
| 24 | + | const now = Date.parse("2026-10-10T00:00:00Z"); | |
| 25 | + | assert.ok(legacyArtifactsWorthAsking(old, now)); | |
| 26 | + | // Still going: its artifacts are in R2, and its page refreshes. | |
| 27 | + | assert.ok(!legacyArtifactsWorthAsking({ ...old, status: "in_progress" }, now)); | |
| 28 | + | // Made after the move to R2. | |
| 29 | + | assert.ok(!legacyArtifactsWorthAsking({ ...old, createdAt: "2026-10-09T08:00:00Z" }, now)); | |
| 30 | + | // Every KV artifact has expired. | |
| 31 | + | assert.ok(!legacyArtifactsWorthAsking(old, Date.parse("2026-10-22T00:00:00Z"))); | |
| 32 | + | }); |
| 1 | + | /** How big an artifact is, as people read sizes. */ | |
| 2 | + | export function formatBytes(bytes: number): string { | |
| 3 | + | if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; | |
| 4 | + | if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; | |
| 5 | + | return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; | |
| 6 | + | } | |
| 7 | + | ||
| 8 | + | /** When an artifact goes, from now: "expires today", "expires in 13 days". */ | |
| 9 | + | export function expiresIn(at: string | null, now: number = Date.now()): string { | |
| 10 | + | if (!at) return ""; | |
| 11 | + | const ms = Date.parse(at) - now; | |
| 12 | + | if (Number.isNaN(ms)) return ""; | |
| 13 | + | if (ms <= 0) return "expired"; | |
| 14 | + | const days = Math.floor(ms / 86_400_000); | |
| 15 | + | if (days === 0) return "expires today"; | |
| 16 | + | if (days === 1) return "expires tomorrow"; | |
| 17 | + | return `expires in ${days} days`; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | /** | |
| 21 | + | * Artifacts older runners kept in Workers KV. None has been made there | |
| 22 | + | * since artifacts moved to R2 on 2026-10-08, and KV expires each 14 days | |
| 23 | + | * after it was made: from 2026-10-22T00:00Z every one is gone. Delete the | |
| 24 | + | * KV artifact code (here, run-artifacts.server.ts, and apps/api/src/blobs.rs) | |
| 25 | + | * after that date. | |
| 26 | + | */ | |
| 27 | + | export const LEGACY_KV_UNTIL = Date.parse("2026-10-22T00:00:00Z"); | |
| 28 | + | /** Runs made from this time on kept their artifacts in R2 only. */ | |
| 29 | + | export const LEGACY_KV_BEFORE = Date.parse("2026-10-09T00:00:00Z"); | |
| 30 | + | ||
| 31 | + | /** | |
| 32 | + | * Whether a run's page asks KV for artifacts an older runner kept there: | |
| 33 | + | * only for a finished run made before the move, and only until they have | |
| 34 | + | * all expired. A KV list is the dearest thing KV does, and a run still | |
| 35 | + | * going refreshes its page every few seconds. | |
| 36 | + | */ | |
| 37 | + | export function legacyArtifactsWorthAsking(run: { createdAt: string; status: string }, now: number = Date.now()): boolean { | |
| 38 | + | return now < LEGACY_KV_UNTIL && run.status === "completed" && Date.parse(run.createdAt) < LEGACY_KV_BEFORE; | |
| 39 | + | } |
| 50 | 50 | const PACKAGES = instrumented("packages", env.PACKAGES); | |
| 51 | 51 | const CHAT = instrumented("chat", env.CHAT); | |
| 52 | 52 | const AGENTS = instrumented("agents", env.AGENTS); | |
| 53 | − | const DOCS = instrumented("docs", env.DOCS); | |
| 53 | + | const ARTIFACTS = instrumented("artifacts", env.ARTIFACTS); | |
| 54 | 54 | ||
| 55 | 55 | export const identity = identityClient(IDENTITY); | |
| 56 | 56 | /** A person's email addresses and account security: methods of identity. */ | |
| ⋯ | |||
| 87 | 87 | export const chat = chatClient(CHAT); | |
| 88 | 88 | /** The workspace's own agents: who they are, their limits and their desks. */ | |
| 89 | 89 | export const workspaceAgents = workspaceAgentsClient(AGENTS); | |
| 90 | − | /** The docs service's spaces (Artifacts' spaces) and projects' docs. Its old pages are no longer read. */ | |
| 91 | − | export const docs = docsClient(DOCS); | |
| 92 | − | /** Artifacts (folios): docs, and later slides, designs and dashboards, kept by the docs service. */ | |
| 93 | − | export const folios = foliosClient(DOCS); | |
| 90 | + | /** The artifacts service's spaces (Artifacts' spaces) and projects' docs. Its old pages are no longer read. */ | |
| 91 | + | export const docs = docsClient(ARTIFACTS); | |
| 92 | + | /** Artifacts (folios): docs, and later slides, designs and dashboards, kept by the artifacts service. */ | |
| 93 | + | export const folios = foliosClient(ARTIFACTS); | |
| 3 | 3 | import type { Route } from "./+types/actions-artifact"; | |
| 4 | 4 | import { addresses } from "../../lib/addresses.server"; | |
| 5 | 5 | import { contentDisposition } from "../../lib/content-safety"; | |
| 6 | − | import { readArtifact } from "../../lib/artifacts.server"; | |
| 6 | + | import { readArtifact } from "../../lib/run-artifacts.server"; | |
| 7 | 7 | import { actions } from "../../lib/services.server"; | |
| 8 | 8 | import { getViewer } from "../../lib/session.server"; | |
| 9 | 9 |
| 45 | 45 | import { Hint } from "../../components/ui/hint"; | |
| 46 | 46 | import { useWorkflowReason } from "../../components/mirror"; | |
| 47 | 47 | import { searchLog } from "../../lib/log-lines"; | |
| 48 | − | import { listArtifacts, withLegacyArtifacts } from "../../lib/artifacts.server"; | |
| 49 | − | import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "../../lib/artifacts"; | |
| 48 | + | import { listArtifacts, withLegacyArtifacts } from "../../lib/run-artifacts.server"; | |
| 49 | + | import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "../../lib/run-artifacts"; | |
| 50 | 50 | import { actions } from "../../lib/services.server"; | |
| 51 | 51 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 52 | 52 | import { accessTo, refusal } from "../../lib/access.server"; |
| 137 | 137 | return folios.deleteTemplate(slug, viewer, sent.template_id ?? ""); | |
| 138 | 138 | case "mark_current": | |
| 139 | 139 | return folios.markCurrent(slug, viewer, id); | |
| 140 | − | // Spaces are the docs service's own, shared with Artifacts. | |
| 140 | + | // Spaces are the artifacts service's own, shared with Artifacts. | |
| 141 | 141 | case "create_space": | |
| 142 | 142 | return docs.createSpace(slug, viewer, sent.space ?? ({ name: "", kind: "workspace" } as NewDocSpace)); | |
| 143 | 143 | case "update_space": |
| 9 | 9 | /** | |
| 10 | 10 | * An artifact's live socket: `wss://<site>/<workspace>/-/artifacts/live?folio=<id>`. | |
| 11 | 11 | * The site checks the session and that the page asking is the site's own, | |
| 12 | − | * then hands the upgrade to the docs service with the viewer, which checks | |
| 12 | + | * then hands the upgrade to the artifacts service with the viewer, which checks | |
| 13 | 13 | * their role in the folio and gives the socket to its room (one Durable | |
| 14 | 14 | * Object per folio, any kind), which enforces that role. | |
| 15 | 15 | */ | |
| ⋯ | |||
| 30 | 30 | headers.set(DOCS_VIEWER_HEADER, JSON.stringify(viewer)); | |
| 31 | 31 | const target = `https://docs/live?folio=${encodeURIComponent(folio)}&workspace=${encodeURIComponent(params.owner.toLowerCase())}`; | |
| 32 | 32 | try { | |
| 33 | − | return await env.DOCS.fetch(new Request(target, { method: "GET", headers })); | |
| 33 | + | return await env.ARTIFACTS.fetch(new Request(target, { method: "GET", headers })); | |
| 34 | 34 | } catch (error) { | |
| 35 | 35 | console.error("artifacts: the live socket could not be handed over", error); | |
| 36 | 36 | return new Response("Artifacts didn't answer.", { status: 503 }); | |
| 9 | 9 | /** | |
| 10 | 10 | * A file put in an artifact (an image, a PDF, an attachment): | |
| 11 | 11 | * `POST <site>/<workspace>/-/artifacts/upload?folio=<id>&name=<file name>` | |
| 12 | − | * with the file as the body. The docs service checks the viewer can edit | |
| 12 | + | * with the file as the body. The artifacts service checks the viewer can edit | |
| 13 | 13 | * the folio, keeps the file, and answers with its address on the usercontent | |
| 14 | 14 | * origin, where it is served, never on the site. | |
| 15 | 15 | */ | |
| ⋯ | |||
| 27 | 27 | target.searchParams.set("folio", url.searchParams.get("folio") ?? ""); | |
| 28 | 28 | target.searchParams.set("name", url.searchParams.get("name") ?? "file"); | |
| 29 | 29 | try { | |
| 30 | − | const answer = await env.DOCS.fetch(target.toString(), { | |
| 30 | + | const answer = await env.ARTIFACTS.fetch(target.toString(), { | |
| 31 | 31 | method: "PUT", | |
| 32 | 32 | headers: { | |
| 33 | 33 | "content-type": request.headers.get("content-type") ?? "application/octet-stream", | |
| 27 | 27 | * socket, forwarded as it is (app/lib/chat-live.server.ts). | |
| 28 | 28 | */ | |
| 29 | 29 | CHAT: ServiceBinding & { fetch(request: Request): Promise<Response> }; | |
| 30 | − | /** Docs (services/docs): RPC, each page's live socket, and files in pages. */ | |
| 31 | − | DOCS: ServiceBinding & { fetch(request: Request | string, init?: RequestInit): Promise<Response> }; | |
| 30 | + | /** Artifacts (services/artifacts): RPC, each artifact's live socket, and files in them. */ | |
| 31 | + | ARTIFACTS: ServiceBinding & { fetch(request: Request | string, init?: RequestInit): Promise<Response> }; | |
| 32 | 32 | /** The workspace's own agents: definitions, templates and desks. */ | |
| 33 | 33 | AGENTS: ServiceBinding; | |
| 34 | 34 | /** |
| 12 | 12 | export const AVATAR_PATH = /^\/avatars\/([0-9a-f]{64})$/; | |
| 13 | 13 | /** A workspace's custom emoji, by the SHA-256 of its bytes: kept by chat under `emoji/<hash>` in the same namespace. */ | |
| 14 | 14 | export const EMOJI_PATH = /^\/emoji\/([0-9a-f]{64})$/; | |
| 15 | − | /** A file put in a Docs page, by its random key: kept by the docs service (services/docs). */ | |
| 15 | + | /** A file put in a Docs page, by its random key: kept by the artifacts service (services/artifacts). */ | |
| 16 | 16 | export const DOCS_FILE_PATH = /^\/docs-files\/([0-9a-f]{64})$/; | |
| 17 | 17 | /** The only types identity stores, having checked each image's bytes. */ | |
| 18 | 18 | const AVATAR_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); | |
| ⋯ | |||
| 120 | 120 | } | |
| 121 | 121 | ||
| 122 | 122 | /** | |
| 123 | − | * A file put in a Docs page. Its key is 256 random bits the docs service | |
| 123 | + | * A file put in a Docs page. Its key is 256 random bits the artifacts service | |
| 124 | 124 | * made, so the address is the permission, as a shared link is; the docs | |
| 125 | 125 | * service serves images, media and PDFs as themselves and everything else | |
| 126 | 126 | * as a download, and nothing here can run script. | |
| ⋯ | |||
| 128 | 128 | async function serveDocsFile(env: Env, method: string, key: string): Promise<Response> { | |
| 129 | 129 | let answer: Response; | |
| 130 | 130 | try { | |
| 131 | − | answer = await env.DOCS.fetch(`https://docs/files/${key}`, { method }); | |
| 131 | + | answer = await env.ARTIFACTS.fetch(`https://docs/files/${key}`, { method }); | |
| 132 | 132 | } catch { | |
| 133 | 133 | return plain(503, "Docs didn't answer"); | |
| 134 | 134 | } | |
| 47 | 47 | { "binding": "SEARCH", "service": "g1t-search" }, | |
| 48 | 48 | // Chat: channels, direct messages and the live socket (services/chat). | |
| 49 | 49 | { "binding": "CHAT", "service": "g1t-chat" }, | |
| 50 | − | // Docs: spaces, pages, each page's live socket, and files in pages (services/docs). | |
| 51 | − | { "binding": "DOCS", "service": "g1t-docs-service" }, | |
| 50 | + | // Artifacts: spaces, artifacts, each one's live socket, and files in them (services/artifacts). | |
| 51 | + | { "binding": "ARTIFACTS", "service": "g1t-artifacts" }, | |
| 52 | 52 | // The workspace's own agents: definitions, templates, desks (services/agents). | |
| 53 | 53 | { "binding": "AGENTS", "service": "g1t-agents" }, | |
| 54 | 54 | // Live notifications, counts and browser push: each tab's feed socket (services/notify). |
| 980 | 980 | pub repo_id: String, | |
| 981 | 981 | } | |
| 982 | 982 | ||
| 983 | − | /// The `doc.page.*` types the docs service (services/docs, TypeScript) | |
| 983 | + | /// The `doc.page.*` types the artifacts service (services/artifacts, TypeScript) | |
| 984 | 984 | /// publishes, with no `repoId` on the event: a page may be in a private | |
| 985 | 985 | /// space, so it never reaches a repository's timeline or webhooks. | |
| 986 | 986 | pub const DOC_PAGE_EVENTS: [&str; 4] = ["doc.page.created", "doc.page.updated", "doc.page.archived", "doc.page.stale"]; |
| 1 | 1 | //! Folios: what people call artifacts (Artifacts mode). | |
| 2 | 2 | //! One mode for docs, slides, designs and dashboards, kept by the docs | |
| 3 | − | //! service (`services/docs`, TypeScript). Code says "folio"; people see | |
| 3 | + | //! service (`services/artifacts`, TypeScript). Code says "folio"; people see | |
| 4 | 4 | //! "artifact" in the UI, URLs, the `artifact` MCP tool, REST paths and the | |
| 5 | 5 | //! `artifacts:*` scopes. The Cloudflare Artifacts git store and workflow run | |
| 6 | 6 | //! artifacts ([`crate::actions`]) are something else. | |
| ⋯ | |||
| 402 | 402 | } | |
| 403 | 403 | } | |
| 404 | 404 | ||
| 405 | − | /// The docs service method it goes to. | |
| 405 | + | /// The artifacts service method it goes to. | |
| 406 | 406 | pub fn method(&self) -> &'static str { | |
| 407 | 407 | match self { | |
| 408 | 408 | FolioAccessChange::Grant { .. } | FolioAccessChange::Revoke { .. } => "set_folio_grant", | |
| ⋯ | |||
| 451 | 451 | /// What is wrong with an agent edit's envelope (`FolioAgentEdit` in | |
| 452 | 452 | /// folios.ts), in the same words as `folioAgentEditError`: its kind, and a | |
| 453 | 453 | /// doc's target and Markdown or the other kinds' list of ops. Returns the | |
| 454 | − | /// kind when it is well formed. Each op is the docs service's to check. | |
| 454 | + | /// kind when it is well formed. Each op is the artifacts service's to check. | |
| 455 | 455 | pub fn agent_edit_error(edit: &Value) -> Result<FolioKind, String> { | |
| 456 | 456 | let Some(edit) = edit.as_object() else { return Err("An edit is an object.".to_owned()) }; | |
| 457 | 457 | let Some(kind) = edit.get("kind").and_then(Value::as_str).and_then(FolioKind::parse) else { | |
| ⋯ | |||
| 495 | 495 | Ok(kind) | |
| 496 | 496 | } | |
| 497 | 497 | ||
| 498 | − | // --- The docs service's folio RPC --------------------------------------------- | |
| 498 | + | // --- The artifacts service's folio RPC --------------------------------------------- | |
| 499 | 499 | ||
| 500 | − | /// Every method the docs service answers for folios, as `FOLIO_RPC_METHODS` | |
| 500 | + | /// Every method the artifacts service answers for folios, as `FOLIO_RPC_METHODS` | |
| 501 | 501 | /// in folios.ts. | |
| 502 | 502 | pub const FOLIO_RPC_METHODS: [&str; 48] = [ | |
| 503 | 503 | "folio_list", | |
| ⋯ | |||
| 666 | 666 | ||
| 667 | 667 | // --- Events ------------------------------------------------------------------- | |
| 668 | 668 | ||
| 669 | − | /// The `folio.*` types the docs service publishes, with no `repoId` on | |
| 669 | + | /// The `folio.*` types the artifacts service publishes, with no `repoId` on | |
| 670 | 670 | /// the event: a folio may be private, so it never reaches a repository's | |
| 671 | 671 | /// timeline or webhooks. Nothing subscribes to them yet. | |
| 672 | 672 | pub const FOLIO_EVENTS: [&str; 6] = ["folio.created", "folio.updated", "folio.trashed", "folio.restored", "folio.shared", "folio.stale"]; | |
| 1230 | 1230 | // Artifacts mode's docs, slides, designs and dashboards (the `artifact` | |
| 1231 | 1231 | // MCP tool). Reading takes artifacts:read, making and changing them | |
| 1232 | 1232 | // artifacts:write, and sharing them or deleting them for good | |
| 1233 | − | // artifacts:admin. The docs service then checks the person's own role | |
| 1233 | + | // artifacts:admin. The artifacts service then checks the person's own role | |
| 1234 | 1234 | // on each one. | |
| 1235 | 1235 | ("list_workspace_artifacts", Scope::ArtifactsRead), | |
| 1236 | 1236 | ("search_workspace_artifacts", Scope::ArtifactsRead), |
| 6 | 6 | // Cloudflare-only things live: | |
| 7 | 7 | // | |
| 8 | 8 | // - account, routes, placement, observability and builds are dropped; | |
| 9 | − | // - ARTIFACTS (git storage) becomes a service binding to workers/artifacts, | |
| 9 | + | // - GITSTORE (git storage, Cloudflare Artifacts) becomes a service binding to workers/gitstore, | |
| 10 | 10 | // which keeps repositories in the git store (gitstore/server.mjs); | |
| 11 | 11 | // - EMAIL (Email Sending) becomes a service binding to workers/mail; | |
| 12 | 12 | // - the packages service keeps files in S3-compatible storage (RustFS) | |
| 13 | 13 | // instead of R2, the repos service its nightly backups (a bucket of | |
| 14 | − | // their own, BACKUP_S3_BUCKET), and the docs service the files in pages | |
| 14 | + | // their own, BACKUP_S3_BUCKET), and the artifacts service the files in pages | |
| 15 | 15 | // (DOCS_S3_BUCKET); | |
| 16 | 16 | // - services that are off in this phase (agents, the context hub, the | |
| 17 | 17 | // g1t.page dispatcher, model proxy) are bound to workers/off instead, and | |
| ⋯ | |||
| 98 | 98 | ||
| 99 | 99 | /** | |
| 100 | 100 | * Services whose cron triggers scheduler.mjs runs here: sweeps and | |
| 101 | − | * reminders that need nothing self-hosting lacks (the docs service's is | |
| 101 | + | * reminders that need nothing self-hosting lacks (the artifacts service's is | |
| 102 | 102 | * emptying artifacts' trash after 30 days). Not run: actions (its | |
| 103 | 103 | * minute would start scheduled workflows with no runner to take them), | |
| 104 | 104 | * billing (reconciles against Cloudflare and Stripe), deployments (calls | |
| 105 | 105 | * Cloudflare's API) and the services that are off. | |
| 106 | 106 | */ | |
| 107 | − | const SELF_HOST_CRONS = new Set(["g1t-repos", "g1t-events", "g1t-identity", "g1t-security", "g1t-webhooks", "g1t-packages", "g1t-docs-service"]); | |
| 107 | + | const SELF_HOST_CRONS = new Set(["g1t-repos", "g1t-events", "g1t-identity", "g1t-security", "g1t-webhooks", "g1t-packages", "g1t-artifacts"]); | |
| 108 | 108 | ||
| 109 | 109 | /** Queues whose consumers are off: events stops sending to them. */ | |
| 110 | 110 | const OFF_QUEUES = new Set(["g1t-events-runner", "g1t-events-context"]); | |
| ⋯ | |||
| 189 | 189 | ||
| 190 | 190 | // Cloudflare-only bindings, and what stands in for them. | |
| 191 | 191 | if (hosted.artifacts) { | |
| 192 | − | for (const artifacts of hosted.artifacts) { | |
| 193 | − | config.services.push({ binding: artifacts.binding, service: "g1t-artifacts" }); | |
| 192 | + | for (const store of hosted.artifacts) { | |
| 193 | + | config.services.push({ binding: store.binding, service: "g1t-gitstore" }); | |
| 194 | 194 | } | |
| 195 | 195 | } | |
| 196 | 196 | if (hosted.send_email) { | |
| ⋯ | |||
| 268 | 268 | }); | |
| 269 | 269 | } | |
| 270 | 270 | // Files people put in Docs pages go to a bucket of their own on the same | |
| 271 | − | // S3-compatible store, instead of the FILES R2 bucket (services/docs | |
| 271 | + | // S3-compatible store, instead of the FILES R2 bucket (services/artifacts | |
| 272 | 272 | // src/files.ts, `s3FileStore`). | |
| 273 | − | if (hosted.name === "g1t-docs-service") { | |
| 273 | + | if (hosted.name === "g1t-artifacts") { | |
| 274 | 274 | Object.assign(config.vars, { | |
| 275 | 275 | DOCS_FILES: "s3", | |
| 276 | 276 | DOCS_S3_ENDPOINT: process.env.S3_ENDPOINT ?? "http://rustfs:9000", | |
| ⋯ | |||
| 301 | 301 | ||
| 302 | 302 | const compatibility_date = "2026-09-26"; | |
| 303 | 303 | files.push( | |
| 304 | − | write("g1t-artifacts", { | |
| 305 | − | name: "g1t-artifacts", | |
| 306 | − | main: rel("deploy/self-host/workers/artifacts/index.js"), | |
| 304 | + | write("g1t-gitstore", { | |
| 305 | + | name: "g1t-gitstore", | |
| 306 | + | main: rel("deploy/self-host/workers/gitstore/index.js"), | |
| 307 | 307 | compatibility_date, | |
| 308 | 308 | vars: { | |
| 309 | 309 | GITSTORE_URL: process.env.GITSTORE_URL ?? "http://gitstore:8080", | |
| 6 | 6 | // fetches and pushes, and a small JSON API for the reads the repos service | |
| 7 | 7 | // makes (commits, trees, blobs, files) and for creating and forking. | |
| 8 | 8 | // | |
| 9 | − | // It is reached only by the Artifacts-compatible shim (workers/artifacts), | |
| 9 | + | // It is reached only by the Artifacts-compatible shim (workers/gitstore, the repos service's GITSTORE binding), | |
| 10 | 10 | // which the repos service is bound to in place of the Artifacts binding, and | |
| 11 | 11 | // by the repos service itself for git's smart HTTP. Nothing else should be | |
| 12 | 12 | // able to reach it: the API takes a shared secret, and git requests a |
| 1 | − | // The Artifacts binding, for self-hosted g1t. | |
| 2 | − | // | |
| 3 | − | // The repos service is written against Cloudflare Artifacts' Workers | |
| 4 | − | // binding (services/repos/src/store.rs). Self-hosted, its ARTIFACTS binding | |
| 5 | − | // is a service binding to this Worker instead, which offers the same | |
| 6 | − | // methods and keeps the repositories in the git store (gitstore/server.mjs): | |
| 7 | − | // plain bare repositories on disk. Hosted g1t never runs this. | |
| 8 | − | // | |
| 9 | − | // Only what g1t calls is implemented: create, get and delete on the namespace; | |
| 10 | − | // info, createToken, log, readCommit, readTree, readBlob, readFile and fork | |
| 11 | − | // on a repository. | |
| 12 | − | ||
| 13 | − | import { RpcTarget, WorkerEntrypoint } from "cloudflare:workers"; | |
| 14 | − | ||
| 15 | − | class ArtifactsError extends Error { | |
| 16 | − | constructor(code, message) { | |
| 17 | − | super(message); | |
| 18 | − | this.name = "ArtifactsError"; | |
| 19 | − | this.code = code; | |
| 20 | − | } | |
| 21 | − | } | |
| 22 | − | ||
| 23 | − | async function store(env, path, init = {}) { | |
| 24 | − | const base = (env.GITSTORE_URL ?? "http://gitstore:8080").replace(/\/$/, ""); | |
| 25 | − | const response = await fetch(`${base}/api/repos${path}`, { | |
| 26 | − | ...init, | |
| 27 | − | headers: { | |
| 28 | − | "x-gitstore-secret": env.GITSTORE_SECRET ?? "", | |
| 29 | − | ...(init.body ? { "content-type": "application/json" } : {}), | |
| 30 | − | }, | |
| 31 | − | }); | |
| 32 | − | return response; | |
| 33 | − | } | |
| 34 | − | ||
| 35 | − | async function json(response) { | |
| 36 | − | if (response.ok) return response.json(); | |
| 37 | − | let code = "INTERNAL_ERROR"; | |
| 38 | − | let message = `git store answered ${response.status}`; | |
| 39 | − | try { | |
| 40 | − | const body = await response.json(); | |
| 41 | − | code = body.code ?? code; | |
| 42 | − | message = body.message ?? message; | |
| 43 | − | } catch {} | |
| 44 | − | throw new ArtifactsError(code, message); | |
| 45 | − | } | |
| 46 | − | ||
| 47 | − | /** Bytes as something with `arrayBuffer()`, the way a Blob is read. */ | |
| 48 | − | async function bytes(response) { | |
| 49 | − | if (response.status === 404) return null; | |
| 50 | − | if (!response.ok) await json(response); | |
| 51 | − | return new Response(await response.arrayBuffer(), { | |
| 52 | − | headers: { "content-type": response.headers.get("content-type") ?? "application/octet-stream" }, | |
| 53 | − | }); | |
| 54 | − | } | |
| 55 | − | ||
| 56 | − | class Repo extends RpcTarget { | |
| 57 | − | #env; | |
| 58 | − | #name; | |
| 59 | − | ||
| 60 | − | constructor(env, name) { | |
| 61 | − | super(); | |
| 62 | − | this.#env = env; | |
| 63 | − | this.#name = name; | |
| 64 | − | } | |
| 65 | − | ||
| 66 | − | #path(rest = "") { | |
| 67 | − | return `/${encodeURIComponent(this.#name)}${rest}`; | |
| 68 | − | } | |
| 69 | − | ||
| 70 | − | async info() { | |
| 71 | − | return json(await store(this.#env, this.#path())); | |
| 72 | − | } | |
| 73 | − | ||
| 74 | − | async createToken(scope = "write", ttl = 86400) { | |
| 75 | − | return json( | |
| 76 | − | await store(this.#env, this.#path("/tokens"), { | |
| 77 | − | method: "POST", | |
| 78 | − | body: JSON.stringify({ scope, ttl }), | |
| 79 | − | }), | |
| 80 | − | ); | |
| 81 | − | } | |
| 82 | − | ||
| 83 | − | async log(opts = {}) { | |
| 84 | − | const query = new URLSearchParams(); | |
| 85 | − | for (const [key, value] of Object.entries(opts ?? {})) { | |
| 86 | − | if (value !== undefined && value !== null) query.set(key, String(value)); | |
| 87 | − | } | |
| 88 | − | return json(await store(this.#env, this.#path(`/log?${query}`))); | |
| 89 | − | } | |
| 90 | − | ||
| 91 | − | async readCommit(hash) { | |
| 92 | − | return json(await store(this.#env, this.#path(`/commits/${encodeURIComponent(hash)}`))); | |
| 93 | − | } | |
| 94 | − | ||
| 95 | − | async readTree(hash) { | |
| 96 | − | return json(await store(this.#env, this.#path(`/trees/${encodeURIComponent(hash)}`))); | |
| 97 | − | } | |
| 98 | − | ||
| 99 | − | async readBlob(hash) { | |
| 100 | − | return bytes(await store(this.#env, this.#path(`/blobs/${encodeURIComponent(hash)}`))); | |
| 101 | − | } | |
| 102 | − | ||
| 103 | − | async readFile({ ref, path }) { | |
| 104 | − | const query = new URLSearchParams({ ref, path }); | |
| 105 | − | return bytes(await store(this.#env, this.#path(`/file?${query}`))); | |
| 106 | − | } | |
| 107 | − | ||
| 108 | − | async fork(name, opts = {}) { | |
| 109 | − | const created = await json( | |
| 110 | − | await store(this.#env, this.#path("/fork"), { | |
| 111 | − | method: "POST", | |
| 112 | − | body: JSON.stringify({ ...opts, name }), | |
| 113 | − | }), | |
| 114 | − | ); | |
| 115 | − | const token = await new Repo(this.#env, name).createToken("write"); | |
| 116 | − | return { ...created, token: token.plaintext }; | |
| 117 | − | } | |
| 118 | − | } | |
| 119 | − | ||
| 120 | − | export default class Artifacts extends WorkerEntrypoint { | |
| 121 | − | async create(name, opts = {}) { | |
| 122 | − | const created = await json( | |
| 123 | − | await store(this.env, "", { | |
| 124 | − | method: "POST", | |
| 125 | − | body: JSON.stringify({ | |
| 126 | − | name, | |
| 127 | − | description: opts?.description, | |
| 128 | − | defaultBranch: opts?.setDefaultBranch, | |
| 129 | − | readOnly: opts?.readOnly, | |
| 130 | − | }), | |
| 131 | − | }), | |
| 132 | − | ); | |
| 133 | − | const token = await new Repo(this.env, name).createToken("write"); | |
| 134 | − | return { ...created, token: token.plaintext }; | |
| 135 | − | } | |
| 136 | − | ||
| 137 | − | async get(name) { | |
| 138 | − | // Artifacts answers NOT_FOUND here for a repository that does not exist. | |
| 139 | − | await json(await store(this.env, `/${encodeURIComponent(name)}`)); | |
| 140 | − | return new Repo(this.env, name); | |
| 141 | − | } | |
| 142 | − | ||
| 143 | − | async delete(name) { | |
| 144 | − | const response = await store(this.env, `/${encodeURIComponent(name)}`, { method: "DELETE" }); | |
| 145 | − | if (response.status === 404) return false; | |
| 146 | − | await json(response); | |
| 147 | − | return true; | |
| 148 | − | } | |
| 149 | − | ||
| 150 | − | async fetch() { | |
| 151 | − | return new Response("The Artifacts binding for self-hosted g1t. Bind to it; do not browse it.", { | |
| 152 | − | status: 404, | |
| 153 | − | }); | |
| 154 | − | } | |
| 155 | − | } |
| 1 | + | // The git store binding (GITSTORE), for self-hosted g1t. | |
| 2 | + | // | |
| 3 | + | // The repos service is written against Cloudflare Artifacts' Workers | |
| 4 | + | // binding (services/repos/src/store.rs). Self-hosted, its GITSTORE binding | |
| 5 | + | // is a service binding to this Worker instead, which offers the same | |
| 6 | + | // methods and keeps the repositories in the git store (gitstore/server.mjs): | |
| 7 | + | // plain bare repositories on disk. Hosted g1t never runs this. | |
| 8 | + | // | |
| 9 | + | // Only what g1t calls is implemented: create, get and delete on the namespace; | |
| 10 | + | // info, createToken, log, readCommit, readTree, readBlob, readFile and fork | |
| 11 | + | // on a repository. | |
| 12 | + | ||
| 13 | + | import { RpcTarget, WorkerEntrypoint } from "cloudflare:workers"; | |
| 14 | + | ||
| 15 | + | class ArtifactsError extends Error { | |
| 16 | + | constructor(code, message) { | |
| 17 | + | super(message); | |
| 18 | + | this.name = "ArtifactsError"; | |
| 19 | + | this.code = code; | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | async function store(env, path, init = {}) { | |
| 24 | + | const base = (env.GITSTORE_URL ?? "http://gitstore:8080").replace(/\/$/, ""); | |
| 25 | + | const response = await fetch(`${base}/api/repos${path}`, { | |
| 26 | + | ...init, | |
| 27 | + | headers: { | |
| 28 | + | "x-gitstore-secret": env.GITSTORE_SECRET ?? "", | |
| 29 | + | ...(init.body ? { "content-type": "application/json" } : {}), | |
| 30 | + | }, | |
| 31 | + | }); | |
| 32 | + | return response; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | async function json(response) { | |
| 36 | + | if (response.ok) return response.json(); | |
| 37 | + | let code = "INTERNAL_ERROR"; | |
| 38 | + | let message = `git store answered ${response.status}`; | |
| 39 | + | try { | |
| 40 | + | const body = await response.json(); | |
| 41 | + | code = body.code ?? code; | |
| 42 | + | message = body.message ?? message; | |
| 43 | + | } catch {} | |
| 44 | + | throw new ArtifactsError(code, message); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /** Bytes as something with `arrayBuffer()`, the way a Blob is read. */ | |
| 48 | + | async function bytes(response) { | |
| 49 | + | if (response.status === 404) return null; | |
| 50 | + | if (!response.ok) await json(response); | |
| 51 | + | return new Response(await response.arrayBuffer(), { | |
| 52 | + | headers: { "content-type": response.headers.get("content-type") ?? "application/octet-stream" }, | |
| 53 | + | }); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | class Repo extends RpcTarget { | |
| 57 | + | #env; | |
| 58 | + | #name; | |
| 59 | + | ||
| 60 | + | constructor(env, name) { | |
| 61 | + | super(); | |
| 62 | + | this.#env = env; | |
| 63 | + | this.#name = name; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | #path(rest = "") { | |
| 67 | + | return `/${encodeURIComponent(this.#name)}${rest}`; | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | async info() { | |
| 71 | + | return json(await store(this.#env, this.#path())); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | async createToken(scope = "write", ttl = 86400) { | |
| 75 | + | return json( | |
| 76 | + | await store(this.#env, this.#path("/tokens"), { | |
| 77 | + | method: "POST", | |
| 78 | + | body: JSON.stringify({ scope, ttl }), | |
| 79 | + | }), | |
| 80 | + | ); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | async log(opts = {}) { | |
| 84 | + | const query = new URLSearchParams(); | |
| 85 | + | for (const [key, value] of Object.entries(opts ?? {})) { | |
| 86 | + | if (value !== undefined && value !== null) query.set(key, String(value)); | |
| 87 | + | } | |
| 88 | + | return json(await store(this.#env, this.#path(`/log?${query}`))); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | async readCommit(hash) { | |
| 92 | + | return json(await store(this.#env, this.#path(`/commits/${encodeURIComponent(hash)}`))); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | async readTree(hash) { | |
| 96 | + | return json(await store(this.#env, this.#path(`/trees/${encodeURIComponent(hash)}`))); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | async readBlob(hash) { | |
| 100 | + | return bytes(await store(this.#env, this.#path(`/blobs/${encodeURIComponent(hash)}`))); | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | async readFile({ ref, path }) { | |
| 104 | + | const query = new URLSearchParams({ ref, path }); | |
| 105 | + | return bytes(await store(this.#env, this.#path(`/file?${query}`))); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | async fork(name, opts = {}) { | |
| 109 | + | const created = await json( | |
| 110 | + | await store(this.#env, this.#path("/fork"), { | |
| 111 | + | method: "POST", | |
| 112 | + | body: JSON.stringify({ ...opts, name }), | |
| 113 | + | }), | |
| 114 | + | ); | |
| 115 | + | const token = await new Repo(this.#env, name).createToken("write"); | |
| 116 | + | return { ...created, token: token.plaintext }; | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | export default class GitStore extends WorkerEntrypoint { | |
| 121 | + | async create(name, opts = {}) { | |
| 122 | + | const created = await json( | |
| 123 | + | await store(this.env, "", { | |
| 124 | + | method: "POST", | |
| 125 | + | body: JSON.stringify({ | |
| 126 | + | name, | |
| 127 | + | description: opts?.description, | |
| 128 | + | defaultBranch: opts?.setDefaultBranch, | |
| 129 | + | readOnly: opts?.readOnly, | |
| 130 | + | }), | |
| 131 | + | }), | |
| 132 | + | ); | |
| 133 | + | const token = await new Repo(this.env, name).createToken("write"); | |
| 134 | + | return { ...created, token: token.plaintext }; | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | async get(name) { | |
| 138 | + | // Artifacts answers NOT_FOUND here for a repository that does not exist. | |
| 139 | + | await json(await store(this.env, `/${encodeURIComponent(name)}`)); | |
| 140 | + | return new Repo(this.env, name); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | async delete(name) { | |
| 144 | + | const response = await store(this.env, `/${encodeURIComponent(name)}`, { method: "DELETE" }); | |
| 145 | + | if (response.status === 404) return false; | |
| 146 | + | await json(response); | |
| 147 | + | return true; | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | async fetch() { | |
| 151 | + | return new Response("The git store binding for self-hosted g1t. Bind to it; do not browse it.", { | |
| 152 | + | status: 404, | |
| 153 | + | }); | |
| 154 | + | } | |
| 155 | + | } |
| 128 | 128 | ], | |
| 129 | 129 | "self_host": "run" | |
| 130 | 130 | }, | |
| 131 | − | // Docs mode's service, which also hosts artifacts (folios). Its | |
| 132 | − | // Worker is g1t-docs-service: g1t-docs is the documentation site | |
| 133 | − | // (apps/docs). | |
| 134 | − | "docs-service": { | |
| 135 | − | "path": "services/docs", | |
| 131 | + | // Artifacts: docs (a doc is one kind of artifact), their spaces, | |
| 132 | + | // sharing and live rooms. It was g1t-docs-service (services/docs) | |
| 133 | + | // until 2026-10; its resources kept their g1t-docs names. g1t-docs | |
| 134 | + | // is the documentation site (apps/docs). | |
| 135 | + | "artifacts": { | |
| 136 | + | "path": "services/artifacts", | |
| 136 | 137 | "kind": "ts-worker", | |
| 137 | − | "worker": "g1t-docs-service", | |
| 138 | + | "worker": "g1t-artifacts", | |
| 138 | 139 | "d1": { "database": "g1t-docs", "migrations": "migrations" }, | |
| 139 | 140 | "stage": "core", | |
| 140 | 141 | "secrets": [], | |
| 141 | 142 | "setup": [ | |
| 142 | − | "The D1 database, before the first deploy: npx wrangler d1 create g1t-docs, then put its id in services/docs/wrangler.jsonc", | |
| 143 | + | "An installation that ran g1t-docs-service: move to g1t-artifacts as \"Renaming a Worker\" in docs.g1t.sh/guides/deploy-to-cloudflare/ says (its queue consumer first, then this Worker, which takes the old Worker's live rooms)", | |
| 144 | + | "The D1 database, before the first deploy: npx wrangler d1 create g1t-docs, then put its id in services/artifacts/wrangler.jsonc", | |
| 143 | 145 | "The R2 bucket for files in pages: npx wrangler r2 bucket create g1t-docs-files", | |
| 144 | 146 | "The queue the events service sends it merges and pushes on (pages whose cited code changed, projects' docs): npx wrangler queues create g1t-events-docs. The service also sends its own backfill jobs to it (JOBS)", | |
| 145 | 147 | "The Vectorize index agents recall Docs from: npx wrangler vectorize create g1t-docs --dimensions=768 --metric=cosine, with string metadata indexes on workspace_id and space_id (npx wrangler vectorize create-metadata-index g1t-docs --property-name=<name> --type=string)", |
| 1964 | 1964 | "resolved": "services/agents", | |
| 1965 | 1965 | "link": true | |
| 1966 | 1966 | }, | |
| 1967 | + | "node_modules/@g1t/artifacts": { | |
| 1968 | + | "resolved": "services/artifacts", | |
| 1969 | + | "link": true | |
| 1970 | + | }, | |
| 1967 | 1971 | "node_modules/@g1t/chat": { | |
| 1968 | 1972 | "resolved": "services/chat", | |
| 1969 | 1973 | "link": true | |
| ⋯ | |||
| 1982 | 1986 | }, | |
| 1983 | 1987 | "node_modules/@g1t/docs": { | |
| 1984 | 1988 | "resolved": "apps/docs", | |
| 1985 | − | "link": true | |
| 1986 | − | }, | |
| 1987 | − | "node_modules/@g1t/docs-service": { | |
| 1988 | − | "resolved": "services/docs", | |
| 1989 | 1989 | "link": true | |
| 1990 | 1990 | }, | |
| 1991 | 1991 | "node_modules/@g1t/models": { | |
| ⋯ | |||
| 13507 | 13507 | "@g1t/contracts": "*" | |
| 13508 | 13508 | } | |
| 13509 | 13509 | }, | |
| 13510 | + | "services/artifacts": { | |
| 13511 | + | "name": "@g1t/artifacts", | |
| 13512 | + | "version": "0.1.0", | |
| 13513 | + | "license": "MIT", | |
| 13514 | + | "dependencies": { | |
| 13515 | + | "@g1t/contracts": "*", | |
| 13516 | + | "lib0": "^0.2.117", | |
| 13517 | + | "y-protocols": "^1.0.7", | |
| 13518 | + | "yjs": "^13.6.33" | |
| 13519 | + | } | |
| 13520 | + | }, | |
| 13510 | 13521 | "services/chat": { | |
| 13511 | 13522 | "name": "@g1t/chat", | |
| 13512 | 13523 | "version": "0.1.0", | |
| ⋯ | |||
| 13530 | 13541 | "license": "MIT", | |
| 13531 | 13542 | "dependencies": { | |
| 13532 | 13543 | "@g1t/contracts": "*" | |
| 13533 | − | } | |
| 13534 | − | }, | |
| 13535 | − | "services/docs": { | |
| 13536 | − | "name": "@g1t/docs-service", | |
| 13537 | − | "version": "0.1.0", | |
| 13538 | − | "license": "MIT", | |
| 13539 | − | "dependencies": { | |
| 13540 | − | "@g1t/contracts": "*", | |
| 13541 | − | "lib0": "^0.2.117", | |
| 13542 | − | "y-protocols": "^1.0.7", | |
| 13543 | − | "yjs": "^13.6.33" | |
| 13544 | 13544 | } | |
| 13545 | 13545 | }, | |
| 13546 | 13546 | "services/models": { | |
| 1 | 1 | /** | |
| 2 | − | * Docs: the workspace's written knowledge, kept by the docs service | |
| 3 | − | * (`services/docs`). Spaces hold trees of pages; each page is a CRDT | |
| 2 | + | * Docs: the workspace's written knowledge, kept by the artifacts service | |
| 3 | + | * (`services/artifacts`). Spaces hold trees of pages; each page is a CRDT | |
| 4 | 4 | * document (Yjs) edited live over a socket, saved with a Markdown | |
| 5 | 5 | * rendition that search, agents, export and the read view use. | |
| 6 | 6 | * | |
| ⋯ | |||
| 544 | 544 | ||
| 545 | 545 | /** | |
| 546 | 546 | * Header the site sets on a forwarded live socket and on uploads: the | |
| 547 | − | * viewer, as JSON. Trusted only because the docs service is reachable | |
| 547 | + | * viewer, as JSON. Trusted only because the artifacts service is reachable | |
| 548 | 548 | * through service bindings alone. | |
| 549 | 549 | */ | |
| 550 | 550 | export const DOCS_VIEWER_HEADER = "x-g1t-docs-viewer"; | |
| 477 | 477 | metrics: Record<string, unknown> | null; | |
| 478 | 478 | }; | |
| 479 | 479 | /** | |
| 480 | − | * Docs (services/docs): a page was made. Published with no `repoId`, so | |
| 480 | + | * Docs (services/artifacts): a page was made. Published with no `repoId`, so | |
| 481 | 481 | * a page (which may be in a private space) never reaches a repository's | |
| 482 | 482 | * timeline or webhooks; `actor` is the user's or agent's id. Readers | |
| 483 | − | * check access with the docs service before showing anything of it. | |
| 483 | + | * check access with the artifacts service before showing anything of it. | |
| 484 | 484 | */ | |
| 485 | 485 | "doc.page.created": DocPageEventData; | |
| 486 | 486 | /** | |
| ⋯ | |||
| 501 | 501 | */ | |
| 502 | 502 | "doc.page.stale": DocPageEventData & { repoId: string; repo: string; commit: string; pull: number | null; paths: string[]; owners: string[] }; | |
| 503 | 503 | /** | |
| 504 | − | * Artifacts (folios, services/docs): a folio was made. Like `doc.page.*`, | |
| 504 | + | * Artifacts (folios, services/artifacts): a folio was made. Like `doc.page.*`, | |
| 505 | 505 | * published with no `repoId`, never offered to webhooks, and readers check | |
| 506 | − | * access with the docs service before showing anything of it. | |
| 506 | + | * access with the artifacts service before showing anything of it. | |
| 507 | 507 | */ | |
| 508 | 508 | "folio.created": FolioEventData; | |
| 509 | 509 | /** A folio's content changed: a version (`versionKind`) with everyone whose changes are in it. */ | |
| 67 | 67 | assert.equal(folioListQueryError({ tab: "all", kinds: ["sheet" as "doc"] }), "There is no kind of artifact called sheet."); | |
| 68 | 68 | }); | |
| 69 | 69 | ||
| 70 | − | test("the client calls exactly the docs service's folio methods", async () => { | |
| 70 | + | test("the client calls exactly the artifacts service's folio methods", async () => { | |
| 71 | 71 | const called: string[] = []; | |
| 72 | 72 | const bodies: Record<string, unknown>[] = []; | |
| 73 | 73 | const binding: ServiceBinding = { |
| 2 | 2 | * Folios: what people call artifacts. Artifacts mode is one mode for docs, | |
| 3 | 3 | * slides, designs and dashboards, each private, shared with people and | |
| 4 | 4 | * agents, in a space or open to the workspace, and edited live together. | |
| 5 | − | * Kept by the docs service (`services/docs`). | |
| 5 | + | * Kept by the artifacts service (`services/artifacts`). | |
| 6 | 6 | * | |
| 7 | 7 | * Naming: code says "folio", people see "artifact" (UI text, URLs | |
| 8 | 8 | * `/<ws>/-/artifacts/...`, the `artifact` MCP tool, REST paths, the | |
| ⋯ | |||
| 605 | 605 | return null; | |
| 606 | 606 | } | |
| 607 | 607 | ||
| 608 | − | // ── The docs service's folio RPC ────────────────────────────────────── | |
| 608 | + | // ── The artifacts service's folio RPC ────────────────────────────────────── | |
| 609 | 609 | ||
| 610 | − | /** Every method the docs service answers for folios at `/rpc/<method>` (plan section 7). */ | |
| 610 | + | /** Every method the artifacts service answers for folios at `/rpc/<method>` (plan section 7). */ | |
| 611 | 611 | export const FOLIO_RPC_METHODS = [ | |
| 612 | 612 | // Lists and navigation. | |
| 613 | 613 | "folio_list", | |
| 600 | 600 | // checked by the model proxy at models.g1t.sh. | |
| 601 | 601 | ["list_gateway_requests", "models:read"], | |
| 602 | 602 | // Artifacts mode's docs, slides, designs and dashboards (the `artifact` | |
| 603 | − | // MCP tool). The docs service then checks the person's role on each. | |
| 603 | + | // MCP tool). The artifacts service then checks the person's role on each. | |
| 604 | 604 | ["list_workspace_artifacts", "artifacts:read"], | |
| 605 | 605 | ["search_workspace_artifacts", "artifacts:read"], | |
| 606 | 606 | ["get_workspace_artifact", "artifacts:read"], |
| 61 | 61 | } from "./deploy/image.mjs"; | |
| 62 | 62 | import { decide, git, planJson, pool, table } from "./deploy/plan.mjs"; | |
| 63 | 63 | import { reportDeployment } from "./deploy/report.mjs"; | |
| 64 | − | import { ROOT, byStage, codeStages, findWranglerConfigs, npmCiArgs, npmWorkspace, pick, problems, resolvedStack } from "./deploy/stack.mjs"; | |
| 64 | + | import { ROOT, byStage, codeStages, findWranglerConfigs, npmCiArgs, npmWorkspace, pick, problems, resolvedStack, waves } from "./deploy/stack.mjs"; | |
| 65 | 65 | import { withDeployWindow } from "./deploy/status-window.mjs"; | |
| 66 | 66 | ||
| 67 | 67 | const USAGE = "usage: node scripts/deploy.mjs plan|deploy|build|migrate|manifest|doctor|install|build-base|image [--all] [--only a,b] [--skip a,b] [--force] [--rollback] [--concurrency N] [--stage S] [--json]"; | |
| ⋯ | |||
| 429 | 429 | for (const unit of units) results.push({ unit: unit.id, stage, ok: false, skipped: true, ms: 0, note: "an earlier stage failed" }); | |
| 430 | 430 | continue; | |
| 431 | 431 | } | |
| 432 | − | log(`== ${stage}: ${units.map((u) => u.id).join(", ")}`); | |
| 433 | − | const shipped = await pool(units, opts.concurrency, (unit) => ship(unit, deploying.find((d) => d.unit === unit), context)); | |
| 434 | − | results.push(...shipped); | |
| 435 | − | failed = shipped.some((r) => !r.ok); | |
| 432 | + | // A Worker that does not exist yet goes before those bound to it. | |
| 433 | + | for (const wave of waves(units, (unit) => Boolean(deploying.find((d) => d.unit === unit)?.missing))) { | |
| 434 | + | if (failed) { | |
| 435 | + | for (const unit of wave) results.push({ unit: unit.id, stage, ok: false, skipped: true, ms: 0, note: "a Worker it binds to failed to deploy" }); | |
| 436 | + | continue; | |
| 437 | + | } | |
| 438 | + | log(`== ${stage}: ${wave.map((u) => u.id).join(", ")}`); | |
| 439 | + | const shipped = await pool(wave, opts.concurrency, (unit) => ship(unit, deploying.find((d) => d.unit === unit), context)); | |
| 440 | + | results.push(...shipped); | |
| 441 | + | failed = shipped.some((r) => !r.ok); | |
| 442 | + | } | |
| 436 | 443 | } | |
| 437 | 444 | }, | |
| 438 | 445 | { id: head ?? null, dryRun, log }, | |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.