Skip to content

Commit

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.

syntaqxcommitted Parent5cf931aBrowse files
212 files+785−7250/212 viewed
+1−1
113113 at least every 30 minutes and SEV2 (a core part broken for many) at least
114114 hourly; both email subscribers and need a blameless postmortem within
115115 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
117117 failing namespace. After 15 minutes, serve it read-only from the fallback
118118 git store (`scripts/ops/restore-to-gitstore.mjs restore`, then the repos
119119 Worker's secret `GIT_FALLBACK_NAMESPACES`) and open an incident. To
+1−1
130130 | `services/context` | The context hub: catalog, search and scorecards. | TypeScript |
131131 | `services/chat` | Channels, direct messages, threads and their live sockets. | TypeScript |
132132 | `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 |
134134 | `services/notify` | Notifications: each person's feed, live counts and browser push. | TypeScript |
135135 | `services/packages` | The package registries and container images. | Rust |
136136 | `services/og` | Social cards at `og.g1t.sh`: a PNG per page, showing only what anyone may see. | TypeScript |
+0−300
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
44 //!
55 //! Artifacts are kept in R2 (ACTIONS_CACHE, under `a/`), uploaded in
66 //! 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
88 //! runners kept in Workers KV are still listed and found there until KV
99 //! expires them. Cache entries are kept in R2 too, up to 2 GB each,
1010 //! uploaded in parts; the actions service decides what is found, what
2626 //! - `DELETE .../cache/uploads/{id}?upload=`: gives the upload up
2727 //! - `PUT .../cache?key=`: a whole entry of at most 60 MB at once (older runners)
2828 //!
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
3030 //! name at `/repos/{owner}/{repo}/actions/runs/{run}/artifacts/{name}`.
3131
3232 use serde::{Deserialize, Serialize};
6060 /// and none has been made there since artifacts moved to R2 on 2026-10-08:
6161 /// from 2026-10-22T00:00Z every one is gone, and KV is not asked (a list is
6262 /// 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.
6464 const LEGACY_KV_UNTIL_MS: u64 = 1_792_627_200_000;
6565
6666 /// Whether artifacts kept in KV may still be there at `now`.
624624 if let Outcome::Ok(found) = found
625625 && !found.blob.is_empty()
626626 {
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);
628628 }
629629 // Kept in KV by an older runner.
630630 if !legacy_kv(g1t_kit::now_ms()) {
+7−7
22 //! dashboards (Artifacts mode), at
33 //! `/workspaces/{workspace}/artifacts` and as the `artifact` MCP tool.
44 //! 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).
66 //!
7−//! The docs service (`services/docs`, the `DOCS` binding) decides who may
7+//! The artifacts service (`services/artifacts`, the `ARTIFACTS` binding) decides who may
88 //! do what with each one, from the person's own role on it: this checks
99 //! the input, names people for the service (a username becomes
1010 //! `user:<id>`), and gives each answer its public shape, in snake_case.
306306 }
307307 }
308308
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
310310 /// kind, an object passes as given. Left out, append.
311311 fn edit_target(input: &Value) -> Value {
312312 match &input["target"] {
403403
404404 // --- Answers, as the API shows them -------------------------------------------
405405
406−/// A person, agent or team, from a docs service profile.
406+/// A person, agent or team, from an artifacts service profile.
407407 pub(crate) fn person_json(profile: &Value) -> Value {
408408 if !profile.is_object() {
409409 return Value::Null;
609609 // --- Running ------------------------------------------------------------------
610610
611611 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
613613 }
614614
615615 /// The person acting, or the refusal: nobody, or a workspace's own token.
649649 })
650650 }
651651
652−/// Who a share is for, as the docs service names them.
652+/// Who a share is for, as the artifacts service names them.
653653 async fn principal(services: &Services, input: &Value) -> Result<std::result::Result<Option<String>, Outcome<Value>>> {
654654 if let Some(principal) = text(input, "principal") {
655655 return Ok(Ok(Some(principal)));
943943 assert_eq!(edit["target"]["heading"], "Risks");
944944 assert_eq!(agent_edit(&json!({ "markdown": "x", "target": { "kind": "section" } })), Err("A section target names its heading.".to_owned()));
945945 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
947947 // until each kind ships).
948948 let edit = agent_edit(&json!({ "kind": "slides", "ops": [{ "op": "add_slide" }] })).unwrap();
949949 assert_eq!(edit["kind"], "slides");
+2−2
55 //! the data. This Worker holds none.
66
77 mod about;
8−mod artifacts;
8+mod run_artifacts;
99 mod addresses;
1010 mod alerts;
1111 mod audit;
840840 };
841841 match audit::run(route.op, &services, &viewer, &input).await? {
842842 // 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) => {
844844 match value["url"].as_str().and_then(|url| worker::Url::parse(url).ok()) {
845845 Some(url) => Response::redirect_with_status(url, 302),
846846 None => reply(&value),
+1−1
88 use serde_json::{Map, Value, json};
99
1010 use crate::about::AboutOp;
11−use crate::artifacts::ArtifactsOp;
11+use crate::run_artifacts::ArtifactsOp;
1212 use crate::deploy_keys::DeployKeysOp;
1313 use crate::mirrors::MirrorsOp;
1414 use crate::deployments::DeploymentsOp;
+7−7
2828 use crate::alerts::{AlertKind, SecurityAlert};
2929 use crate::checks::ChecksOp;
3030 use crate::about::AboutOp;
31−use crate::artifacts::ArtifactsOp;
31+use crate::run_artifacts::ArtifactsOp;
3232 use crate::deploy_keys::DeployKeysOp;
3333 use crate::mirrors::MirrorsOp;
3434 use crate::deployments::DeploymentsOp;
6969 pub deployments: Fetcher,
7070 /// Packages: their settings, versions, deleting and restoring them.
7171 pub packages: Fetcher,
72− /// The docs service: Artifacts mode's docs, slides, designs and
72+ /// The artifacts service (services/artifacts): docs, slides, designs and
7373 /// dashboards (folios), for the artifact routes and tool.
74− pub docs: Fetcher,
74+ pub artifacts: Fetcher,
7575 /// Where the request came in, for its audit entries.
7676 pub audit: crate::audit::AuditContext,
7777 /// Set for a request made with an agent's token: all it may do.
9898 projects: env.service("PROJECTS")?,
9999 deployments: env.service("DEPLOYMENTS")?,
100100 packages: env.service("PACKAGES")?,
101− docs: env.service("DOCS")?,
101+ artifacts: env.service("ARTIFACTS")?,
102102 scope: None,
103103 audit: crate::audit::AuditContext::default(),
104104 addresses: crate::addresses::Addresses::from_env(env),
316316 /// A workspace's rules for personal access tokens, its members'
317317 /// tokens and approving them: token_policy.rs.
318318 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.
320320 Artifacts(ArtifactsOp),
321321 /// A repository's deploy keys: deploy_keys.rs.
322322 DeployKeys(DeployKeysOp),
327327 /// them, and who may use them: packages.rs.
328328 Packages(PackagesOp),
329329 /// Artifacts mode's docs, slides, designs and dashboards, kept by the
330− /// docs service: folios.rs.
330+ /// artifacts service: folios.rs.
331331 Folios(FoliosOp),
332332 }
333333
56245624 Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await,
56255625 Op::Protection(op) => crate::protection::run(op, services, viewer, input).await,
56265626 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,
56285628 Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await,
56295629 Op::Mirrors(op) => crate::mirrors::run(op, services, viewer, input).await,
56305630 Op::Packages(op) => crate::packages::run(op, services, viewer, input).await,
+1−1
33 use serde_json::{Map, Value};
44
55 use crate::about::AboutOp;
6−use crate::artifacts::ArtifactsOp;
6+use crate::run_artifacts::ArtifactsOp;
77 use crate::deploy_keys::DeployKeysOp;
88 use crate::mirrors::MirrorsOp;
99 use crate::deployments::DeploymentsOp;
+300−0
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+}
+2−2
2626 //! which clients that sign their requests with it need.
2727 //!
2828 //! 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).
3030
3131 use base64::Engine;
3232 use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
3939 use serde_json::{Map, Value, json};
4040 use worker::{Bucket, Env, Request, Response, Result, UploadedPart};
4141
42−use crate::artifacts::blob_url;
42+use crate::run_artifacts::blob_url;
4343 use crate::operations::Services;
4444
4545 /// The Twirp services, by name.
+1−1
1919 use serde_json::{Map, Value, json};
2020
2121 use crate::about::AboutOp;
22−use crate::artifacts::ArtifactsOp;
22+use crate::run_artifacts::ArtifactsOp;
2323 use crate::deploy_keys::DeployKeysOp;
2424 use crate::mirrors::MirrorsOp;
2525 use crate::deployments::DeploymentsOp;
+1−1
3535 // Packages: list_packages, their settings, deleting and restoring them.
3636 { "binding": "PACKAGES", "service": "g1t-packages" },
3737 // Artifacts (folios): the artifact tool and /workspaces/{ws}/artifacts.
38− { "binding": "DOCS", "service": "g1t-docs-service" }
38+ { "binding": "ARTIFACTS", "service": "g1t-artifacts" }
3939 ],
4040 // GitHub Actions artifacts older runners kept, in chunks, with KV's own
4141 // expiry, read until it passes (and cache
+55−4
6262 A unit binds only to units in its own stage or an earlier one, so new code
6363 never calls a service that has not shipped. `npm run test:deploy` checks
6464 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.
6569
6670 ## The tool
6771
205209 `node scripts/deploy.mjs doctor` lists what is missing.
206210 9. For [Deployments](/guides/deployments/), which need the Workers for
207211 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
212221 `node services/identity/scripts/create-user.mjs <username>`.
213222
214223 ### Adding a unit
224233 4. `npm run test:deploy` and `node scripts/deploy.mjs manifest --check`
225234 say what is missing.
226235
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+
227278 ## The runner's images
228279
229280 `services/runner` runs every sandbox (agents, checks, the merge queue,
+2−2
3030
3131 | Binding | Stands in for | Self-hosted |
3232 | --- | --- | --- |
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. |
3434 | `EMAIL` | Cloudflare Email Sending | `deploy/self-host/workers/mail`: logs each message and hands it to Mailpit, which can relay through your SMTP server |
3535 | 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 |
3636
6363 | `services/projects` | Runs |
6464 | `services/search` | Runs: site search is SQLite full-text search |
6565 | `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. |
6767 | `services/notify` | Runs. Notifications are live in open tabs; browser push needs a VAPID key pair, which an installation does not make. |
6868 | `services/agents` | Runs, but replies need the model proxy, which is off, so an agent answers with a short apology |
6969 | `services/billing` | Runs with nothing charged and no usage limit |
+3−3
44 * request, a channel, a project, another page); and inline mentions of
55 * people, agents and pages, dates, and citations of code (a file, folder
66 * 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
99 * src/markdown.ts), so keep the two in step. Browser-only: loaded with
1010 * the editor.
1111 */
299299 * Code the page cites, inline: what it names (the path, or the symbol,
300300 * endpoint or variable there), linking to it in Code at the commit it was
301301 * 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).
303303 */
304304 export const Citation = createReactInlineContentSpec(
305305 {
+1−1
22 * An artifact's live connection, whatever its kind: the Yjs document and
33 * everyone's presence, synced over
44 * `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
66 * y-protocols sync and awareness messages; text frames are the service's
77 * own notices (`FoliosLiveEvent`): a rename, a new suggestion, a version,
88 * a change of access.
+0−81
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−}
+0−32
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−});
+0−39
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−}
+1−1
177177 /**
178178 * Where a citation links: the file or folder in Code at the commit it was
179179 * 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.
181181 */
182182 export function citationHref(c: { repo: string; path: string; ref: string | null }): string {
183183 const parts = c.path.split("/").filter(Boolean);
+81−0
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+}
+32−0
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+});
+39−0
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+}
+5−5
5050 const PACKAGES = instrumented("packages", env.PACKAGES);
5151 const CHAT = instrumented("chat", env.CHAT);
5252 const AGENTS = instrumented("agents", env.AGENTS);
53−const DOCS = instrumented("docs", env.DOCS);
53+const ARTIFACTS = instrumented("artifacts", env.ARTIFACTS);
5454
5555 export const identity = identityClient(IDENTITY);
5656 /** A person's email addresses and account security: methods of identity. */
8787 export const chat = chatClient(CHAT);
8888 /** The workspace's own agents: who they are, their limits and their desks. */
8989 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);
+1−1
33 import type { Route } from "./+types/actions-artifact";
44 import { addresses } from "../../lib/addresses.server";
55 import { contentDisposition } from "../../lib/content-safety";
6−import { readArtifact } from "../../lib/artifacts.server";
6+import { readArtifact } from "../../lib/run-artifacts.server";
77 import { actions } from "../../lib/services.server";
88 import { getViewer } from "../../lib/session.server";
99
+2−2
4545 import { Hint } from "../../components/ui/hint";
4646 import { useWorkflowReason } from "../../components/mirror";
4747 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";
5050 import { actions } from "../../lib/services.server";
5151 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
5252 import { accessTo, refusal } from "../../lib/access.server";
+1−1
137137 return folios.deleteTemplate(slug, viewer, sent.template_id ?? "");
138138 case "mark_current":
139139 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.
141141 case "create_space":
142142 return docs.createSpace(slug, viewer, sent.space ?? ({ name: "", kind: "workspace" } as NewDocSpace));
143143 case "update_space":
+2−2
99 /**
1010 * An artifact's live socket: `wss://<site>/<workspace>/-/artifacts/live?folio=<id>`.
1111 * 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
1313 * their role in the folio and gives the socket to its room (one Durable
1414 * Object per folio, any kind), which enforces that role.
1515 */
3030 headers.set(DOCS_VIEWER_HEADER, JSON.stringify(viewer));
3131 const target = `https://docs/live?folio=${encodeURIComponent(folio)}&workspace=${encodeURIComponent(params.owner.toLowerCase())}`;
3232 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 }));
3434 } catch (error) {
3535 console.error("artifacts: the live socket could not be handed over", error);
3636 return new Response("Artifacts didn't answer.", { status: 503 });
+2−2
99 /**
1010 * A file put in an artifact (an image, a PDF, an attachment):
1111 * `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
1313 * the folio, keeps the file, and answers with its address on the usercontent
1414 * origin, where it is served, never on the site.
1515 */
2727 target.searchParams.set("folio", url.searchParams.get("folio") ?? "");
2828 target.searchParams.set("name", url.searchParams.get("name") ?? "file");
2929 try {
30− const answer = await env.DOCS.fetch(target.toString(), {
30+ const answer = await env.ARTIFACTS.fetch(target.toString(), {
3131 method: "PUT",
3232 headers: {
3333 "content-type": request.headers.get("content-type") ?? "application/octet-stream",
+2−2
2727 * socket, forwarded as it is (app/lib/chat-live.server.ts).
2828 */
2929 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> };
3232 /** The workspace's own agents: definitions, templates and desks. */
3333 AGENTS: ServiceBinding;
3434 /**
+3−3
1212 export const AVATAR_PATH = /^\/avatars\/([0-9a-f]{64})$/;
1313 /** A workspace's custom emoji, by the SHA-256 of its bytes: kept by chat under `emoji/<hash>` in the same namespace. */
1414 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). */
1616 export const DOCS_FILE_PATH = /^\/docs-files\/([0-9a-f]{64})$/;
1717 /** The only types identity stores, having checked each image's bytes. */
1818 const AVATAR_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
120120 }
121121
122122 /**
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
124124 * made, so the address is the permission, as a shared link is; the docs
125125 * service serves images, media and PDFs as themselves and everything else
126126 * as a download, and nothing here can run script.
128128 async function serveDocsFile(env: Env, method: string, key: string): Promise<Response> {
129129 let answer: Response;
130130 try {
131− answer = await env.DOCS.fetch(`https://docs/files/${key}`, { method });
131+ answer = await env.ARTIFACTS.fetch(`https://docs/files/${key}`, { method });
132132 } catch {
133133 return plain(503, "Docs didn't answer");
134134 }
+2−2
4747 { "binding": "SEARCH", "service": "g1t-search" },
4848 // Chat: channels, direct messages and the live socket (services/chat).
4949 { "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" },
5252 // The workspace's own agents: definitions, templates, desks (services/agents).
5353 { "binding": "AGENTS", "service": "g1t-agents" },
5454 // Live notifications, counts and browser push: each tab's feed socket (services/notify).
+1−1
980980 pub repo_id: String,
981981 }
982982
983−/// The `doc.page.*` types the docs service (services/docs, TypeScript)
983+/// The `doc.page.*` types the artifacts service (services/artifacts, TypeScript)
984984 /// publishes, with no `repoId` on the event: a page may be in a private
985985 /// space, so it never reaches a repository's timeline or webhooks.
986986 pub const DOC_PAGE_EVENTS: [&str; 4] = ["doc.page.created", "doc.page.updated", "doc.page.archived", "doc.page.stale"];
+6−6
11 //! Folios: what people call artifacts (Artifacts mode).
22 //! 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
44 //! "artifact" in the UI, URLs, the `artifact` MCP tool, REST paths and the
55 //! `artifacts:*` scopes. The Cloudflare Artifacts git store and workflow run
66 //! artifacts ([`crate::actions`]) are something else.
402402 }
403403 }
404404
405− /// The docs service method it goes to.
405+ /// The artifacts service method it goes to.
406406 pub fn method(&self) -> &'static str {
407407 match self {
408408 FolioAccessChange::Grant { .. } | FolioAccessChange::Revoke { .. } => "set_folio_grant",
451451 /// What is wrong with an agent edit's envelope (`FolioAgentEdit` in
452452 /// folios.ts), in the same words as `folioAgentEditError`: its kind, and a
453453 /// 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.
455455 pub fn agent_edit_error(edit: &Value) -> Result<FolioKind, String> {
456456 let Some(edit) = edit.as_object() else { return Err("An edit is an object.".to_owned()) };
457457 let Some(kind) = edit.get("kind").and_then(Value::as_str).and_then(FolioKind::parse) else {
495495 Ok(kind)
496496 }
497497
498−// --- The docs service's folio RPC ---------------------------------------------
498+// --- The artifacts service's folio RPC ---------------------------------------------
499499
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`
501501 /// in folios.ts.
502502 pub const FOLIO_RPC_METHODS: [&str; 48] = [
503503 "folio_list",
666666
667667 // --- Events -------------------------------------------------------------------
668668
669−/// The `folio.*` types the docs service publishes, with no `repoId` on
669+/// The `folio.*` types the artifacts service publishes, with no `repoId` on
670670 /// the event: a folio may be private, so it never reaches a repository's
671671 /// timeline or webhooks. Nothing subscribes to them yet.
672672 pub const FOLIO_EVENTS: [&str; 6] = ["folio.created", "folio.updated", "folio.trashed", "folio.restored", "folio.shared", "folio.stale"];
+1−1
12301230 // Artifacts mode's docs, slides, designs and dashboards (the `artifact`
12311231 // MCP tool). Reading takes artifacts:read, making and changing them
12321232 // 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
12341234 // on each one.
12351235 ("list_workspace_artifacts", Scope::ArtifactsRead),
12361236 ("search_workspace_artifacts", Scope::ArtifactsRead),
+11−11
66 // Cloudflare-only things live:
77 //
88 // - 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,
1010 // which keeps repositories in the git store (gitstore/server.mjs);
1111 // - EMAIL (Email Sending) becomes a service binding to workers/mail;
1212 // - the packages service keeps files in S3-compatible storage (RustFS)
1313 // 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
1515 // (DOCS_S3_BUCKET);
1616 // - services that are off in this phase (agents, the context hub, the
1717 // g1t.page dispatcher, model proxy) are bound to workers/off instead, and
9898
9999 /**
100100 * 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
102102 * emptying artifacts' trash after 30 days). Not run: actions (its
103103 * minute would start scheduled workflows with no runner to take them),
104104 * billing (reconciles against Cloudflare and Stripe), deployments (calls
105105 * Cloudflare's API) and the services that are off.
106106 */
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"]);
108108
109109 /** Queues whose consumers are off: events stops sending to them. */
110110 const OFF_QUEUES = new Set(["g1t-events-runner", "g1t-events-context"]);
189189
190190 // Cloudflare-only bindings, and what stands in for them.
191191 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" });
194194 }
195195 }
196196 if (hosted.send_email) {
268268 });
269269 }
270270 // 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
272272 // src/files.ts, `s3FileStore`).
273− if (hosted.name === "g1t-docs-service") {
273+ if (hosted.name === "g1t-artifacts") {
274274 Object.assign(config.vars, {
275275 DOCS_FILES: "s3",
276276 DOCS_S3_ENDPOINT: process.env.S3_ENDPOINT ?? "http://rustfs:9000",
301301
302302 const compatibility_date = "2026-09-26";
303303 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"),
307307 compatibility_date,
308308 vars: {
309309 GITSTORE_URL: process.env.GITSTORE_URL ?? "http://gitstore:8080",
+1−1
66 // fetches and pushes, and a small JSON API for the reads the repos service
77 // makes (commits, trees, blobs, files) and for creating and forking.
88 //
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),
1010 // which the repos service is bound to in place of the Artifacts binding, and
1111 // by the repos service itself for git's smart HTTP. Nothing else should be
1212 // able to reach it: the API takes a shared secret, and git requests a
+0−155
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−}
+155−0
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+}
+9−7
128128 ],
129129 "self_host": "run"
130130 },
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",
136137 "kind": "ts-worker",
137− "worker": "g1t-docs-service",
138+ "worker": "g1t-artifacts",
138139 "d1": { "database": "g1t-docs", "migrations": "migrations" },
139140 "stage": "core",
140141 "secrets": [],
141142 "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",
143145 "The R2 bucket for files in pages: npx wrangler r2 bucket create g1t-docs-files",
144146 "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)",
145147 "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)",
+15−15
19641964 "resolved": "services/agents",
19651965 "link": true
19661966 },
1967+ "node_modules/@g1t/artifacts": {
1968+ "resolved": "services/artifacts",
1969+ "link": true
1970+ },
19671971 "node_modules/@g1t/chat": {
19681972 "resolved": "services/chat",
19691973 "link": true
19821986 },
19831987 "node_modules/@g1t/docs": {
19841988 "resolved": "apps/docs",
1985− "link": true
1986− },
1987− "node_modules/@g1t/docs-service": {
1988− "resolved": "services/docs",
19891989 "link": true
19901990 },
19911991 "node_modules/@g1t/models": {
1350713507 "@g1t/contracts": "*"
1350813508 }
1350913509 },
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+ },
1351013521 "services/chat": {
1351113522 "name": "@g1t/chat",
1351213523 "version": "0.1.0",
1353013541 "license": "MIT",
1353113542 "dependencies": {
1353213543 "@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"
1354413544 }
1354513545 },
1354613546 "services/models": {
+3−3
11 /**
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
44 * document (Yjs) edited live over a socket, saved with a Markdown
55 * rendition that search, agents, export and the read view use.
66 *
544544
545545 /**
546546 * 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
548548 * through service bindings alone.
549549 */
550550 export const DOCS_VIEWER_HEADER = "x-g1t-docs-viewer";
+4−4
477477 metrics: Record<string, unknown> | null;
478478 };
479479 /**
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
481481 * a page (which may be in a private space) never reaches a repository's
482482 * 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.
484484 */
485485 "doc.page.created": DocPageEventData;
486486 /**
501501 */
502502 "doc.page.stale": DocPageEventData & { repoId: string; repo: string; commit: string; pull: number | null; paths: string[]; owners: string[] };
503503 /**
504− * Artifacts (folios, services/docs): a folio was made. Like `doc.page.*`,
504+ * Artifacts (folios, services/artifacts): a folio was made. Like `doc.page.*`,
505505 * 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.
507507 */
508508 "folio.created": FolioEventData;
509509 /** A folio's content changed: a version (`versionKind`) with everyone whose changes are in it. */
+1−1
6767 assert.equal(folioListQueryError({ tab: "all", kinds: ["sheet" as "doc"] }), "There is no kind of artifact called sheet.");
6868 });
6969
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 () => {
7171 const called: string[] = [];
7272 const bodies: Record<string, unknown>[] = [];
7373 const binding: ServiceBinding = {
+3−3
22 * Folios: what people call artifacts. Artifacts mode is one mode for docs,
33 * slides, designs and dashboards, each private, shared with people and
44 * 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`).
66 *
77 * Naming: code says "folio", people see "artifact" (UI text, URLs
88 * `/<ws>/-/artifacts/...`, the `artifact` MCP tool, REST paths, the
605605 return null;
606606 }
607607
608−// ── The docs service's folio RPC ──────────────────────────────────────
608+// ── The artifacts service's folio RPC ──────────────────────────────────────
609609
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). */
611611 export const FOLIO_RPC_METHODS = [
612612 // Lists and navigation.
613613 "folio_list",
+1−1
600600 // checked by the model proxy at models.g1t.sh.
601601 ["list_gateway_requests", "models:read"],
602602 // 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.
604604 ["list_workspace_artifacts", "artifacts:read"],
605605 ["search_workspace_artifacts", "artifacts:read"],
606606 ["get_workspace_artifact", "artifacts:read"],
+12−5
6161 } from "./deploy/image.mjs";
6262 import { decide, git, planJson, pool, table } from "./deploy/plan.mjs";
6363 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";
6565 import { withDeployWindow } from "./deploy/status-window.mjs";
6666
6767 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]";
429429 for (const unit of units) results.push({ unit: unit.id, stage, ok: false, skipped: true, ms: 0, note: "an earlier stage failed" });
430430 continue;
431431 }
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+ }
436443 }
437444 },
438445 { id: head ?? null, dryRun, log },
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.