Skip to content

Commit

Merge branch 'worktree-agent-a5624039392e490a2' into integrate

syntaqxcommitted Parents29bb62dc708ff1Browse files
18 files+2041−340/18 viewed
+1053−0
1+//! Artifacts over REST and MCP: a workspace's docs, slides, designs and
2+//! dashboards (Artifacts mode, docs/ARTIFACTS_MODE.md), at
3+//! `/workspaces/{workspace}/artifacts` and as the `artifact` MCP tool.
4+//! Code calls them folios; people, URLs, the tool and the scopes say
5+//! "artifact". Workflow runs' artifacts are something else (artifacts.rs).
6+//!
7+//! The docs service (`services/docs`, the `DOCS` binding) decides who may
8+//! do what with each one, from the person's own role on it: this checks
9+//! the input, names people for the service (a username becomes
10+//! `user:<id>`), and gives each answer its public shape, in snake_case.
11+//! The token's `artifacts:*` scope is checked before anything runs
12+//! (audit.rs). A workspace's own token is refused: an artifact always has
13+//! a person as its owner.
14+
15+use g1t_contracts::datasets::DatasetQuery;
16+use g1t_contracts::folios::*;
17+use g1t_contracts::identity::UsernameArgs;
18+use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer};
19+use serde::Serialize;
20+use serde::de::DeserializeOwned;
21+use serde_json::{Map, Value, json};
22+use worker::Result;
23+
24+use crate::operations::Services;
25+
26+/// One operation on artifacts.
27+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28+pub enum FoliosOp {
29+ List,
30+ Search,
31+ Get,
32+ GetContent,
33+ ListVersions,
34+ GetAccess,
35+ ListTemplates,
36+ ListSpaces,
37+ QueryDataset,
38+ Create,
39+ Update,
40+ Edit,
41+ Trash,
42+ Restore,
43+ RestoreVersion,
44+ SetAccess,
45+ Purge,
46+}
47+
48+impl FoliosOp {
49+ /// Every one: `Op::ALL` lists each as `Op::Folios(…)`, which a test
50+ /// checks against this.
51+ #[cfg(test)]
52+ pub const ALL: [FoliosOp; 17] = [
53+ FoliosOp::List,
54+ FoliosOp::Search,
55+ FoliosOp::Get,
56+ FoliosOp::GetContent,
57+ FoliosOp::ListVersions,
58+ FoliosOp::GetAccess,
59+ FoliosOp::ListTemplates,
60+ FoliosOp::ListSpaces,
61+ FoliosOp::QueryDataset,
62+ FoliosOp::Create,
63+ FoliosOp::Update,
64+ FoliosOp::Edit,
65+ FoliosOp::Trash,
66+ FoliosOp::Restore,
67+ FoliosOp::RestoreVersion,
68+ FoliosOp::SetAccess,
69+ FoliosOp::Purge,
70+ ];
71+
72+ /// Its operation id. `workspace_artifact`, because GitHub's names for
73+ /// workflow runs' artifacts (`list_artifacts`, `get_artifact`) are taken.
74+ pub fn name(self) -> &'static str {
75+ match self {
76+ FoliosOp::List => "list_workspace_artifacts",
77+ FoliosOp::Search => "search_workspace_artifacts",
78+ FoliosOp::Get => "get_workspace_artifact",
79+ FoliosOp::GetContent => "get_workspace_artifact_content",
80+ FoliosOp::ListVersions => "list_workspace_artifact_versions",
81+ FoliosOp::GetAccess => "get_workspace_artifact_access",
82+ FoliosOp::ListTemplates => "list_workspace_artifact_templates",
83+ FoliosOp::ListSpaces => "list_workspace_artifact_spaces",
84+ FoliosOp::QueryDataset => "query_workspace_dataset",
85+ FoliosOp::Create => "create_workspace_artifact",
86+ FoliosOp::Update => "update_workspace_artifact",
87+ FoliosOp::Edit => "edit_workspace_artifact",
88+ FoliosOp::Trash => "trash_workspace_artifact",
89+ FoliosOp::Restore => "restore_workspace_artifact",
90+ FoliosOp::RestoreVersion => "restore_workspace_artifact_version",
91+ FoliosOp::SetAccess => "set_workspace_artifact_access",
92+ FoliosOp::Purge => "purge_workspace_artifact",
93+ }
94+ }
95+
96+ /// For the API reference.
97+ pub fn title(self) -> &'static str {
98+ match self {
99+ FoliosOp::List => "List a workspace's artifacts",
100+ FoliosOp::Search => "Search a workspace's artifacts",
101+ FoliosOp::Get => "Get an artifact",
102+ FoliosOp::GetContent => "Get an artifact's content",
103+ FoliosOp::ListVersions => "List an artifact's versions",
104+ FoliosOp::GetAccess => "Get who can open an artifact",
105+ FoliosOp::ListTemplates => "List artifact templates",
106+ FoliosOp::ListSpaces => "List artifact spaces",
107+ FoliosOp::QueryDataset => "Query a dataset",
108+ FoliosOp::Create => "Create an artifact",
109+ FoliosOp::Update => "Update an artifact",
110+ FoliosOp::Edit => "Edit an artifact's content",
111+ FoliosOp::Trash => "Move an artifact to the trash",
112+ FoliosOp::Restore => "Restore an artifact from the trash",
113+ FoliosOp::RestoreVersion => "Restore an artifact version",
114+ FoliosOp::SetAccess => "Share an artifact",
115+ FoliosOp::Purge => "Delete an artifact for good",
116+ }
117+ }
118+
119+ pub fn description(self) -> &'static str {
120+ match self {
121+ FoliosOp::List => "List the artifacts (docs, slides, designs and dashboards) in a workspace that you can open, most recently edited first: each with its id (fol_…), kind, title, icon, space (null in its owner's Private), parent_id, owner, your role (viewer_role: view, comment, edit or manage), general_access, private, excerpt, edited_at and html_url. tab is all (the default), yours (you own them) or shared (shared with you). Narrow with kind, space (its slug or id), project (owner/name), q (words or meaning) and limit (at most 100); next_cursor pages on. With state trashed, the artifacts in the trash you can restore instead. Not workflow runs' artifacts.",
122+ FoliosOp::Search => "Search the artifacts in a workspace that you can open, by words and by meaning: each hit is the artifact with the passage that matched (snippet, and the heading it is under), its space_name and edited_at. Narrow with kind, space, project and limit (at most 50). Only what you can read now is found.",
123+ FoliosOp::Get => "Get one artifact by its id (fol_…) or its address (`/acme/-/artifacts/q4-roadmap-fol_…`): its kind, title, space, parent_id, owner, who made it, your role, general_access, whether it is private, how many it is shared with, its excerpt, whether it may be out of date with the code it cites (stale), and html_url. Not found when you cannot open it, as for one that does not exist. Opening a link-shared artifact this way counts as following its link.",
124+ FoliosOp::GetContent => "Get an artifact's content in the form agents read and write: for a doc, its Markdown in content, and its top-level blocks (id, type, level and Markdown) to target an edit at; with can (read, suggest, edit): what you may do to it. Slides, designs and dashboards answer that they are not here yet.",
125+ FoliosOp::ListVersions => "List an artifact's saved versions, newest first: each with its id (ver_…), created_at, kind (created, edit, agent, suggestion, proposal or restore), note, and the people and agents who made it.",
126+ FoliosOp::GetAccess => "Who can open an artifact and how: its owner, each person, agent and team it is shared with (principal, role, and inherited_from when the access comes from a doc it is under), general_access (none, workspace or link) with general_role, whether it follows its space or parent (inherit), agent_mode, and whether you may change it (can_share).",
127+ FoliosOp::ListTemplates => "The templates an artifact can start from: the built-in ones and those saved in the workspace, each with its id, kind, name, description and body (Markdown, or a JSON spec). Narrow with kind.",
128+ FoliosOp::ListSpaces => "The spaces in your Artifacts sidebar: the General space, open spaces you joined, your teams' spaces and Members-only spaces you are in. Each with its id, slug, name, kind (workspace for an open space, team or private), your role in it (viewer_role) and how many artifacts it holds you can open.",
129+ FoliosOp::QueryDataset => "Run a dataset query (issues, pull requests, workflow runs, deployments, spend or agent sessions) as you, over what you can read: rows of the measure, grouped and filtered as asked, with truncated when more rows matched, and partial true when some of it was left out because you cannot read it. Answers that dashboards are not here yet until they ship.",
130+ FoliosOp::Create => "Make an artifact. kind is doc (the default); slides, designs and dashboards answer that they are not here yet. It lands in space (a slug or id you can edit in), under parent_id (a doc you can edit), or, with neither, in your Private, where only you can open it. Start it from markdown, or from a template (template_id), with an optional title and icon. You own it. Returns the artifact.",
131+ FoliosOp::Update => "Rename an artifact (title), change its icon (null clears it), or move it: to space (a slug or id; `private` for your Private, yours only), under parent_id (a doc), before before_id among its new siblings. Moving changes who can open it when it follows its space or parent. Takes the edit role on it, and on where it goes. Returns the artifact.",
132+ FoliosOp::Edit => "Change an artifact's content. For a doc: markdown with a target: append (add to the end), document (replace it all), section (with heading: that heading and everything under it) or blocks (from_block through to_block, block ids from its content). With the edit role the change is made, as a new version; with the comment role, or suggest_only, it is filed as a suggestion its editors accept or reject. note says why; marks_current says it brings the doc up to date with the code it cites. Returns mode (applied or suggested), version_id or the suggestion, and the artifact.",
133+ FoliosOp::Trash => "Move an artifact, and everything under it, to the trash: nobody can open it until it is restored, and it is deleted for good after 30 days. Takes the edit role. Returns the artifact, with trashed_at.",
134+ FoliosOp::Restore => "Bring an artifact back from the trash, with what went to the trash with it, where it was (at the top of its space or your Private when the doc it was under is gone). Takes the edit role. Returns the artifact.",
135+ FoliosOp::RestoreVersion => "Make an earlier version (version_id) an artifact's content again, as a new version: nothing in between is lost. Takes the edit role. Returns the new version.",
136+ FoliosOp::SetAccess => "Change who can open an artifact. Share it with a person (username), a team (team: its slug) or an agent (agent: its id), or a principal from its access list, at role view, comment, edit or manage (full access), with an optional notify message; role none takes theirs away. Set general_access to none (only people invited), workspace (everyone in the workspace) or link (anyone in the workspace with the link), with general_role view, comment or edit. inherit false stops it following its space or parent; true follows again. agent_mode suggest or edit says how agents change it (null follows its space). Takes full access (manage). Returns who can open it now.",
137+ FoliosOp::Purge => "Delete an artifact in the trash, and everything under it, for good: its content and versions are gone and cannot be restored. Move it to the trash first. Takes full access (manage).",
138+ }
139+ }
140+
141+ /// Whether it changes anything.
142+ #[cfg(test)]
143+ pub fn writes(self) -> bool {
144+ !matches!(
145+ self,
146+ FoliosOp::List
147+ | FoliosOp::Search
148+ | FoliosOp::Get
149+ | FoliosOp::GetContent
150+ | FoliosOp::ListVersions
151+ | FoliosOp::GetAccess
152+ | FoliosOp::ListTemplates
153+ | FoliosOp::ListSpaces
154+ | FoliosOp::QueryDataset
155+ )
156+ }
157+
158+ pub fn input(self) -> Value {
159+ let workspace = json!({ "type": "string", "description": "The workspace's slug, e.g. \"acme\"." });
160+ let artifact_id = json!({ "type": "string", "description": "The artifact's id (fol_…), or its address: /acme/-/artifacts/q4-roadmap-fol_…" });
161+ let kind = json!({ "type": "string", "enum": ["doc", "slides", "design", "dashboard"], "description": "doc, slides, design or dashboard." });
162+ let space = json!({ "type": "string", "description": "A space: its slug (general) or id (spc_…)." });
163+ let project = json!({ "type": "string", "description": "Only artifacts about this repository: owner/name." });
164+ let with = |mut properties: Value| {
165+ properties["workspace"] = workspace.clone();
166+ properties["artifact_id"] = artifact_id.clone();
167+ properties
168+ };
169+ let one = ["workspace", "artifact_id"];
170+ let (properties, required): (Value, Vec<&str>) = match self {
171+ FoliosOp::List => (
172+ json!({
173+ "workspace": workspace,
174+ "tab": { "type": "string", "enum": ["all", "yours", "shared"], "description": "all (the default), yours (you own them) or shared (shared with you by others)." },
175+ "kind": kind,
176+ "space": space,
177+ "project": project,
178+ "q": { "type": "string", "description": "Only artifacts matching these words or this meaning, best first." },
179+ "state": { "type": "string", "enum": ["active", "trashed"], "description": "active (the default), or trashed: what you can restore from the trash." },
180+ "cursor": { "type": "string", "description": "next_cursor from the page before." },
181+ "limit": { "type": "integer", "minimum": 1, "maximum": 100, "description": "How many, at most 100." },
182+ }),
183+ vec!["workspace"],
184+ ),
185+ FoliosOp::Search => (
186+ json!({
187+ "workspace": workspace,
188+ "q": { "type": "string", "description": "Words or a question." },
189+ "kind": kind,
190+ "space": space,
191+ "project": project,
192+ "limit": { "type": "integer", "minimum": 1, "maximum": 50, "description": "How many hits, at most 50." },
193+ }),
194+ vec!["workspace", "q"],
195+ ),
196+ FoliosOp::Get | FoliosOp::GetContent | FoliosOp::ListVersions | FoliosOp::GetAccess | FoliosOp::Trash | FoliosOp::Restore | FoliosOp::Purge => {
197+ (with(json!({})), one.to_vec())
198+ }
199+ FoliosOp::ListTemplates => (json!({ "workspace": workspace, "kind": kind }), vec!["workspace"]),
200+ FoliosOp::ListSpaces => (json!({ "workspace": workspace }), vec!["workspace"]),
201+ FoliosOp::QueryDataset => (
202+ json!({
203+ "workspace": workspace,
204+ "query": {
205+ "type": "object",
206+ "description": "dataset (issues, pull_requests, workflow_runs, deployments, spend or agent_sessions), measure ({ op: count, sum, avg, p50, p95 or rate, field where it takes one }), and optional group_by, interval (day, week or month), time, filters ([{ field, op, value }], at most 10), range (7d, 30d, 90d, or { from, to }, at most 366 days) and limit (at most 100).",
207+ },
208+ }),
209+ vec!["workspace", "query"],
210+ ),
211+ FoliosOp::Create => (
212+ json!({
213+ "workspace": workspace,
214+ "kind": kind,
215+ "title": { "type": "string", "description": "At most 200 characters. Left out: the template's, or Untitled." },
216+ "icon": { "type": "string", "description": "An emoji." },
217+ "space": { "type": "string", "description": "A space you can edit in: its slug or id. Left out, with no parent_id: your Private." },
218+ "parent_id": { "type": "string", "description": "A doc to put it under, which you can edit: its id. Its space is the doc's." },
219+ "markdown": { "type": "string", "description": "What a doc starts with, in Markdown." },
220+ "template_id": { "type": "string", "description": "A template to start from (list_workspace_artifact_templates), instead of markdown." },
221+ }),
222+ vec!["workspace"],
223+ ),
224+ FoliosOp::Update => (
225+ with(json!({
226+ "title": { "type": "string", "description": "Its new title." },
227+ "icon": { "type": ["string", "null"], "description": "An emoji; null clears it." },
228+ "space": { "type": "string", "description": "Move it to the top of this space (slug or id), or `private` for your Private." },
229+ "parent_id": { "type": "string", "description": "Move it under this doc: its id." },
230+ "before_id": { "type": "string", "description": "Where it goes among its new siblings: before this one. Left out: last." },
231+ })),
232+ one.to_vec(),
233+ ),
234+ FoliosOp::Edit => (
235+ with(json!({
236+ "markdown": { "type": "string", "description": "For a doc: the Markdown to add, or to replace the target with." },
237+ "target": {
238+ "type": ["object", "string"],
239+ "description": "For a doc: append or document (as a string or { \"kind\": … }), { \"kind\": \"section\", \"heading\": … }, or { \"kind\": \"blocks\", \"from_block\": …, \"to_block\": … }. Left out: append.",
240+ },
241+ "ops": { "type": "array", "items": { "type": "object" }, "description": "For slides, designs and dashboards, once they ship: the kind's own ops." },
242+ "kind": { "type": "string", "enum": ["doc", "slides", "design", "dashboard"], "description": "The artifact's kind; left out, doc." },
243+ "note": { "type": "string", "description": "Why, in a line: shown with the version or suggestion." },
244+ "suggest_only": { "type": "boolean", "description": "File a suggestion even when you could edit." },
245+ "marks_current": { "type": "boolean", "description": "The edit brings it up to date with the code it cites." },
246+ })),
247+ one.to_vec(),
248+ ),
249+ FoliosOp::RestoreVersion => (
250+ with(json!({ "version_id": { "type": "string", "description": "The version's id (ver_…), from its versions." } })),
251+ [one.as_slice(), &["version_id"]].concat(),
252+ ),
253+ FoliosOp::SetAccess => (
254+ with(json!({
255+ "username": { "type": "string", "description": "A member to share it with." },
256+ "team": { "type": "string", "description": "A team of the workspace to share it with: its slug." },
257+ "agent": { "type": "string", "description": "An agent of the workspace to share it with: its id." },
258+ "principal": { "type": "string", "description": "Who, as its access list names them: user:…, agent:… or team:…" },
259+ "role": { "type": "string", "enum": ["view", "comment", "edit", "manage", "none"], "description": "What they may do: view, comment, edit, or manage (full access, sharing included); none takes their access away." },
260+ "notify": { "type": "string", "description": "A message for the person it is shared with." },
261+ "general_access": { "type": "string", "enum": ["none", "workspace", "link"], "description": "none (only people invited), workspace (everyone in the workspace) or link (anyone in the workspace with the link)." },
262+ "general_role": { "type": "string", "enum": ["view", "comment", "edit"], "description": "What general access gives; never full access." },
263+ "inherit": { "type": "boolean", "description": "Whether it follows its space or the doc it is under." },
264+ "agent_mode": { "type": ["string", "null"], "enum": ["suggest", "edit", null], "description": "How agents change it: suggest or edit; null follows its space." },
265+ })),
266+ one.to_vec(),
267+ ),
268+ };
269+ json!({ "type": "object", "properties": properties, "required": required })
270+ }
271+}
272+
273+// --- Reading the input --------------------------------------------------------
274+
275+fn text(input: &Value, key: &str) -> Option<String> {
276+ input[key].as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned)
277+}
278+
279+/// A whole number given as one, or as digits (a REST query).
280+fn number(input: &Value, key: &str) -> Option<u32> {
281+ input[key]
282+ .as_u64()
283+ .or_else(|| text(input, key).and_then(|given| given.parse().ok()))
284+ .map(|n| u32::try_from(n).unwrap_or(u32::MAX))
285+}
286+
287+fn invalid(message: impl Into<String>) -> Outcome<Value> {
288+ Outcome::fail(FailureCode::Invalid, message)
289+}
290+
291+/// The folio id an `artifact_id` names: an id, or an address or link that
292+/// ends in one.
293+pub(crate) fn artifact_id(given: &str) -> Option<String> {
294+ let given = given.trim();
295+ let path = given.split(['?', '#']).next().unwrap_or_default();
296+ let last = path.trim_end_matches('/').rsplit('/').next().unwrap_or_default();
297+ folio_id_from(last).map(str::to_owned)
298+}
299+
300+fn kind_of(input: &Value) -> std::result::Result<Option<FolioKind>, String> {
301+ match text(input, "kind") {
302+ None => Ok(None),
303+ Some(kind) => FolioKind::parse(&kind.to_lowercase())
304+ .map(Some)
305+ .ok_or_else(|| format!("There is no kind of artifact called {kind}: doc, slides, design or dashboard.")),
306+ }
307+}
308+
309+/// A doc edit's target, as the docs service reads it: a string is the
310+/// kind, an object passes as given. Left out, append.
311+fn edit_target(input: &Value) -> Value {
312+ match &input["target"] {
313+ Value::String(kind) => json!({ "kind": kind.trim().to_lowercase() }),
314+ Value::Object(target) => Value::Object(target.clone()),
315+ _ => json!({ "kind": "append" }),
316+ }
317+}
318+
319+/// The `FolioAgentEdit` a call describes.
320+pub(crate) fn agent_edit(input: &Value) -> std::result::Result<Value, String> {
321+ let kind = kind_of(input)?.unwrap_or(FolioKind::Doc);
322+ let mut edit = Map::new();
323+ edit.insert("kind".to_owned(), json!(kind.as_str()));
324+ if kind == FolioKind::Doc {
325+ let Some(markdown) = input["markdown"].as_str() else {
326+ return Err("Give the markdown to add, or to replace the target with.".to_owned());
327+ };
328+ edit.insert("markdown".to_owned(), json!(markdown));
329+ edit.insert("target".to_owned(), edit_target(input));
330+ } else {
331+ edit.insert("ops".to_owned(), input["ops"].clone());
332+ }
333+ for key in ["note", "suggest_only", "marks_current"] {
334+ if let Some(value) = input.get(key).filter(|value| !value.is_null()) {
335+ edit.insert(key.to_owned(), value.clone());
336+ }
337+ }
338+ let edit = Value::Object(edit);
339+ agent_edit_error(&edit)?;
340+ Ok(edit)
341+}
342+
343+/// What a call to the access route changes, in order: someone's role,
344+/// then general access, then following the space or parent, then how
345+/// agents change it. The principal is resolved by the caller.
346+pub(crate) fn access_changes(input: &Value, principal: Option<String>) -> std::result::Result<Vec<FolioAccessChange>, String> {
347+ let mut changes = Vec::new();
348+ let role = text(input, "role").map(|role| role.to_lowercase());
349+ match (principal, role.as_deref()) {
350+ (Some(principal), Some("none")) => changes.push(FolioAccessChange::Revoke { principal }),
351+ (Some(principal), Some(role)) => {
352+ let role = parse_role(role).ok_or_else(|| format!("{role} is not a role: view, comment, edit, manage or none."))?;
353+ changes.push(FolioAccessChange::Grant { principal, role, notify: text(input, "notify") });
354+ }
355+ (Some(_), None) => return Err("Give the role: view, comment, edit, manage, or none to take their access away.".to_owned()),
356+ (None, Some(_)) => return Err("Give who to share it with: username, team, agent or principal.".to_owned()),
357+ (None, None) => {}
358+ }
359+ if let Some(access) = text(input, "general_access") {
360+ let access = match access.to_lowercase().as_str() {
361+ "none" | "restricted" => GeneralAccess::None,
362+ "workspace" => GeneralAccess::Workspace,
363+ "link" => GeneralAccess::Link,
364+ other => return Err(format!("{other} is not general access: none, workspace or link.")),
365+ };
366+ let role = match (access, text(input, "general_role")) {
367+ (GeneralAccess::None, _) => None,
368+ (_, None) => Some(FolioRole::View),
369+ (_, Some(role)) => Some(parse_role(&role.to_lowercase()).ok_or_else(|| format!("{role} is not a role: view, comment or edit."))?),
370+ };
371+ changes.push(FolioAccessChange::General { access, role });
372+ }
373+ if let Some(inherit) = input["inherit"].as_bool() {
374+ changes.push(FolioAccessChange::Inherit { inherit });
375+ }
376+ if let Some(mode) = input.get("agent_mode") {
377+ let agent_mode = match mode.as_str().map(str::to_lowercase).as_deref() {
378+ None if mode.is_null() => None,
379+ Some("suggest") => Some(AgentMode::Suggest),
380+ Some("edit") => Some(AgentMode::Edit),
381+ _ => return Err("agent_mode is suggest, edit, or null to follow its space.".to_owned()),
382+ };
383+ changes.push(FolioAccessChange::AgentMode { agent_mode });
384+ }
385+ if changes.is_empty() {
386+ return Err("Say what to change: who and a role, general_access, inherit or agent_mode.".to_owned());
387+ }
388+ for change in &changes {
389+ change.validate()?;
390+ }
391+ Ok(changes)
392+}
393+
394+fn parse_role(text: &str) -> Option<FolioRole> {
395+ match text {
396+ "view" | "read" => Some(FolioRole::View),
397+ "comment" => Some(FolioRole::Comment),
398+ "edit" | "write" => Some(FolioRole::Edit),
399+ "manage" | "full" | "admin" => Some(FolioRole::Manage),
400+ _ => None,
401+ }
402+}
403+
404+// --- Answers, as the API shows them -------------------------------------------
405+
406+/// A person, agent or team, from a docs service profile.
407+pub(crate) fn person_json(profile: &Value) -> Value {
408+ if !profile.is_object() {
409+ return Value::Null;
410+ }
411+ let kind = profile["kind"].as_str().unwrap_or("user");
412+ let name = profile["name"].clone();
413+ match kind {
414+ "agent" => json!({ "type": "agent", "id": profile["id"], "handle": name, "display_name": profile["display_name"] }),
415+ "team" => json!({ "type": "team", "slug": profile["id"], "display_name": profile["display_name"] }),
416+ _ => json!({ "type": "user", "id": profile["id"], "username": name, "display_name": profile["display_name"] }),
417+ }
418+}
419+
420+fn people_json(list: &Value) -> Value {
421+ Value::Array(list.as_array().map(|list| list.iter().map(person_json).collect()).unwrap_or_default())
422+}
423+
424+fn html_url(site: &str, path: &Value) -> Value {
425+ match path.as_str() {
426+ Some(path) => json!(format!("{}{}", site.trim_end_matches('/'), path)),
427+ None => Value::Null,
428+ }
429+}
430+
431+/// Enough to link to an artifact.
432+pub(crate) fn ref_json(folio: &Value, site: &str) -> Value {
433+ json!({
434+ "id": folio["id"],
435+ "kind": folio["kind"],
436+ "title": folio["title"],
437+ "icon": folio["icon"],
438+ "slug": folio["slug"],
439+ "html_url": html_url(site, &folio["path"]),
440+ })
441+}
442+
443+/// An artifact as the API shows one.
444+pub(crate) fn folio_json(folio: &Value, site: &str) -> Value {
445+ let space = match &folio["space"] {
446+ Value::Object(space) => json!({ "id": space.get("id"), "slug": space.get("slug"), "name": space.get("name"), "kind": space.get("kind") }),
447+ _ => Value::Null,
448+ };
449+ json!({
450+ "id": folio["id"],
451+ "kind": folio["kind"],
452+ "title": folio["title"],
453+ "icon": folio["icon"],
454+ "slug": folio["slug"],
455+ "space": space,
456+ "parent_id": folio["parent_id"],
457+ "has_children": folio["has_children"],
458+ "owner": person_json(&folio["owner"]),
459+ "created_by": person_json(&folio["created_by"]),
460+ "created_at": folio["created_at"],
461+ "updated_at": folio["updated_at"],
462+ "edited_by": person_json(&folio["edited_by"]),
463+ "edited_at": folio["edited_at"],
464+ "trashed_at": folio["trashed_at"],
465+ "viewer_role": folio["viewer_role"],
466+ "private": folio["private"],
467+ "shared_count": folio["shared_count"],
468+ "general_access": folio["general_access"],
469+ "general_role": folio["general_role"],
470+ "inherit": folio["inherit"],
471+ "agent_mode": folio["agent_mode"],
472+ "excerpt": folio["excerpt"],
473+ "source": folio["source"],
474+ "stale": folio["stale"],
475+ "html_url": html_url(site, &folio["path"]),
476+ })
477+}
478+
479+fn content_json(read: &Value, site: &str) -> Value {
480+ let mut artifact = ref_json(&read["folio"], site);
481+ artifact["edited_at"] = read["folio"]["edited_at"].clone();
482+ let mut shown = json!({
483+ "artifact": artifact,
484+ "space": read["space"],
485+ "content": read["content"],
486+ "can": read["can"],
487+ });
488+ if let Some(blocks) = read.get("blocks").filter(|blocks| blocks.is_array()) {
489+ shown["blocks"] = blocks.clone();
490+ }
491+ shown
492+}
493+
494+fn suggestion_json(suggestion: &Value) -> Value {
495+ json!({
496+ "id": suggestion["id"],
497+ "status": suggestion["status"],
498+ "target": suggestion["target"],
499+ "before_markdown": suggestion["before_markdown"],
500+ "after_markdown": suggestion["after_markdown"],
501+ "note": suggestion["note"],
502+ "author": person_json(&suggestion["author"]),
503+ "created_at": suggestion["created_at"],
504+ })
505+}
506+
507+fn edit_json(result: &Value, site: &str) -> Value {
508+ let mut shown = json!({ "mode": result["mode"], "artifact": ref_json(&result["folio"], site) });
509+ match result["mode"].as_str() {
510+ Some("applied") => {
511+ shown["version_id"] = result["version_id"].clone();
512+ shown["summary"] = result["summary"].clone();
513+ }
514+ Some("suggested") => shown["suggestion"] = suggestion_json(&result["suggestion"]),
515+ _ => {}
516+ }
517+ shown
518+}
519+
520+fn version_json(version: &Value) -> Value {
521+ json!({
522+ "id": version["id"],
523+ "artifact_id": version["folio_id"],
524+ "created_at": version["created_at"],
525+ "kind": version["kind"],
526+ "note": version["note"],
527+ "authors": people_json(&version["authors"]),
528+ })
529+}
530+
531+fn access_json(list: &Value) -> Value {
532+ let rows: Vec<Value> = list["rows"]
533+ .as_array()
534+ .map(|rows| {
535+ rows.iter()
536+ .map(|row| {
537+ let inherited = match row["source"]["kind"].as_str() {
538+ Some("folio") => json!({ "artifact_id": row["source"]["id"], "title": row["source"]["title"] }),
539+ _ => Value::Null,
540+ };
541+ json!({ "principal": row["principal"], "member": person_json(&row["profile"]), "role": row["role"], "inherited_from": inherited })
542+ })
543+ .collect()
544+ })
545+ .unwrap_or_default();
546+ let inherited_from = match &list["inherited_from"] {
547+ Value::Object(from) => json!({ "type": from.get("kind").and_then(Value::as_str).map(|kind| if kind == "folio" { "artifact" } else { kind }), "id": from.get("id"), "name": from.get("name") }),
548+ _ => Value::Null,
549+ };
550+ json!({
551+ "artifact_id": list["folio_id"],
552+ "owner": person_json(&list["owner"]),
553+ "shared_with": rows,
554+ "general_access": list["general_access"],
555+ "general_role": list["general_role"],
556+ "inherit": list["inherit"],
557+ "inherited_from": inherited_from,
558+ "agent_mode": list["agent_mode"],
559+ "can_share": list["can_share"],
560+ })
561+}
562+
563+fn template_json(template: &Value) -> Value {
564+ json!({
565+ "id": template["id"],
566+ "kind": template["kind"],
567+ "name": template["name"],
568+ "description": template["description"],
569+ "icon": template["icon"],
570+ "builtin": template["builtin"],
571+ "body": template["body"],
572+ })
573+}
574+
575+fn space_json(space: &Value) -> Value {
576+ json!({
577+ "id": space["id"],
578+ "slug": space["slug"],
579+ "name": space["name"],
580+ "description": space["description"],
581+ "icon": space["icon"],
582+ "kind": space["kind"],
583+ "is_default": space["is_default"],
584+ "joined": space["joined"],
585+ "viewer_role": space["viewer_role"],
586+ "artifact_count": space["page_count"],
587+ })
588+}
589+
590+fn hit_json(hit: &Value, site: &str) -> Value {
591+ let mut shown = ref_json(hit, site);
592+ for key in ["space_name", "snippet", "heading", "matched", "edited_at"] {
593+ shown[key] = hit.get(key).cloned().unwrap_or(Value::Null);
594+ }
595+ shown
596+}
597+
598+fn mapped(outcome: Outcome<Value>, f: impl FnOnce(&Value) -> Value) -> Outcome<Value> {
599+ match outcome {
600+ Outcome::Ok(value) => Outcome::Ok(f(&value)),
601+ Outcome::Fail(refused) => Outcome::Fail(refused),
602+ }
603+}
604+
605+fn listed(outcome: Outcome<Value>, f: impl Fn(&Value) -> Value) -> Outcome<Value> {
606+ mapped(outcome, |list| Value::Array(list.as_array().map(|list| list.iter().map(&f).collect()).unwrap_or_default()))
607+}
608+
609+// --- Running ------------------------------------------------------------------
610+
611+async fn call<T: DeserializeOwned>(services: &Services, method: &str, args: &impl Serialize) -> Result<Outcome<T>> {
612+ g1t_kit::call(&services.docs, method, args).await
613+}
614+
615+/// The person acting, or the refusal: nobody, or a workspace's own token.
616+pub(crate) fn person(viewer: &Viewer) -> std::result::Result<User, Outcome<Value>> {
617+ match viewer {
618+ None => Err(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token.")),
619+ Some(user) if user.kind == PrincipalKind::Workspace => Err(Outcome::fail(
620+ FailureCode::Forbidden,
621+ "Artifacts belong to people: use a personal access token, or a g1t agent's.",
622+ )),
623+ Some(user) => Ok(user.clone()),
624+ }
625+}
626+
627+/// A space's id from its slug or id, among the spaces in the person's
628+/// sidebar; an id passes as given. `Ok(None)` for `private`.
629+async fn space_id(services: &Services, workspace: &str, viewer: &User, given: &str) -> Result<std::result::Result<Option<String>, Outcome<Value>>> {
630+ let given = given.trim();
631+ if given.eq_ignore_ascii_case("private") {
632+ return Ok(Ok(None));
633+ }
634+ if given.starts_with("spc_") {
635+ return Ok(Ok(Some(given.to_owned())));
636+ }
637+ let sidebar: Outcome<Value> = call(services, "folio_sidebar", &json!({ "workspace": workspace, "viewer": viewer })).await?;
638+ let sidebar = match sidebar {
639+ Outcome::Ok(sidebar) => sidebar,
640+ Outcome::Fail(refused) => return Ok(Err(Outcome::Fail(refused))),
641+ };
642+ let found = sidebar["spaces"]
643+ .as_array()
644+ .and_then(|spaces| spaces.iter().find(|space| space["slug"].as_str().is_some_and(|slug| slug.eq_ignore_ascii_case(given))))
645+ .and_then(|space| space["id"].as_str().map(str::to_owned));
646+ Ok(match found {
647+ Some(id) => Ok(Some(id)),
648+ None => Err(invalid(format!("There is no space called {given} in your sidebar: give its id, or join it first."))),
649+ })
650+}
651+
652+/// Who a share is for, as the docs service names them.
653+async fn principal(services: &Services, input: &Value) -> Result<std::result::Result<Option<String>, Outcome<Value>>> {
654+ if let Some(principal) = text(input, "principal") {
655+ return Ok(Ok(Some(principal)));
656+ }
657+ if let Some(team) = text(input, "team") {
658+ let slug = team.trim_start_matches('@').rsplit('/').next().unwrap_or_default().to_lowercase();
659+ return Ok(Ok(Some(format!("team:{slug}"))));
660+ }
661+ if let Some(agent) = text(input, "agent") {
662+ return Ok(Ok(Some(format!("agent:{agent}"))));
663+ }
664+ let Some(username) = text(input, "username").or_else(|| text(input, "user")) else {
665+ return Ok(Ok(None));
666+ };
667+ let username = username.trim_start_matches('@').to_lowercase();
668+ let found: Viewer = g1t_kit::call(&services.identity, "user_by_username", &UsernameArgs { username: username.clone() }).await?;
669+ Ok(match found {
670+ Some(user) => Ok(Some(format!("user:{}", user.id))),
671+ None => Err(invalid(format!("There is no account named {username}."))),
672+ })
673+}
674+
675+pub async fn run(op: FoliosOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
676+ let site = services.addresses.site.clone();
677+ let viewer = match person(viewer) {
678+ Ok(user) => user,
679+ Err(refused) => return Ok(refused),
680+ };
681+ let Some(workspace) = text(input, "workspace").map(|w| w.to_lowercase()) else {
682+ return Ok(invalid("Give the workspace's slug."));
683+ };
684+ let kind = match kind_of(input) {
685+ Ok(kind) => kind,
686+ Err(message) => return Ok(invalid(message)),
687+ };
688+ macro_rules! space {
689+ ($given:expr) => {
690+ match space_id(services, &workspace, &viewer, $given).await? {
691+ Ok(id) => id,
692+ Err(refused) => return Ok(refused),
693+ }
694+ };
695+ }
696+ let folio_id = match op {
697+ FoliosOp::List | FoliosOp::Search | FoliosOp::ListTemplates | FoliosOp::ListSpaces | FoliosOp::QueryDataset | FoliosOp::Create => String::new(),
698+ _ => match text(input, "artifact_id").as_deref().and_then(artifact_id) {
699+ Some(id) => id,
700+ None => return Ok(invalid("Give the artifact_id: its id (fol_…) or its address.")),
701+ },
702+ };
703+ let one = || FolioArgs { workspace: workspace.clone(), viewer: viewer.clone(), folio_id: folio_id.clone() };
704+ let shown = |folio: &Value| folio_json(folio, &site);
705+ Ok(match op {
706+ FoliosOp::List => {
707+ if text(input, "state").as_deref() == Some("trashed") {
708+ let found: Outcome<Value> = call(services, "folio_trash", &json!({ "workspace": workspace, "viewer": viewer })).await?;
709+ return Ok(mapped(found, |list| json!({ "items": list.as_array().map(|list| list.iter().map(shown).collect::<Vec<_>>()).unwrap_or_default(), "next_cursor": null })));
710+ }
711+ let tab = match text(input, "tab").map(|tab| tab.to_lowercase()).as_deref() {
712+ None | Some("all") => FolioListTab::All,
713+ Some("yours") | Some("mine") => FolioListTab::Yours,
714+ Some("shared") => FolioListTab::Shared,
715+ Some(other) => return Ok(invalid(format!("{other} is not a tab: all, yours or shared."))),
716+ };
717+ let space_id = match text(input, "space") {
718+ Some(given) => space!(&given),
719+ None => None,
720+ };
721+ let limit = number(input, "limit");
722+ let query = FolioListQuery {
723+ tab,
724+ kinds: kind.map(|kind| vec![kind]),
725+ space_id,
726+ owner: None,
727+ project: text(input, "project"),
728+ q: text(input, "q"),
729+ cursor: text(input, "cursor"),
730+ limit,
731+ };
732+ if let Err(message) = query.validate() {
733+ return Ok(invalid(message));
734+ }
735+ let found: Outcome<Value> = call(services, "folio_list", &FolioListArgs { workspace, viewer, query }).await?;
736+ mapped(found, |list| {
737+ json!({
738+ "items": list["items"].as_array().map(|items| items.iter().map(shown).collect::<Vec<_>>()).unwrap_or_default(),
739+ "next_cursor": list["next_cursor"],
740+ })
741+ })
742+ }
743+ FoliosOp::Search => {
744+ let Some(q) = text(input, "q") else { return Ok(invalid("Give q: what to search for.")) };
745+ let space_id = match text(input, "space") {
746+ Some(given) => space!(&given),
747+ None => None,
748+ };
749+ let limit = number(input, "limit").map(|l| l.clamp(1, 50));
750+ let query = FolioSearchQuery { q, kinds: kind.map(|kind| vec![kind]), space_id, project: text(input, "project"), owner: None, mode: Some("hybrid".to_owned()), limit };
751+ let found: Outcome<Value> = call(services, "search_folios", &SearchFoliosArgs { workspace, viewer, query }).await?;
752+ listed(found, |hit| hit_json(hit, &site))
753+ }
754+ FoliosOp::Get => mapped(call(services, "folio", &one()).await?, shown),
755+ FoliosOp::GetContent => mapped(call(services, "folio_content", &one()).await?, |read| content_json(read, &site)),
756+ FoliosOp::ListVersions => listed(call(services, "folio_versions", &one()).await?, version_json),
757+ FoliosOp::GetAccess => mapped(call(services, "folio_access", &one()).await?, access_json),
758+ FoliosOp::ListTemplates => {
759+ let found: Outcome<Value> = call(services, "folio_templates", &FolioTemplatesArgs { workspace, viewer, kind }).await?;
760+ listed(found, template_json)
761+ }
762+ FoliosOp::ListSpaces => {
763+ let found: Outcome<Value> = call(services, "folio_sidebar", &json!({ "workspace": workspace, "viewer": viewer })).await?;
764+ mapped(found, |sidebar| Value::Array(sidebar["spaces"].as_array().map(|spaces| spaces.iter().map(space_json).collect()).unwrap_or_default()))
765+ }
766+ FoliosOp::QueryDataset => {
767+ let query: DatasetQuery = match serde_json::from_value(input["query"].clone()) {
768+ Ok(query) => query,
769+ Err(error) => return Ok(invalid(format!("That query does not read: {error}."))),
770+ };
771+ if let Err(message) = query.validate() {
772+ return Ok(invalid(message));
773+ }
774+ call(services, "query_dataset", &QueryDatasetArgs { workspace, viewer, query }).await?
775+ }
776+ FoliosOp::Create => {
777+ let kind = kind.unwrap_or(FolioKind::Doc);
778+ let space_id = match text(input, "space") {
779+ Some(given) => space!(&given),
780+ None => None,
781+ };
782+ let content = input["markdown"].as_str().map(|markdown| FolioContentInput { markdown: Some(markdown.to_owned()), spec: None });
783+ let new = NewFolio {
784+ kind,
785+ title: text(input, "title"),
786+ icon: text(input, "icon"),
787+ space_id,
788+ parent_id: text(input, "parent_id"),
789+ template_id: text(input, "template_id"),
790+ content,
791+ source: None,
792+ share_with: None,
793+ };
794+ if let Err(message) = new.validate() {
795+ return Ok(invalid(message));
796+ }
797+ mapped(call(services, "create_folio", &CreateFolioArgs { workspace, viewer, input: new }).await?, shown)
798+ }
799+ FoliosOp::Update => {
800+ let mut change = FolioChange::default();
801+ if let Some(title) = input["title"].as_str() {
802+ if title.chars().count() > MAX_TITLE {
803+ return Ok(invalid(format!("A title is at most {MAX_TITLE} characters.")));
804+ }
805+ change.title = Some(title.to_owned());
806+ }
807+ if let Some(icon) = input.get("icon") {
808+ change.icon = Some(icon.as_str().map(str::to_owned));
809+ }
810+ let moving = input.get("space").is_some_and(|v| !v.is_null()) || input.get("parent_id").is_some_and(|v| !v.is_null());
811+ if change == FolioChange::default() && !moving {
812+ return Ok(invalid("Say what to change: title, icon, space or parent_id."));
813+ }
814+ let mut answer: Option<Outcome<Value>> = None;
815+ if change != FolioChange::default() {
816+ let changed: Outcome<Value> = call(services, "update_folio", &UpdateFolioArgs { workspace: workspace.clone(), viewer: viewer.clone(), folio_id: folio_id.clone(), change }).await?;
817+ if let Outcome::Fail(_) = changed {
818+ return Ok(changed);
819+ }
820+ answer = Some(changed);
821+ }
822+ if moving {
823+ let parent_id = text(input, "parent_id");
824+ let space_id = match (&parent_id, text(input, "space")) {
825+ (None, Some(given)) => space!(&given),
826+ _ => None,
827+ };
828+ let to = FolioMove { space_id, parent_id, before_id: text(input, "before_id") };
829+ answer = Some(call(services, "move_folio", &MoveFolioArgs { workspace, viewer, folio_id, to }).await?);
830+ }
831+ mapped(answer.unwrap_or_else(|| invalid("Nothing to change.")), shown)
832+ }
833+ FoliosOp::Edit => {
834+ let edit = match agent_edit(input) {
835+ Ok(edit) => edit,
836+ Err(message) => return Ok(invalid(message)),
837+ };
838+ mapped(call(services, "edit_folio", &EditFolioArgs { workspace, viewer, folio_id, edit }).await?, |result| edit_json(result, &site))
839+ }
840+ FoliosOp::Trash => mapped(call(services, "trash_folio", &one()).await?, shown),
841+ FoliosOp::Restore => mapped(call(services, "restore_folio", &one()).await?, shown),
842+ FoliosOp::Purge => mapped(call(services, "delete_folio", &one()).await?, |_| json!({ "deleted": true })),
843+ FoliosOp::RestoreVersion => {
844+ let Some(version_id) = text(input, "version_id") else { return Ok(invalid("Give the version_id.")) };
845+ let args = FolioVersionArgs { workspace, viewer, folio_id, version_id };
846+ mapped(call(services, "restore_folio_version", &args).await?, version_json)
847+ }
848+ FoliosOp::SetAccess => {
849+ let principal = match principal(services, input).await? {
850+ Ok(principal) => principal,
851+ Err(refused) => return Ok(refused),
852+ };
853+ let changes = match access_changes(input, principal) {
854+ Ok(changes) => changes,
855+ Err(message) => return Ok(invalid(message)),
856+ };
857+ let mut last: Outcome<Value> = invalid("Nothing to change.");
858+ for change in changes {
859+ let method = change.method();
860+ let args = FolioAccessArgs { workspace: workspace.clone(), viewer: viewer.clone(), folio_id: folio_id.clone(), change };
861+ last = call(services, method, &args).await?;
862+ if let Outcome::Fail(_) = last {
863+ return Ok(last);
864+ }
865+ }
866+ mapped(last, access_json)
867+ }
868+ })
869+}
870+
871+#[cfg(test)]
872+mod tests {
873+ use super::*;
874+
875+ fn folio() -> Value {
876+ json!({
877+ "id": "fol_0123456789abcdefghjkmnpqrs",
878+ "kind": "doc",
879+ "title": "Q4 roadmap",
880+ "icon": null,
881+ "slug": "q4-roadmap-fol_0123456789abcdefghjkmnpqrs",
882+ "path": "/acme/-/artifacts/q4-roadmap-fol_0123456789abcdefghjkmnpqrs",
883+ "workspace_id": "ws_1",
884+ "space": { "id": "spc_1", "slug": "general", "name": "General", "kind": "workspace" },
885+ "parent_id": null,
886+ "position": 1024,
887+ "owner": { "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana", "avatar": null, "role": null },
888+ "created_by": { "kind": "agent", "id": "agt_1", "name": "g1t", "display_name": "g1t", "avatar": null, "role": null },
889+ "created_at": "2026-10-01T00:00:00.000Z",
890+ "updated_at": "2026-10-02T00:00:00.000Z",
891+ "edited_by": null,
892+ "edited_at": "2026-10-02T00:00:00.000Z",
893+ "trashed_at": null,
894+ "viewer_role": "manage",
895+ "favorite": false,
896+ "private": false,
897+ "shared_count": 2,
898+ "general_access": "workspace",
899+ "general_role": "view",
900+ "inherit": true,
901+ "inherited_from": null,
902+ "agent_mode": "suggest",
903+ "excerpt": "What we ship.",
904+ "preview": null,
905+ "source": null,
906+ "stale": false,
907+ "has_children": false,
908+ })
909+ }
910+
911+ #[test]
912+ fn an_artifact_is_snake_case_with_its_page() {
913+ let shown = folio_json(&folio(), "https://g1t.sh/");
914+ assert_eq!(shown["html_url"], "https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_0123456789abcdefghjkmnpqrs");
915+ assert_eq!(shown["owner"], json!({ "type": "user", "id": "usr_1", "username": "ana", "display_name": "Ana" }));
916+ assert_eq!(shown["created_by"]["type"], "agent");
917+ assert_eq!(shown["edited_by"], Value::Null);
918+ assert_eq!(shown["space"]["slug"], "general");
919+ assert!(shown.get("preview").is_none() && shown.get("workspace_id").is_none());
920+ assert!(g1t_kit::wire::camel_case_keys(&shown).is_empty());
921+ }
922+
923+ #[test]
924+ fn an_artifact_is_named_by_its_id_or_any_address_ending_in_it() {
925+ let id = "fol_0123456789abcdefghjkmnpqrs";
926+ assert_eq!(artifact_id(id).as_deref(), Some(id));
927+ assert_eq!(artifact_id("q4-roadmap-fol_0123456789abcdefghjkmnpqrs").as_deref(), Some(id));
928+ assert_eq!(artifact_id("https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_0123456789abcdefghjkmnpqrs?x=1#h").as_deref(), Some(id));
929+ assert_eq!(artifact_id("/acme/-/artifacts/q4-roadmap-fol_0123456789abcdefghjkmnpqrs/").as_deref(), Some(id));
930+ assert_eq!(artifact_id("q4-roadmap"), None);
931+ assert_eq!(artifact_id("../fol_x"), None);
932+ }
933+
934+ #[test]
935+ fn an_edit_is_a_doc_edit_unless_it_says_otherwise() {
936+ let edit = agent_edit(&json!({ "markdown": "## Next\n\nMore." })).unwrap();
937+ assert_eq!(edit, json!({ "kind": "doc", "markdown": "## Next\n\nMore.", "target": { "kind": "append" } }));
938+ let edit = agent_edit(&json!({ "markdown": "x", "target": "Document", "note": "Rewrite", "suggest_only": true })).unwrap();
939+ assert_eq!(edit["target"], json!({ "kind": "document" }));
940+ assert_eq!(edit["note"], "Rewrite");
941+ assert_eq!(edit["suggest_only"], true);
942+ let edit = agent_edit(&json!({ "markdown": "x", "target": { "kind": "section", "heading": "Risks" } })).unwrap();
943+ assert_eq!(edit["target"]["heading"], "Risks");
944+ assert_eq!(agent_edit(&json!({ "markdown": "x", "target": { "kind": "section" } })), Err("A section target names its heading.".to_owned()));
945+ assert!(agent_edit(&json!({})).unwrap_err().contains("markdown"));
946+ // Other kinds carry ops, which the docs service checks (and refuses
947+ // until each kind ships).
948+ let edit = agent_edit(&json!({ "kind": "slides", "ops": [{ "op": "add_slide" }] })).unwrap();
949+ assert_eq!(edit["kind"], "slides");
950+ assert_eq!(agent_edit(&json!({ "kind": "slides" })), Err("A deck edit has a list of ops.".to_owned()));
951+ assert!(agent_edit(&json!({ "kind": "poster", "markdown": "x" })).unwrap_err().contains("poster"));
952+ }
953+
954+ #[test]
955+ fn sharing_reads_one_change_of_each_kind_in_order() {
956+ let changes = access_changes(
957+ &json!({ "role": "edit", "notify": "Have a look", "general_access": "workspace", "inherit": false, "agent_mode": null }),
958+ Some("user:usr_2".to_owned()),
959+ )
960+ .unwrap();
961+ assert_eq!(
962+ changes,
963+ vec![
964+ FolioAccessChange::Grant { principal: "user:usr_2".into(), role: FolioRole::Edit, notify: Some("Have a look".into()) },
965+ FolioAccessChange::General { access: GeneralAccess::Workspace, role: Some(FolioRole::View) },
966+ FolioAccessChange::Inherit { inherit: false },
967+ FolioAccessChange::AgentMode { agent_mode: None },
968+ ]
969+ );
970+ assert_eq!(changes[0].method(), "set_folio_grant");
971+ assert_eq!(changes[1].method(), "set_folio_general_access");
972+ assert_eq!(
973+ access_changes(&json!({ "role": "none" }), Some("team:design".into())).unwrap(),
974+ vec![FolioAccessChange::Revoke { principal: "team:design".into() }]
975+ );
976+ assert_eq!(
977+ access_changes(&json!({ "general_access": "none", "general_role": "edit" }), None).unwrap(),
978+ vec![FolioAccessChange::General { access: GeneralAccess::None, role: None }]
979+ );
980+ // General access never gives full access; that is shared by name.
981+ assert_eq!(
982+ access_changes(&json!({ "general_access": "link", "general_role": "manage" }), None),
983+ Err("General access gives view, comment or edit, never full access.".to_owned())
984+ );
985+ assert!(access_changes(&json!({ "role": "edit" }), None).unwrap_err().contains("who"));
986+ assert!(access_changes(&json!({}), Some("user:usr_2".into())).unwrap_err().contains("role"));
987+ assert!(access_changes(&json!({}), None).unwrap_err().starts_with("Say what to change"));
988+ assert!(access_changes(&json!({ "role": "owner" }), Some("user:usr_2".into())).unwrap_err().contains("owner"));
989+ assert!(access_changes(&json!({ "role": "view" }), Some("someone".into())).unwrap_err().contains("user:, agent: or team:"));
990+ }
991+
992+ #[test]
993+ fn a_workspace_token_is_refused_and_nobody_is_asked_to_sign_in() {
994+ let workspace = User { id: "ws_1".into(), username: "acme".into(), kind: PrincipalKind::Workspace, ..User::default() };
995+ let Err(Outcome::Fail(refused)) = person(&Some(workspace)) else { panic!("a workspace token") };
996+ assert_eq!(refused.code, FailureCode::Forbidden);
997+ let Err(Outcome::Fail(refused)) = person(&None) else { panic!("nobody") };
998+ assert_eq!(refused.code, FailureCode::Unauthenticated);
999+ assert!(person(&Some(User { id: "usr_1".into(), username: "ana".into(), ..User::default() })).is_ok());
1000+ }
1001+
1002+ #[test]
1003+ fn answers_are_snake_case_and_name_people_the_api_way() {
1004+ let site = "https://g1t.sh";
1005+ let access = access_json(&json!({
1006+ "folio_id": "fol_1",
1007+ "owner": { "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana" },
1008+ "rows": [
1009+ { "principal": "team:design", "profile": { "kind": "team", "id": "design", "name": "design", "display_name": "@acme/design" }, "role": "edit", "source": { "kind": "folio", "id": "fol_0", "title": "Plans", "path": "/acme/-/artifacts/plans-fol_0" } },
1010+ { "principal": "user:usr_2", "profile": { "kind": "user", "id": "usr_2", "name": "bo", "display_name": "Bo" }, "role": "view", "source": { "kind": "grant" } },
1011+ ],
1012+ "general_access": "none",
1013+ "general_role": null,
1014+ "inherit": true,
1015+ "inherited_from": { "kind": "folio", "id": "fol_0", "name": "Plans" },
1016+ "agent_mode": null,
1017+ "can_share": true,
1018+ "public_link": "off",
1019+ }));
1020+ assert_eq!(access["shared_with"][0]["member"], json!({ "type": "team", "slug": "design", "display_name": "@acme/design" }));
1021+ assert_eq!(access["shared_with"][0]["inherited_from"]["artifact_id"], "fol_0");
1022+ assert_eq!(access["shared_with"][1]["inherited_from"], Value::Null);
1023+ assert_eq!(access["inherited_from"]["type"], "artifact");
1024+ assert!(access.get("public_link").is_none());
1025+ let content = content_json(
1026+ &json!({ "folio": { "id": "fol_1", "kind": "doc", "title": "T", "icon": null, "slug": "t-fol_1", "path": "/acme/-/artifacts/t-fol_1", "edited_at": "2026-10-02T00:00:00.000Z" }, "space": null, "content": "# T", "blocks": [{ "id": "b1", "type": "heading", "level": 1, "markdown": "# T" }], "can": { "read": true, "suggest": true, "edit": true }, "audience_can_read": true }),
1027+ site,
1028+ );
1029+ assert_eq!(content["artifact"]["html_url"], "https://g1t.sh/acme/-/artifacts/t-fol_1");
1030+ assert!(content.get("audience_can_read").is_none());
1031+ let edit = edit_json(&json!({ "mode": "applied", "version_id": "ver_1", "folio": { "id": "fol_1", "path": "/acme/-/artifacts/t-fol_1" }, "summary": "Added a section." }), site);
1032+ assert_eq!(edit["version_id"], "ver_1");
1033+ for shown in [access, content, edit, version_json(&json!({ "id": "ver_1", "folio_id": "fol_1", "authors": [{ "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana" }] }))] {
1034+ assert!(g1t_kit::wire::camel_case_keys(&shown).is_empty(), "{shown}");
1035+ }
1036+ }
1037+
1038+ #[test]
1039+ fn each_operation_is_described_with_a_schema_and_a_scope() {
1040+ use g1t_contracts::scopes::{Level, Scope, scope_for};
1041+ for op in FoliosOp::ALL {
1042+ assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Folios(op)), "{}", op.name());
1043+ assert!(!op.title().is_empty() && op.description().len() > 80, "{}", op.name());
1044+ assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name());
1045+ let scope = scope_for(op.name()).unwrap();
1046+ assert_eq!(scope.resource(), g1t_contracts::scopes::Resource::Artifacts, "{}", op.name());
1047+ assert_eq!(op.writes(), scope.level() != Level::Read, "{}", op.name());
1048+ }
1049+ // Sharing and deleting for good are the admin scope's alone.
1050+ let admin: Vec<FoliosOp> = FoliosOp::ALL.into_iter().filter(|op| scope_for(op.name()) == Some(Scope::ArtifactsAdmin)).collect();
1051+ assert_eq!(admin, vec![FoliosOp::SetAccess, FoliosOp::Purge]);
1052+ }
1053+}
+1−0
2222 mod oauth;
2323 mod oidc;
2424 mod packages;
25+mod folios;
2526 mod people;
2627 mod openapi;
2728 mod pins;
+25−0
1313 use crate::mirrors::MirrorsOp;
1414 use crate::deployments::DeploymentsOp;
1515 use crate::packages::PackagesOp;
16+use crate::folios::FoliosOp;
1617 use crate::protection::ProtectionOp;
1718 use crate::token_policy::TokenOp;
1819 use crate::operations::Op;
536537 Op::ImportIssue,
537538 ],
538539 ),
540+ (
541+ "Artifacts",
542+ "A workspace's docs, slides, designs and dashboards (Artifacts mode): listing and searching the ones you can open, reading and editing their content in Markdown, making, moving, trashing and restoring them, their versions, and who can open them. Each answers for your own role on it. Not workflow runs' artifacts, which are under Actions. Slides, designs and dashboards answer that they are not here yet.",
543+ &[
544+ Op::Folios(FoliosOp::List),
545+ Op::Folios(FoliosOp::Search),
546+ Op::Folios(FoliosOp::Get),
547+ Op::Folios(FoliosOp::GetContent),
548+ Op::Folios(FoliosOp::ListVersions),
549+ Op::Folios(FoliosOp::GetAccess),
550+ Op::Folios(FoliosOp::ListTemplates),
551+ Op::Folios(FoliosOp::ListSpaces),
552+ Op::Folios(FoliosOp::QueryDataset),
553+ Op::Folios(FoliosOp::Create),
554+ Op::Folios(FoliosOp::Update),
555+ Op::Folios(FoliosOp::Edit),
556+ Op::Folios(FoliosOp::Trash),
557+ Op::Folios(FoliosOp::Restore),
558+ Op::Folios(FoliosOp::RestoreVersion),
559+ Op::Folios(FoliosOp::SetAccess),
560+ Op::Folios(FoliosOp::Purge),
561+ ],
562+ ),
539563 ];
540564
541565 /// The section of the API reference an operation is listed under.
752776 Op::DeployKeys(op) => op.title(),
753777 Op::Mirrors(op) => op.title(),
754778 Op::Packages(op) => op.title(),
779+ Op::Folios(op) => op.title(),
755780 }
756781 }
757782
+34−1
3333 use crate::mirrors::MirrorsOp;
3434 use crate::deployments::DeploymentsOp;
3535 use crate::packages::PackagesOp;
36+use crate::folios::FoliosOp;
3637 use crate::protection::ProtectionOp;
3738 use crate::token_policy::TokenOp;
3839 use crate::rules::RulesOp;
6869 pub deployments: Fetcher,
6970 /// Packages: their settings, versions, deleting and restoring them.
7071 pub packages: Fetcher,
72+ /// The docs service: Artifacts mode's docs, slides, designs and
73+ /// dashboards (folios), for the artifact routes and tool.
74+ pub docs: Fetcher,
7175 /// Where the request came in, for its audit entries.
7276 pub audit: crate::audit::AuditContext,
7377 /// Set for a request made with an agent's token: all it may do.
9498 projects: env.service("PROJECTS")?,
9599 deployments: env.service("DEPLOYMENTS")?,
96100 packages: env.service("PACKAGES")?,
101+ docs: env.service("DOCS")?,
97102 scope: None,
98103 audit: crate::audit::AuditContext::default(),
99104 addresses: crate::addresses::Addresses::from_env(env),
321326 /// A workspace's packages, their versions, deleting and restoring
322327 /// them, and who may use them: packages.rs.
323328 Packages(PackagesOp),
329+ /// Artifacts mode's docs, slides, designs and dashboards, kept by the
330+ /// docs service: folios.rs.
331+ Folios(FoliosOp),
324332 }
325333
326334 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
690698 }
691699
692700 impl Op {
693− pub const ALL: [Op; 328] = [
701+ pub const ALL: [Op; 345] = [
694702 Op::Whoami,
695703 Op::GetWorkspace,
696704 Op::CreateWorkspace,
10191027 Op::Packages(PackagesOp::RestorePackage),
10201028 Op::Packages(PackagesOp::DeleteVersion),
10211029 Op::Packages(PackagesOp::RestoreVersion),
1030+ Op::Folios(FoliosOp::List),
1031+ Op::Folios(FoliosOp::Search),
1032+ Op::Folios(FoliosOp::Get),
1033+ Op::Folios(FoliosOp::GetContent),
1034+ Op::Folios(FoliosOp::ListVersions),
1035+ Op::Folios(FoliosOp::GetAccess),
1036+ Op::Folios(FoliosOp::ListTemplates),
1037+ Op::Folios(FoliosOp::ListSpaces),
1038+ Op::Folios(FoliosOp::QueryDataset),
1039+ Op::Folios(FoliosOp::Create),
1040+ Op::Folios(FoliosOp::Update),
1041+ Op::Folios(FoliosOp::Edit),
1042+ Op::Folios(FoliosOp::Trash),
1043+ Op::Folios(FoliosOp::Restore),
1044+ Op::Folios(FoliosOp::RestoreVersion),
1045+ Op::Folios(FoliosOp::SetAccess),
1046+ Op::Folios(FoliosOp::Purge),
10221047 ];
10231048
10241049 pub fn by_name(name: &str) -> Option<Op> {
12311256 Op::DeployKeys(op) => op.name(),
12321257 Op::Mirrors(op) => op.name(),
12331258 Op::Packages(op) => op.name(),
1259+ Op::Folios(op) => op.name(),
12341260 }
12351261 }
12361262
17871813 Op::DeployKeys(op) => op.description(),
17881814 Op::Mirrors(op) => op.description(),
17891815 Op::Packages(op) => op.description(),
1816+ Op::Folios(op) => op.description(),
17901817 }
17911818 }
17921819
32723299 Op::DeployKeys(op) => op.input(),
32733300 Op::Mirrors(op) => op.input(),
32743301 Op::Packages(op) => op.input(),
3302+ Op::Folios(op) => op.input(),
32753303 }
32763304 }
32773305
33463374 if let Op::Packages(_) = self {
33473375 return false;
33483376 }
3377+ // An artifact belongs to its workspace.
3378+ if let Op::Folios(_) = self {
3379+ return false;
3380+ }
33493381 if let Op::About(op) = self {
33503382 return op.needs_repo();
33513383 }
55925624 Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await,
55935625 Op::Mirrors(op) => crate::mirrors::run(op, services, viewer, input).await,
55945626 Op::Packages(op) => crate::packages::run(op, services, viewer, input).await,
5627+ Op::Folios(op) => crate::folios::run(op, services, viewer, input).await,
55955628 Op::ReopenSecurityAlert => {
55965629 let changed: Outcome<AlertChange> = call(
55975630 &services.security,
+680−0
1259312593 "id": "rmt_01kp4b3c4d5e6f7g8h9j0k1m2n"
1259412594 },
1259512595 "response": true
12596+ },
12597+ "list_workspace_artifacts": {
12598+ "params": {
12599+ "workspace": "acme"
12600+ },
12601+ "query": {
12602+ "kind": "doc",
12603+ "space": "general"
12604+ },
12605+ "response": {
12606+ "items": [
12607+ {
12608+ "id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
12609+ "kind": "doc",
12610+ "title": "Q4 roadmap",
12611+ "icon": "🗺️",
12612+ "slug": "q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
12613+ "space": {
12614+ "id": "spc_01kq1a3c5e7g9j1l3n5q7s9u1w",
12615+ "slug": "general",
12616+ "name": "General",
12617+ "kind": "workspace"
12618+ },
12619+ "parent_id": null,
12620+ "has_children": false,
12621+ "owner": {
12622+ "type": "user",
12623+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
12624+ "username": "ana",
12625+ "display_name": "Ana Lima"
12626+ },
12627+ "created_by": {
12628+ "type": "user",
12629+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
12630+ "username": "ana",
12631+ "display_name": "Ana Lima"
12632+ },
12633+ "created_at": "2026-10-06T09:12:00.000Z",
12634+ "updated_at": "2026-10-08T16:40:12.000Z",
12635+ "edited_by": {
12636+ "type": "user",
12637+ "id": "usr_01kmb2d4f6h8k0m2p4r6t8v0x2",
12638+ "username": "bo",
12639+ "display_name": "Bo Chen"
12640+ },
12641+ "edited_at": "2026-10-08T16:40:12.000Z",
12642+ "trashed_at": null,
12643+ "viewer_role": "manage",
12644+ "private": false,
12645+ "shared_count": 1,
12646+ "general_access": "workspace",
12647+ "general_role": "view",
12648+ "inherit": true,
12649+ "agent_mode": "suggest",
12650+ "excerpt": "What we ship this quarter, and what we leave for next.",
12651+ "source": null,
12652+ "stale": false,
12653+ "html_url": "https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z"
12654+ }
12655+ ],
12656+ "next_cursor": null
12657+ },
12658+ "notes": "Only artifacts you can open are listed, with `viewer_role` your own role on each. Page on with `cursor` set to `next_cursor` until it is null. With `state=trashed`, what you can restore from the trash. With `q`, the best matches first. These are Artifacts mode's docs, slides, designs and dashboards, not workflow runs' artifacts (`list_artifacts`)."
12659+ },
12660+ "search_workspace_artifacts": {
12661+ "params": {
12662+ "workspace": "acme"
12663+ },
12664+ "query": {
12665+ "q": "what ships in Q4"
12666+ },
12667+ "response": [
12668+ {
12669+ "id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
12670+ "kind": "doc",
12671+ "title": "Q4 roadmap",
12672+ "icon": "🗺️",
12673+ "slug": "q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
12674+ "html_url": "https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
12675+ "space_name": "General",
12676+ "snippet": "We ship the merge queue and Artifacts in Q4; dashboards follow.",
12677+ "heading": "This quarter",
12678+ "matched": "both",
12679+ "edited_at": "2026-10-08T16:40:12.000Z"
12680+ }
12681+ ],
12682+ "notes": "Matches words and meaning, and is checked again against who can read each artifact now: one shared with you a minute ago is found, one taken away is not."
12683+ },
12684+ "get_workspace_artifact": {
12685+ "params": {
12686+ "workspace": "acme",
12687+ "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z"
12688+ },
12689+ "response": {
12690+ "id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
12691+ "kind": "doc",
12692+ "title": "Q4 roadmap",
12693+ "icon": "🗺️",
12694+ "slug": "q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
12695+ "space": {
12696+ "id": "spc_01kq1a3c5e7g9j1l3n5q7s9u1w",
12697+ "slug": "general",
12698+ "name": "General",
12699+ "kind": "workspace"
12700+ },
12701+ "parent_id": null,
12702+ "has_children": false,
12703+ "owner": {
12704+ "type": "user",
12705+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
12706+ "username": "ana",
12707+ "display_name": "Ana Lima"
12708+ },
12709+ "created_by": {
12710+ "type": "user",
12711+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
12712+ "username": "ana",
12713+ "display_name": "Ana Lima"
12714+ },
12715+ "created_at": "2026-10-06T09:12:00.000Z",
12716+ "updated_at": "2026-10-08T16:40:12.000Z",
12717+ "edited_by": {
12718+ "type": "user",
12719+ "id": "usr_01kmb2d4f6h8k0m2p4r6t8v0x2",
12720+ "username": "bo",
12721+ "display_name": "Bo Chen"
12722+ },
12723+ "edited_at": "2026-10-08T16:40:12.000Z",
12724+ "trashed_at": null,
12725+ "viewer_role": "manage",
12726+ "private": false,
12727+ "shared_count": 1,
12728+ "general_access": "workspace",
12729+ "general_role": "view",
12730+ "inherit": true,
12731+ "agent_mode": "suggest",
12732+ "excerpt": "What we ship this quarter, and what we leave for next.",
12733+ "source": null,
12734+ "stale": false,
12735+ "html_url": "https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z"
12736+ },
12737+ "notes": "`artifact_id` is the id, or the artifact's address (`q4-roadmap-fol_…`, or its whole link). Not found when you cannot open it, the same as when it does not exist."
12738+ },
12739+ "get_workspace_artifact_content": {
12740+ "params": {
12741+ "workspace": "acme",
12742+ "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z"
12743+ },
12744+ "response": {
12745+ "artifact": {
12746+ "id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
12747+ "kind": "doc",
12748+ "title": "Q4 roadmap",
12749+ "icon": "🗺️",
12750+ "slug": "q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
12751+ "html_url": "https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
12752+ "edited_at": "2026-10-08T16:40:12.000Z"
12753+ },
12754+ "space": {
12755+ "id": "spc_01kq1a3c5e7g9j1l3n5q7s9u1w",
12756+ "slug": "general",
12757+ "name": "General",
12758+ "agent_mode": "suggest"
12759+ },
12760+ "content": "# Q4 roadmap\n\n## This quarter\n\nWe ship the merge queue and Artifacts.\n",
12761+ "blocks": [
12762+ {
12763+ "id": "b_4f1c",
12764+ "type": "heading",
12765+ "level": 1,
12766+ "markdown": "# Q4 roadmap"
12767+ },
12768+ {
12769+ "id": "b_9a02",
12770+ "type": "heading",
12771+ "level": 2,
12772+ "markdown": "## This quarter"
12773+ },
12774+ {
12775+ "id": "b_77d3",
12776+ "type": "paragraph",
12777+ "level": null,
12778+ "markdown": "We ship the merge queue and Artifacts."
12779+ }
12780+ ],
12781+ "can": {
12782+ "read": true,
12783+ "suggest": true,
12784+ "edit": true
12785+ }
12786+ },
12787+ "notes": "A doc's content is Markdown. `blocks` are its top-level blocks, whose ids an edit with a `blocks` target names. Slides, designs and dashboards answer `422` saying they are not here yet."
12788+ },
12789+ "list_workspace_artifact_versions": {
12790+ "params": {
12791+ "workspace": "acme",
12792+ "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z"
12793+ },
12794+ "response": [
12795+ {
12796+ "id": "ver_01kq9e6g8j0m2p4r6t8v0x2z4b",
12797+ "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
12798+ "created_at": "2026-10-08T16:40:12.000Z",
12799+ "kind": "edit",
12800+ "note": null,
12801+ "authors": [
12802+ {
12803+ "type": "user",
12804+ "id": "usr_01kmb2d4f6h8k0m2p4r6t8v0x2",
12805+ "username": "bo",
12806+ "display_name": "Bo Chen"
12807+ }
12808+ ]
12809+ },
12810+ {
12811+ "id": "ver_01kq7c4e6g8j0m2p4r6t8v0x2y",
12812+ "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
12813+ "created_at": "2026-10-06T09:12:00.000Z",
12814+ "kind": "created",
12815+ "note": null,
12816+ "authors": [
12817+ {
12818+ "type": "user",
12819+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
12820+ "username": "ana",
12821+ "display_name": "Ana Lima"
12822+ }
12823+ ]
12824+ }
12825+ ]
12826+ },
12827+ "get_workspace_artifact_access": {
12828+ "params": {
12829+ "workspace": "acme",
12830+ "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z"
12831+ },
12832+ "response": {
12833+ "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
12834+ "owner": {
12835+ "type": "user",
12836+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
12837+ "username": "ana",
12838+ "display_name": "Ana Lima"
12839+ },
12840+ "shared_with": [
12841+ {
12842+ "principal": "user:usr_01kmb2d4f6h8k0m2p4r6t8v0x2",
12843+ "member": {
12844+ "type": "user",
12845+ "id": "usr_01kmb2d4f6h8k0m2p4r6t8v0x2",
12846+ "username": "bo",
12847+ "display_name": "Bo Chen"
12848+ },
12849+ "role": "edit",
12850+ "inherited_from": null
12851+ },
12852+ {
12853+ "principal": "team:design",
12854+ "member": {
12855+ "type": "team",
12856+ "slug": "design",
12857+ "display_name": "@acme/design"
12858+ },
12859+ "role": "comment",
12860+ "inherited_from": null
12861+ }
12862+ ],
12863+ "general_access": "workspace",
12864+ "general_role": "view",
12865+ "inherit": true,
12866+ "inherited_from": {
12867+ "type": "space",
12868+ "id": "spc_01kq1a3c5e7g9j1l3n5q7s9u1w",
12869+ "name": "General"
12870+ },
12871+ "agent_mode": null,
12872+ "can_share": true
12873+ },
12874+ "notes": "Its owner always has full access and is not in `shared_with`. `inherited_from` on a row is the doc above it that gives that access; on the whole list, the space or doc it follows while `inherit` is true."
12875+ },
12876+ "list_workspace_artifact_templates": {
12877+ "params": {
12878+ "workspace": "acme"
12879+ },
12880+ "query": {
12881+ "kind": "doc"
12882+ },
12883+ "response": [
12884+ {
12885+ "id": "builtin:doc:meeting-notes",
12886+ "kind": "doc",
12887+ "name": "Meeting notes",
12888+ "description": "Who came, what was decided, and what happens next.",
12889+ "icon": "📝",
12890+ "builtin": true,
12891+ "body": "# Meeting notes\n\n## Attendees\n\n## Decisions\n\n## Next steps\n"
12892+ }
12893+ ]
12894+ },
12895+ "list_workspace_artifact_spaces": {
12896+ "params": {
12897+ "workspace": "acme"
12898+ },
12899+ "response": [
12900+ {
12901+ "id": "spc_01kq1a3c5e7g9j1l3n5q7s9u1w",
12902+ "slug": "general",
12903+ "name": "General",
12904+ "description": null,
12905+ "icon": null,
12906+ "kind": "workspace",
12907+ "is_default": true,
12908+ "joined": true,
12909+ "viewer_role": "edit",
12910+ "artifact_count": 14
12911+ },
12912+ {
12913+ "id": "spc_01kq2b4d6f8h0k2m4p6r8t0v2x",
12914+ "slug": "design",
12915+ "name": "Design",
12916+ "description": "The design team's specs.",
12917+ "icon": "🎨",
12918+ "kind": "team",
12919+ "is_default": false,
12920+ "joined": true,
12921+ "viewer_role": "view",
12922+ "artifact_count": 6
12923+ }
12924+ ],
12925+ "notes": "Open spaces show once you join them in Artifacts; General always shows. Give a space's `slug` or `id` as `space` to the other artifact calls."
12926+ },
12927+ "query_workspace_dataset": {
12928+ "params": {
12929+ "workspace": "acme"
12930+ },
12931+ "request": {
12932+ "query": {
12933+ "dataset": "pull_requests",
12934+ "measure": {
12935+ "op": "count"
12936+ },
12937+ "group_by": "repo",
12938+ "range": {
12939+ "from": "2026-09-01",
12940+ "to": "2026-09-30"
12941+ }
12942+ }
12943+ },
12944+ "response": {
12945+ "columns": [
12946+ {
12947+ "name": "repo",
12948+ "type": "string"
12949+ },
12950+ {
12951+ "name": "count",
12952+ "type": "number"
12953+ }
12954+ ],
12955+ "rows": [
12956+ [
12957+ "acme/web",
12958+ 42
12959+ ],
12960+ [
12961+ "acme/api",
12962+ 7
12963+ ]
12964+ ],
12965+ "truncated": false,
12966+ "partial": false,
12967+ "as_of": "2026-10-09T12:00:00.000Z"
12968+ },
12969+ "notes": "Runs as you, over what you can read: `partial` is true when some of it was left out because you cannot read it. Until dashboards ship this answers `422` saying they are not here yet."
12970+ },
12971+ "create_workspace_artifact": {
12972+ "params": {
12973+ "workspace": "acme"
12974+ },
12975+ "request": {
12976+ "kind": "doc",
12977+ "title": "Release notes",
12978+ "markdown": "# Release notes\n\nWhat changed in 2.4.\n"
12979+ },
12980+ "response": {
12981+ "id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a",
12982+ "kind": "doc",
12983+ "title": "Release notes",
12984+ "icon": null,
12985+ "slug": "release-notes-fol_01kq8d5f7h9k1n3q5s7u9w1y3a",
12986+ "space": null,
12987+ "parent_id": null,
12988+ "has_children": false,
12989+ "owner": {
12990+ "type": "user",
12991+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
12992+ "username": "ana",
12993+ "display_name": "Ana Lima"
12994+ },
12995+ "created_by": {
12996+ "type": "user",
12997+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
12998+ "username": "ana",
12999+ "display_name": "Ana Lima"
13000+ },
13001+ "created_at": "2026-10-09T10:00:00.000Z",
13002+ "updated_at": "2026-10-09T10:00:00.000Z",
13003+ "edited_by": null,
13004+ "edited_at": "2026-10-09T10:00:00.000Z",
13005+ "trashed_at": null,
13006+ "viewer_role": "manage",
13007+ "private": true,
13008+ "shared_count": 0,
13009+ "general_access": "none",
13010+ "general_role": null,
13011+ "inherit": true,
13012+ "agent_mode": null,
13013+ "excerpt": "What changed in 2.4.",
13014+ "source": null,
13015+ "stale": false,
13016+ "html_url": "https://g1t.sh/acme/-/artifacts/release-notes-fol_01kq8d5f7h9k1n3q5s7u9w1y3a"
13017+ },
13018+ "notes": "Left out `space` and `parent_id`, it lands in your Private, where only you can open it until you share it. `kind` is `doc` until slides, designs and dashboards ship; they answer `422` saying they are not here yet."
13019+ },
13020+ "update_workspace_artifact": {
13021+ "params": {
13022+ "workspace": "acme",
13023+ "artifact_id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a"
13024+ },
13025+ "request": {
13026+ "title": "Release notes 2.4",
13027+ "space": "general"
13028+ },
13029+ "response": {
13030+ "id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a",
13031+ "kind": "doc",
13032+ "title": "Release notes 2.4",
13033+ "icon": null,
13034+ "slug": "release-notes-2-4-fol_01kq8d5f7h9k1n3q5s7u9w1y3a",
13035+ "space": {
13036+ "id": "spc_01kq1a3c5e7g9j1l3n5q7s9u1w",
13037+ "slug": "general",
13038+ "name": "General",
13039+ "kind": "workspace"
13040+ },
13041+ "parent_id": null,
13042+ "has_children": false,
13043+ "owner": {
13044+ "type": "user",
13045+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
13046+ "username": "ana",
13047+ "display_name": "Ana Lima"
13048+ },
13049+ "created_by": {
13050+ "type": "user",
13051+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
13052+ "username": "ana",
13053+ "display_name": "Ana Lima"
13054+ },
13055+ "created_at": "2026-10-09T10:00:00.000Z",
13056+ "updated_at": "2026-10-09T10:00:00.000Z",
13057+ "edited_by": null,
13058+ "edited_at": "2026-10-09T10:00:00.000Z",
13059+ "trashed_at": null,
13060+ "viewer_role": "manage",
13061+ "private": false,
13062+ "shared_count": 0,
13063+ "general_access": "none",
13064+ "general_role": null,
13065+ "inherit": true,
13066+ "agent_mode": null,
13067+ "excerpt": "What changed in 2.4.",
13068+ "source": null,
13069+ "stale": false,
13070+ "html_url": "https://g1t.sh/acme/-/artifacts/release-notes-2-4-fol_01kq8d5f7h9k1n3q5s7u9w1y3a"
13071+ },
13072+ "notes": "Moving it into a space it follows (`inherit`) gives the space's members its access. `space` set to `private` moves it to the top of your Private, which only its owner can do."
13073+ },
13074+ "edit_workspace_artifact": {
13075+ "params": {
13076+ "workspace": "acme",
13077+ "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z"
13078+ },
13079+ "request": {
13080+ "target": {
13081+ "kind": "section",
13082+ "heading": "This quarter"
13083+ },
13084+ "markdown": "## This quarter\n\nWe ship the merge queue, Artifacts and the API for both.\n",
13085+ "note": "Adds the API"
13086+ },
13087+ "response": {
13088+ "mode": "applied",
13089+ "artifact": {
13090+ "id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
13091+ "kind": "doc",
13092+ "title": "Q4 roadmap",
13093+ "icon": "🗺️",
13094+ "slug": "q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
13095+ "html_url": "https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z"
13096+ },
13097+ "version_id": "ver_01kqa7h9k1n3q5s7u9w1y3a5c",
13098+ "summary": "Replaced the section This quarter."
13099+ },
13100+ "notes": "With only the comment role, or with `suggest_only`, the change is filed as a suggestion instead: `mode` is `suggested`, with the `suggestion` its editors accept or reject. A target that is not there any more answers `404`: read the content again."
13101+ },
13102+ "trash_workspace_artifact": {
13103+ "params": {
13104+ "workspace": "acme",
13105+ "artifact_id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a"
13106+ },
13107+ "response": {
13108+ "id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a",
13109+ "kind": "doc",
13110+ "title": "Release notes",
13111+ "icon": null,
13112+ "slug": "release-notes-fol_01kq8d5f7h9k1n3q5s7u9w1y3a",
13113+ "space": null,
13114+ "parent_id": null,
13115+ "has_children": false,
13116+ "owner": {
13117+ "type": "user",
13118+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
13119+ "username": "ana",
13120+ "display_name": "Ana Lima"
13121+ },
13122+ "created_by": {
13123+ "type": "user",
13124+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
13125+ "username": "ana",
13126+ "display_name": "Ana Lima"
13127+ },
13128+ "created_at": "2026-10-09T10:00:00.000Z",
13129+ "updated_at": "2026-10-09T10:00:00.000Z",
13130+ "edited_by": null,
13131+ "edited_at": "2026-10-09T10:00:00.000Z",
13132+ "trashed_at": "2026-10-09T11:30:00.000Z",
13133+ "viewer_role": "manage",
13134+ "private": true,
13135+ "shared_count": 0,
13136+ "general_access": "none",
13137+ "general_role": null,
13138+ "inherit": true,
13139+ "agent_mode": null,
13140+ "excerpt": "What changed in 2.4.",
13141+ "source": null,
13142+ "stale": false,
13143+ "html_url": "https://g1t.sh/acme/-/artifacts/release-notes-fol_01kq8d5f7h9k1n3q5s7u9w1y3a"
13144+ },
13145+ "notes": "Everything under it goes to the trash with it. It is deleted for good 30 days later, or with `purge_workspace_artifact`."
13146+ },
13147+ "restore_workspace_artifact": {
13148+ "params": {
13149+ "workspace": "acme",
13150+ "artifact_id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a"
13151+ },
13152+ "response": {
13153+ "id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a",
13154+ "kind": "doc",
13155+ "title": "Release notes",
13156+ "icon": null,
13157+ "slug": "release-notes-fol_01kq8d5f7h9k1n3q5s7u9w1y3a",
13158+ "space": null,
13159+ "parent_id": null,
13160+ "has_children": false,
13161+ "owner": {
13162+ "type": "user",
13163+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
13164+ "username": "ana",
13165+ "display_name": "Ana Lima"
13166+ },
13167+ "created_by": {
13168+ "type": "user",
13169+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
13170+ "username": "ana",
13171+ "display_name": "Ana Lima"
13172+ },
13173+ "created_at": "2026-10-09T10:00:00.000Z",
13174+ "updated_at": "2026-10-09T10:00:00.000Z",
13175+ "edited_by": null,
13176+ "edited_at": "2026-10-09T10:00:00.000Z",
13177+ "trashed_at": null,
13178+ "viewer_role": "manage",
13179+ "private": true,
13180+ "shared_count": 0,
13181+ "general_access": "none",
13182+ "general_role": null,
13183+ "inherit": true,
13184+ "agent_mode": null,
13185+ "excerpt": "What changed in 2.4.",
13186+ "source": null,
13187+ "stale": false,
13188+ "html_url": "https://g1t.sh/acme/-/artifacts/release-notes-fol_01kq8d5f7h9k1n3q5s7u9w1y3a"
13189+ }
13190+ },
13191+ "restore_workspace_artifact_version": {
13192+ "params": {
13193+ "workspace": "acme",
13194+ "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
13195+ "version_id": "ver_01kq7c4e6g8j0m2p4r6t8v0x2y"
13196+ },
13197+ "response": {
13198+ "id": "ver_01kqb8j0m2p4r6t8v0x2z4b6d",
13199+ "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
13200+ "created_at": "2026-10-09T12:00:00.000Z",
13201+ "kind": "restore",
13202+ "note": null,
13203+ "authors": [
13204+ {
13205+ "type": "user",
13206+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
13207+ "username": "ana",
13208+ "display_name": "Ana Lima"
13209+ }
13210+ ]
13211+ }
13212+ },
13213+ "set_workspace_artifact_access": {
13214+ "params": {
13215+ "workspace": "acme",
13216+ "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z"
13217+ },
13218+ "request": {
13219+ "username": "bo",
13220+ "role": "edit",
13221+ "notify": "Can you fill in the dates?"
13222+ },
13223+ "response": {
13224+ "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
13225+ "owner": {
13226+ "type": "user",
13227+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
13228+ "username": "ana",
13229+ "display_name": "Ana Lima"
13230+ },
13231+ "shared_with": [
13232+ {
13233+ "principal": "user:usr_01kmb2d4f6h8k0m2p4r6t8v0x2",
13234+ "member": {
13235+ "type": "user",
13236+ "id": "usr_01kmb2d4f6h8k0m2p4r6t8v0x2",
13237+ "username": "bo",
13238+ "display_name": "Bo Chen"
13239+ },
13240+ "role": "edit",
13241+ "inherited_from": null
13242+ },
13243+ {
13244+ "principal": "team:design",
13245+ "member": {
13246+ "type": "team",
13247+ "slug": "design",
13248+ "display_name": "@acme/design"
13249+ },
13250+ "role": "comment",
13251+ "inherited_from": null
13252+ }
13253+ ],
13254+ "general_access": "workspace",
13255+ "general_role": "view",
13256+ "inherit": true,
13257+ "inherited_from": {
13258+ "type": "space",
13259+ "id": "spc_01kq1a3c5e7g9j1l3n5q7s9u1w",
13260+ "name": "General"
13261+ },
13262+ "agent_mode": null,
13263+ "can_share": true
13264+ },
13265+ "notes": "One call can share with one person, team or agent, and change `general_access`, `inherit` and `agent_mode`, in that order. `role` set to `none` takes someone's access away. General access never gives full access. On an artifact that follows the doc it is under, change general access on that doc, or set `inherit` to false first."
13266+ },
13267+ "purge_workspace_artifact": {
13268+ "params": {
13269+ "workspace": "acme",
13270+ "artifact_id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a"
13271+ },
13272+ "response": {
13273+ "deleted": true
13274+ },
13275+ "notes": "Only an artifact in the trash can be deleted for good: `trash_workspace_artifact` first."
1259613276 }
1259713277 }
+2−0
127127 Op::DeployKeys(DeployKeysOp::DeleteDeployKey) => return through::<bool>(op, as_is),
128128 // Packages are shaped by the API itself, in `snake_case`.
129129 Op::Packages(_) => return as_is,
130+ // Artifacts are shaped by the API itself, in `snake_case`.
131+ Op::Folios(_) => return as_is,
130132 // Built by the API itself, in `snake_case`.
131133 Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is),
132134 Op::DismissSecurityAlert | Op::ReopenSecurityAlert => {
+20−0
88 use crate::mirrors::MirrorsOp;
99 use crate::deployments::DeploymentsOp;
1010 use crate::packages::PackagesOp;
11+use crate::folios::FoliosOp;
1112 use crate::protection::ProtectionOp;
1213 use crate::token_policy::TokenOp;
1314 use crate::operations::Op;
961962 Op::MergePullRequest,
962963 &[],
963964 ),
965+ // Artifacts mode's docs, slides, designs and dashboards. Search comes
966+ // before an artifact by id, which it would otherwise match.
967+ route("GET", "/workspaces/:workspace/artifacts", Op::Folios(FoliosOp::List), &[("tab", "tab"), ("kind", "kind"), ("space", "space"), ("project", "project"), ("q", "q"), ("state", "state"), ("cursor", "cursor"), ("limit", "limit")]),
968+ route("POST", "/workspaces/:workspace/artifacts", Op::Folios(FoliosOp::Create), &[]),
969+ route("GET", "/workspaces/:workspace/artifacts/search", Op::Folios(FoliosOp::Search), &[("q", "q"), ("kind", "kind"), ("space", "space"), ("project", "project"), ("limit", "limit")]),
970+ route("GET", "/workspaces/:workspace/artifacts/:artifact_id", Op::Folios(FoliosOp::Get), &[]),
971+ route("PATCH", "/workspaces/:workspace/artifacts/:artifact_id", Op::Folios(FoliosOp::Update), &[]),
972+ route("DELETE", "/workspaces/:workspace/artifacts/:artifact_id", Op::Folios(FoliosOp::Trash), &[]),
973+ route("POST", "/workspaces/:workspace/artifacts/:artifact_id/restore", Op::Folios(FoliosOp::Restore), &[]),
974+ route("POST", "/workspaces/:workspace/artifacts/:artifact_id/purge", Op::Folios(FoliosOp::Purge), &[]),
975+ route("GET", "/workspaces/:workspace/artifacts/:artifact_id/content", Op::Folios(FoliosOp::GetContent), &[]),
976+ route("PUT", "/workspaces/:workspace/artifacts/:artifact_id/content", Op::Folios(FoliosOp::Edit), &[]),
977+ route("GET", "/workspaces/:workspace/artifacts/:artifact_id/access", Op::Folios(FoliosOp::GetAccess), &[]),
978+ route("PUT", "/workspaces/:workspace/artifacts/:artifact_id/access", Op::Folios(FoliosOp::SetAccess), &[]),
979+ route("GET", "/workspaces/:workspace/artifacts/:artifact_id/versions", Op::Folios(FoliosOp::ListVersions), &[]),
980+ route("POST", "/workspaces/:workspace/artifacts/:artifact_id/versions/:version_id/restore", Op::Folios(FoliosOp::RestoreVersion), &[]),
981+ route("GET", "/workspaces/:workspace/artifact-templates", Op::Folios(FoliosOp::ListTemplates), &[("kind", "kind")]),
982+ route("GET", "/workspaces/:workspace/artifact-spaces", Op::Folios(FoliosOp::ListSpaces), &[]),
983+ route("POST", "/workspaces/:workspace/datasets/query", Op::Folios(FoliosOp::QueryDataset), &[]),
964984 ];
965985
966986 impl Route {
+72−1
2424 use crate::mirrors::MirrorsOp;
2525 use crate::deployments::DeploymentsOp;
2626 use crate::packages::PackagesOp;
27+use crate::folios::FoliosOp;
2728 use crate::protection::ProtectionOp;
2829 use crate::token_policy::TokenOp;
2930 use crate::operations::Op;
525526 a("decline_repository_invitation", Op::DeclineRepoInvitation, "Decline one"),
526527 ],
527528 },
529+ Tool {
530+ name: "artifact",
531+ title: "Artifacts",
532+ description: "A workspace's docs, slides, designs and dashboards (Artifacts mode), as you can open them: list, search and read them (a doc's content is Markdown, with block ids to target), make them, edit them (a change with the edit role, a suggestion with comment), move, trash and restore them, their versions, and who can open them. Name one by its id (fol_…) or its address. Not the `workflow` tool's run artifacts. Slides, designs and dashboards answer that they are not here yet.",
533+ default_action: None,
534+ actions: &[
535+ a("list", Op::Folios(FoliosOp::List), "Artifacts you can open; tab, kind, space, q; state trashed for the trash"),
536+ a("search", Op::Folios(FoliosOp::Search), "Search them by words and meaning, with the passage that matched"),
537+ a("get", Op::Folios(FoliosOp::Get), "One artifact: kind, title, space, owner, your role, who it is shared with"),
538+ a("read", Op::Folios(FoliosOp::GetContent), "Its content: a doc's Markdown and block ids, and what you may do"),
539+ a("versions", Op::Folios(FoliosOp::ListVersions), "Its saved versions, newest first"),
540+ a("access", Op::Folios(FoliosOp::GetAccess), "Who can open it, and how"),
541+ a("templates", Op::Folios(FoliosOp::ListTemplates), "Templates to start one from"),
542+ a("spaces", Op::Folios(FoliosOp::ListSpaces), "The spaces in your sidebar"),
543+ a("query_data", Op::Folios(FoliosOp::QueryDataset), "Run a dataset query as you, over what you can read"),
544+ a("create", Op::Folios(FoliosOp::Create), "Make one: in a space, under a doc, or in your Private"),
545+ a("update", Op::Folios(FoliosOp::Update), "Rename it, change its icon, or move it"),
546+ a("edit", Op::Folios(FoliosOp::Edit), "Change its content: append, replace it all, a section or blocks"),
547+ a("trash", Op::Folios(FoliosOp::Trash), "Move it to the trash; restorable for 30 days"),
548+ a("restore", Op::Folios(FoliosOp::Restore), "Bring it back from the trash"),
549+ a("restore_version", Op::Folios(FoliosOp::RestoreVersion), "Make an earlier version its content again"),
550+ a("share", Op::Folios(FoliosOp::SetAccess), "Share it, change general access, or take access away"),
551+ a("purge", Op::Folios(FoliosOp::Purge), "Delete one in the trash for good"),
552+ ],
553+ },
528554 ];
529555
530556 /// Operations that cannot be undone, or reach beyond g1t's own records:
561587 | Op::RemoveRunner
562588 | Op::DeleteRunnerGroup
563589 | Op::UpdateRunnerSettings
590+ | Op::Folios(FoliosOp::SetAccess | FoliosOp::Purge)
564591 )
565592 }
566593
811838 assert!(tool.action(default).is_some(), "{}", tool.name);
812839 }
813840 }
814− assert!(TOOLS.len() <= 18, "{} tools", TOOLS.len());
841+ assert!(TOOLS.len() <= 19, "{} tools", TOOLS.len());
815842 }
816843
817844 #[test]
9921019 assert!(!reads_only(Op::RequestReviewers));
9931020 }
9941021
1022+ /// The `artifact` tool offers each token only what its artifacts scope
1023+ /// allows: reading, then changing, then sharing and deleting for good.
1024+ /// Workflow runs' artifacts are the `workflow` tool's, under their own
1025+ /// scope, and neither scope reaches the other's.
1026+ #[test]
1027+ fn the_artifact_tool_offers_what_the_artifacts_scope_allows() {
1028+ let actions = |scopes: Vec<Scope>| -> Option<Value> {
1029+ let access = token(Some(scopes));
1030+ Tool::by_name("artifact").unwrap().listed(&Gate::Token(&access)).map(|tool| tool["inputSchema"]["properties"]["action"]["enum"].clone())
1031+ };
1032+ let reads = json!(["list", "search", "get", "read", "versions", "access", "templates", "spaces", "query_data"]);
1033+ assert_eq!(actions(vec![Scope::ArtifactsRead]), Some(reads.clone()));
1034+ let writes = actions(vec![Scope::ArtifactsWrite]).unwrap();
1035+ for action in ["create", "update", "edit", "trash", "restore", "restore_version"] {
1036+ assert!(writes.as_array().unwrap().contains(&json!(action)), "{action}");
1037+ }
1038+ assert!(!writes.as_array().unwrap().contains(&json!("share")) && !writes.as_array().unwrap().contains(&json!("purge")));
1039+ let admin = actions(vec![Scope::ArtifactsAdmin]).unwrap();
1040+ assert_eq!(admin.as_array().unwrap().len(), Tool::by_name("artifact").unwrap().actions.len());
1041+ // Without an artifacts scope there is no artifact tool at all.
1042+ assert_eq!(actions(vec![Scope::WorkflowsWrite, Scope::IssuesWrite]), None);
1043+ // And an artifacts scope shows nothing of workflow runs' artifacts.
1044+ let access = token(Some(vec![Scope::ArtifactsAdmin]));
1045+ assert!(Tool::by_name("workflow").unwrap().listed(&Gate::Token(&access)).is_none());
1046+ // The read-only and agent presets read artifacts and change none.
1047+ for preset in [Preset::ReadOnly, Preset::Agent] {
1048+ let access = token(preset.scopes());
1049+ let tool = Tool::by_name("artifact").unwrap().listed(&Gate::Token(&access)).unwrap();
1050+ assert_eq!(tool["inputSchema"]["properties"]["action"]["enum"], reads, "{}", preset.as_str());
1051+ assert_eq!(tool["annotations"]["readOnlyHint"], true);
1052+ }
1053+ // Sharing and deleting for good can't be undone the same way.
1054+ let tools = listed(&Gate::Everything);
1055+ let artifact = tools.iter().find(|tool| tool["name"] == "artifact").unwrap();
1056+ assert_eq!(artifact["annotations"]["destructiveHint"], true);
1057+ assert!(artifact["description"].as_str().unwrap().contains("Not the `workflow` tool's run artifacts"));
1058+ let tool = Tool::by_name("artifact").unwrap();
1059+ assert_eq!(resolve(tool, &json!({ "action": "read", "workspace": "acme" })), Err("artifact.read needs artifact_id.".to_owned()));
1060+ assert_eq!(
1061+ resolve(tool, &json!({ "action": "edit", "workspace": "acme", "artifact_id": "fol_1", "markdown": "x" })),
1062+ Ok(Op::Folios(FoliosOp::Edit))
1063+ );
1064+ }
1065+
9951066 /// How much smaller `tools/list` is than one tool per operation. Run
9961067 /// with `--nocapture` to see the numbers.
9971068 #[test]
+3−1
3333 // A person's pinned projects: list_pinned_projects and changing them.
3434 { "binding": "PROJECTS", "service": "g1t-projects" },
3535 // Packages: list_packages, their settings, deleting and restoring them.
36− { "binding": "PACKAGES", "service": "g1t-packages" }
36+ { "binding": "PACKAGES", "service": "g1t-packages" },
37+ // Artifacts (folios): the artifact tool and /workspaces/{ws}/artifacts.
38+ { "binding": "DOCS", "service": "g1t-docs-service" }
3739 ],
3840 // GitHub Actions artifacts older runners kept, in chunks, with KV's own
3941 // expiry, read until it passes (and cache
+6−1
782782 | Billing | read, read and write | `billing:read`, `billing:write` |
783783 | Self-hosted runners | read, admin | `runners:read`, `runners:admin` |
784784 | AI Gateway | read, read and write | `models:read`, `models:write` |
785+| Artifacts | read, read and write, admin | `artifacts:read`, `artifacts:write`, `artifacts:admin` |
785786
786787 Account permissions are about you, wherever you are, and only a personal
787788 token can hold them:
886887 | `runners:admin` | Register and remove self-hosted runners, change their groups and settings |
887888 | `models:read` | See the workspace's [AI Gateway](/guides/ai-gateway/) requests: their models, tokens, cost and status |
888889 | `models:write` | Send model requests through the [AI Gateway](/guides/ai-gateway/), which uses the workspace's AI credit. Only a workspace's own token can send them. Not in any preset. |
890+| `artifacts:read` | List, read and search the [artifacts](/guides/bring-your-own-agent/#artifacts) you can open (docs, and later slides, designs and dashboards), their versions and who can open them. Not workflow runs' artifacts, which are `workflows:read`. |
891+| `artifacts:write` | Create, rename, move, edit, trash and restore artifacts, and suggest changes to them |
892+| `artifacts:admin` | Share artifacts, change who can open them, and delete them for good. Not in any preset. |
889893
890894 Every operation of the API and the MCP server needs exactly one of these,
891895 except `whoami` (`GET /user`), which any token may use. Each endpoint's page
10021006 personal token, starting on the CI preset. A workspace token reaches all
10031007 of that workspace's repositories, or the ones chosen, never another
10041008 workspace, and cannot manage people, tokens or workspaces. It holds no
1005−account permissions.
1009+account permissions, and cannot use artifacts, which always belong to a
1010+person.
10061011
10071012 It has the Write role on the workspace's repositories, as a member does:
10081013 it pushes, merges and works on issues and pull requests, within its
+39−0
125125 `number`. [MCP tools](/reference/mcp/) lists every tool and action with its
126126 required inputs, its scope and its REST route.
127127
128+## Artifacts
129+
130+Your agent can read and write a workspace's artifacts: its docs, and later
131+its slides, designs and dashboards. The `artifact` tool does it as you,
132+so it opens only what you can open, and changes only what you can change.
133+Its token needs `artifacts:read` to read them (the Read only and Agent
134+[presets](/guides/authentication/#presets) have it), `artifacts:write` to
135+make and change them, and `artifacts:admin` to share them or delete them
136+for good. A token without one of these sees no `artifact` tool, and a
137+token sees only the actions its scope allows.
138+
139+1. Find what is there with `list` (narrow with `kind`, `space` or `q`) or
140+ `search`, which matches words and meaning and returns the passage that
141+ matched.
142+2. Read one with `read`. A doc comes back as Markdown in `content`, with
143+ its top-level `blocks` and their ids, and `can` says whether you may
144+ edit it or only suggest.
145+3. Change it with `edit`: `markdown` and a `target`, which is `append`,
146+ `document` (replace it all), a `section` by its `heading`, or `blocks`
147+ from one block id to another. With the edit role the change is made, as
148+ a new version; with the comment role, or with `suggest_only`, it is
149+ filed as a suggestion that the doc's editors accept or reject.
150+4. Make one with `create`. Left out `space` and `parent_id`, it lands in
151+ your Private, where only you can open it until you share it.
152+
153+```json
154+{ "action": "edit", "workspace": "acme", "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z",
155+ "target": { "kind": "section", "heading": "Risks" },
156+ "markdown": "## Risks\n\nThe migration needs a maintenance window.\n",
157+ "note": "Adds the migration risk" }
158+```
159+
160+Name an artifact by its id (`fol_…`) or by its link. Each action is also
161+a REST route under `/workspaces/{workspace}/artifacts`; see the
162+[API reference](/reference/api/). These are not workflow runs' artifacts,
163+which are the `workflow` tool's. Slides, designs and dashboards answer
164+that they are not here yet. A workspace's own token cannot use artifacts:
165+they always belong to a person.
166+
128167 ## Staying out of each other's way
129168
130169 `pull_request` with `get` returns `overlaps`: other pull requests in progress that
+36−4
33 description: The g1t MCP server's resource tools, each action they take with its required inputs and scope, and how to call them.
44 ---
55
6−The MCP server at `https://mcp.g1t.sh` exposes 18 tools, one per kind of
6+The MCP server at `https://mcp.g1t.sh` exposes 19 tools, one per kind of
77 thing on g1t: `search`, `repository`, `issue`, `pull_request`, `agent`,
88 `plan`, `memory`, `workflow`, `package`, `secret`, `security`, `webhook`, `access`,
9−`team`, `workspace`, `billing`, `notifications` and `account`. Each tool takes an `action` that says what to do. Every
9+`team`, `workspace`, `billing`, `notifications`, `account` and `artifact`. Each tool takes an `action` that says what to do. Every
1010 action is the same operation as a route of the [REST API](/reference/api/),
1111 with the same inputs, permissions and results, so the two always agree.
1212
169169 | --- | --- |
170170 | `title` | The tool's name for people, such as `Pull requests`. |
171171 | `readOnlyHint` | `true` when every action shown only reads. |
172−| `destructiveHint` | `true` when the tool is not read-only and an action shown cannot be undone or reaches beyond g1t's own records: deleting a workspace, deleting, purging or transferring a repository, changing its visibility, removing an email address or a collaborator, deleting a team or taking its role on a repository away, disconnecting an integration, deleting a webhook, setting or deleting secrets and variables, replacing model routes, setting a workspace's base permission, removing a member, transferring a workspace's ownership, leaving a workspace, merging a pull request, removing a self-hosted runner, deleting a runner group, and changing runner settings. |
172+| `destructiveHint` | `true` when the tool is not read-only and an action shown cannot be undone or reaches beyond g1t's own records: deleting a workspace, deleting, purging or transferring a repository, changing its visibility, removing an email address or a collaborator, deleting a team or taking its role on a repository away, disconnecting an integration, deleting a webhook, setting or deleting secrets and variables, replacing model routes, setting a workspace's base permission, removing a member, transferring a workspace's ownership, leaving a workspace, merging a pull request, removing a self-hosted runner, deleting a runner group, changing runner settings, sharing an artifact, and deleting an artifact for good. |
173173 | `idempotentHint` | The same as `readOnlyHint`. |
174174 | `openWorldHint` | Always `false`. |
175175
768768 | [`decline_repository_invitation`](/reference/api/access/decline-repo-invitation/) | Decline one. | `id` | `account:write` |
769769
770770
771+## `artifact`
772+
773+A workspace's artifacts: its docs, and later its slides, designs and
774+dashboards. Each action runs as you: it finds and opens only what you can
775+open, and changes only what your role on it allows, whatever the token's
776+scope. Name one by its id (`fol_…`) or its link as `artifact_id`, and its
777+space by slug or id. Not workflow runs' artifacts, which are the
778+`workflow` tool's. Slides, designs and dashboards answer `422` saying they
779+are not here yet. A workspace's own token cannot use this tool. See
780+[artifacts for agents](/guides/bring-your-own-agent/#artifacts).
781+
782+| Action | What it does | Required | Scope |
783+| --- | --- | --- | --- |
784+| [`list`](/reference/api/artifacts/list-workspace-artifacts/) | The artifacts you can open, most recently edited first, with your role on each. Narrow with `tab` (`all`, `yours`, `shared`), `kind`, `space`, `project` and `q`; page with `cursor`. `state` `trashed` lists what you can restore. | `workspace` | `artifacts:read` |
785+| [`search`](/reference/api/artifacts/search-workspace-artifacts/) | Search them by words and meaning; each hit has the passage that matched (`snippet`, `heading`). | `workspace`, `q` | `artifacts:read` |
786+| [`get`](/reference/api/artifacts/get-workspace-artifact/) | One artifact: kind, title, space, owner, your `viewer_role`, general access, whether it is private or stale, and `html_url`. | `workspace`, `artifact_id` | `artifacts:read` |
787+| [`read`](/reference/api/artifacts/get-workspace-artifact-content/) | Its content: a doc's Markdown, its top-level `blocks` with ids, and `can` (read, suggest, edit). | `workspace`, `artifact_id` | `artifacts:read` |
788+| [`versions`](/reference/api/artifacts/list-workspace-artifact-versions/) | Its saved versions, newest first, with who made each. | `workspace`, `artifact_id` | `artifacts:read` |
789+| [`access`](/reference/api/artifacts/get-workspace-artifact-access/) | Who can open it: its owner, who it is shared with and how, general access, and whether you may change it. | `workspace`, `artifact_id` | `artifacts:read` |
790+| [`templates`](/reference/api/artifacts/list-workspace-artifact-templates/) | Templates to start one from, built-in and the workspace's; narrow with `kind`. | `workspace` | `artifacts:read` |
791+| [`spaces`](/reference/api/artifacts/list-workspace-artifact-spaces/) | The spaces in your Artifacts sidebar, with your role in each. | `workspace` | `artifacts:read` |
792+| [`query_data`](/reference/api/artifacts/query-workspace-dataset/) | Run a dataset `query` as you, over what you can read. Answers that dashboards are not here yet until they ship. | `workspace`, `query` | `artifacts:read` |
793+| [`create`](/reference/api/artifacts/create-workspace-artifact/) | Make one from `markdown` or a `template_id`, in a `space`, under a `parent_id`, or in your Private. `kind` is `doc`; the others are not here yet. | `workspace` | `artifacts:write` |
794+| [`update`](/reference/api/artifacts/update-workspace-artifact/) | Change its `title` or `icon`, or move it to a `space` (`private` for your Private) or under a `parent_id`. | `workspace`, `artifact_id` | `artifacts:write` |
795+| [`edit`](/reference/api/artifacts/edit-workspace-artifact/) | Change its content: `markdown` with a `target` (`append`, `document`, a `section` by `heading`, or `blocks`). Made with the edit role; a suggestion with the comment role or `suggest_only`. | `workspace`, `artifact_id` | `artifacts:write` |
796+| [`trash`](/reference/api/artifacts/trash-workspace-artifact/) | Move it, and what is under it, to the trash; deleted for good after 30 days. | `workspace`, `artifact_id` | `artifacts:write` |
797+| [`restore`](/reference/api/artifacts/restore-workspace-artifact/) | Bring it back from the trash. | `workspace`, `artifact_id` | `artifacts:write` |
798+| [`restore_version`](/reference/api/artifacts/restore-workspace-artifact-version/) | Make an earlier version its content again, as a new version. | `workspace`, `artifact_id`, `version_id` | `artifacts:write` |
799+| [`share`](/reference/api/artifacts/set-workspace-artifact-access/) | Share it with a `username`, `team` or `agent` at a `role` (`view`, `comment`, `edit`, `manage`, or `none` to take access away); set `general_access` and `general_role`, `inherit` or `agent_mode`. Takes full access to it. | `workspace`, `artifact_id` | `artifacts:admin` |
800+| [`purge`](/reference/api/artifacts/purge-workspace-artifact/) | Delete one in the trash for good. Takes full access to it. | `workspace`, `artifact_id` | `artifacts:admin` |
801+
771802 ## What g1t can use
772803
773804 g1t works with a [run credential](/guides/working-with-g1t/#credentials):
792823 reopens a security alert, or the `security` actions that decide about
793824 security: `update_secret_alert`, `bypass`, `review_bypass`, the pattern
794825 changes, `update_code_alert`, `update_vulnerability_alert`, `fix`,
795−`update_settings` and `update_workspace_settings`. Every repository it
826+`update_settings` and `update_workspace_settings`, or `artifact` `share`
827+and `purge`. Every repository it
796828 names must be its own. `tools/list` shows such a token only the tools and
797829 actions it may use; a call to any other is refused with the rule that
798830 refused it, and recorded in the workspace's [audit log](/guides/audit-log/),
+0−0

Binary or large file; its contents are not shown.

+5−0
478478 "restore_package",
479479 "delete_package_version",
480480 "restore_package_version",
481+ // Sharing an artifact and deleting one for good are for people: an
482+ // agent shares only through the agents service, with the people
483+ // already in its conversation (docs/ARTIFACTS_MODE.md, section 4.3).
484+ "set_workspace_artifact_access",
485+ "purge_workspace_artifact",
481486 ];
482487
483488 /// Reading what an agent needs to know about its repository.
+2−1
1313 //! shapes are snake_case; the tests keep field names, RPC methods and
1414 //! validators the same as the TypeScript (`folios.fixtures.json`).
1515 //!
16−//! Nothing uses this yet: Phase 1 publishes the events, Phase 3 the API.
16+//! The API (`apps/api/src/folios.rs`) calls the RPC with these; the docs
17+//! service publishes the events.
1718
1819 use serde::{Deserialize, Serialize};
1920 use serde_json::Value;
+63−25
4444 Runners,
4545 Models,
4646 /// Artifacts mode's docs, slides, designs and dashboards (folios in
47− /// code). Not offered yet: see [`Resource::offered`].
47+ /// code): the `artifact` MCP tool and `/workspaces/{ws}/artifacts`.
48+ /// Not workflow runs' artifacts, which are `workflows:*`.
4849 Artifacts,
4950 }
5051
129130 }
130131 }
131132
132− /// Whether tokens are offered it yet. A resource that is not is in the
133− /// table (so its scopes parse, and the TypeScript mirror lists it under
134− /// `UPCOMING_RESOURCES`) but nothing hands it out: presets, full
135− /// access, OAuth and the token form leave it out, and no operation
136− /// needs it. Artifacts is offered once its API ships (Phase 3 of
137− /// docs/ARTIFACTS_MODE.md).
133+ /// Whether tokens are offered it yet. A resource being built can be in
134+ /// the table before its API ships (so its scopes parse, and the
135+ /// TypeScript mirror lists it under `UPCOMING_RESOURCES`) while nothing
136+ /// hands it out: presets, full access, OAuth and the token form leave
137+ /// it out, and no operation needs it. Every resource is offered now;
138+ /// Artifacts was the last, until Phase 3 of docs/ARTIFACTS_MODE.md.
138139 pub fn offered(self) -> bool {
139− !matches!(self, Resource::Artifacts)
140+ let _ = self;
141+ true
140142 }
141143 }
142144
12251227 // The AI Gateway. Sending a request to a model needs `models:write`,
12261228 // checked by the model proxy at models.g1t.sh, not here.
12271229 ("list_gateway_requests", Scope::ModelsRead),
1230+ // Artifacts mode's docs, slides, designs and dashboards (the `artifact`
1231+ // MCP tool). Reading takes artifacts:read, making and changing them
1232+ // artifacts:write, and sharing them or deleting them for good
1233+ // artifacts:admin. The docs service then checks the person's own role
1234+ // on each one.
1235+ ("list_workspace_artifacts", Scope::ArtifactsRead),
1236+ ("search_workspace_artifacts", Scope::ArtifactsRead),
1237+ ("get_workspace_artifact", Scope::ArtifactsRead),
1238+ ("get_workspace_artifact_content", Scope::ArtifactsRead),
1239+ ("list_workspace_artifact_versions", Scope::ArtifactsRead),
1240+ ("get_workspace_artifact_access", Scope::ArtifactsRead),
1241+ ("list_workspace_artifact_templates", Scope::ArtifactsRead),
1242+ ("list_workspace_artifact_spaces", Scope::ArtifactsRead),
1243+ ("query_workspace_dataset", Scope::ArtifactsRead),
1244+ ("create_workspace_artifact", Scope::ArtifactsWrite),
1245+ ("update_workspace_artifact", Scope::ArtifactsWrite),
1246+ ("edit_workspace_artifact", Scope::ArtifactsWrite),
1247+ ("trash_workspace_artifact", Scope::ArtifactsWrite),
1248+ ("restore_workspace_artifact", Scope::ArtifactsWrite),
1249+ ("restore_workspace_artifact_version", Scope::ArtifactsWrite),
1250+ ("set_workspace_artifact_access", Scope::ArtifactsAdmin),
1251+ ("purge_workspace_artifact", Scope::ArtifactsAdmin),
12281252 ];
12291253
12301254 /// Operations any token may use: saying who it is.
14961520 }
14971521
14981522 #[test]
1499− fn artifacts_scopes_exist_but_are_not_offered_yet() {
1523+ fn artifacts_are_offered_read_by_presets_and_shared_only_with_admin() {
15001524 for scope in [Scope::ArtifactsRead, Scope::ArtifactsWrite, Scope::ArtifactsAdmin] {
15011525 assert_eq!(scope.resource(), Resource::Artifacts);
1502− assert!(!scope.offered());
1503− assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1504− // Nothing hands it out: not presets, full access, OAuth or the form.
1505− for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1506− assert!(!preset.scopes().unwrap().contains(&scope), "{}", preset.as_str());
1507− }
1508− assert!(!everything().contains(&scope));
1509− assert!(!offered_scopes().contains(&scope));
1510− assert!(!oauth_default().contains(&scope));
1511− assert!(parse_scopes(scope.as_str()).is_empty());
1512− // And no operation needs it yet.
1513− assert!(OPERATIONS.iter().all(|(_, needed)| *needed != scope));
1526+ assert!(scope.offered());
1527+ assert!(offered_scopes().contains(&scope));
1528+ assert_eq!(parse_scopes(scope.as_str()), vec![scope]);
1529+ assert!(OPERATIONS.iter().any(|(_, needed)| *needed == scope), "{scope:?} gates nothing");
1530+ }
1531+ // Reading them is a read like any other; changing them is chosen.
1532+ for preset in [Preset::ReadOnly, Preset::Agent] {
1533+ let scopes = preset.scopes().unwrap();
1534+ assert!(scopes.contains(&Scope::ArtifactsRead), "{}", preset.as_str());
1535+ assert!(!scopes.contains(&Scope::ArtifactsWrite) && !scopes.contains(&Scope::ArtifactsAdmin), "{}", preset.as_str());
15141536 }
1537+ assert!(!Preset::Ci.scopes().unwrap().contains(&Scope::ArtifactsRead));
1538+ assert!(everything().contains(&Scope::ArtifactsAdmin));
15151539 assert!(Scope::ArtifactsAdmin.includes(Scope::ArtifactsWrite));
15161540 assert!(Scope::ArtifactsAdmin.dangerous());
1541+ assert!(!Scope::ArtifactsWrite.dangerous());
15171542 assert_eq!(Resource::Artifacts.group(), ResourceGroup::Workspace);
1518− let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "read".to_owned())]);
1519− assert_eq!(resolve_permissions(&asked, true), Err("There is no permission called artifacts.".to_owned()));
1520− assert_eq!(offered_scopes().len(), Scope::ALL.len() - 3);
1543+ let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "write".to_owned())]);
1544+ assert_eq!(resolve_permissions(&asked, true), Ok(vec![Scope::ArtifactsWrite]));
1545+ assert_eq!(offered_scopes().len(), Scope::ALL.len());
1546+ // Sharing and deleting for good need admin; editing needs write.
1547+ assert_eq!(scope_for("set_workspace_artifact_access"), Some(Scope::ArtifactsAdmin));
1548+ assert_eq!(scope_for("purge_workspace_artifact"), Some(Scope::ArtifactsAdmin));
1549+ assert_eq!(scope_for("edit_workspace_artifact"), Some(Scope::ArtifactsWrite));
1550+ assert_eq!(scope_for("get_workspace_artifact_content"), Some(Scope::ArtifactsRead));
1551+ let writer = token(&[Scope::ArtifactsWrite]);
1552+ assert!(decide(&writer, "edit_workspace_artifact", &json!({})).allowed);
1553+ assert!(decide(&writer, "list_workspace_artifacts", &json!({})).allowed, "write includes read");
1554+ assert!(decide(&writer, "set_workspace_artifact_access", &json!({})).reason.unwrap().contains("artifacts:admin"));
1555+ // Workflow runs' artifacts are another thing, with their own scope.
1556+ let reader = token(&[Scope::ArtifactsRead]);
1557+ assert!(!decide(&reader, "list_artifacts", &json!({ "repo": "acme/web" })).allowed);
1558+ assert!(!decide(&token(&[Scope::WorkflowsRead]), "list_workspace_artifacts", &json!({})).allowed);
15211559 }
15221560
15231561 #[test]
17911829 let offered: Vec<String> = Scope::ALL.iter().filter(|scope| scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
17921830 let upcoming: Vec<String> = Scope::ALL.iter().filter(|scope| !scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
17931831 assert_eq!(names("export const SCOPES = ["), offered);
1794− assert_eq!(names("export const UPCOMING_SCOPES = ["), upcoming);
1832+ assert_eq!(names("export const UPCOMING_SCOPES"), upcoming);
17951833 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
17961834 .lines()
17971835 .filter_map(|line| {
+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.