Skip to content

Commit

Merge main into Artifacts Phase 2

# Conflicts: # apps/web/app/components/chat/channel.tsx

syntaqxcommitted Parents138aefd0612e69Browse files
72 files+2163−220/72 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
+1−1
7676 | Actor | Who did it: a person, an agent, or a workspace token. |
7777 | On behalf of | For an agent, the person it worked for: `g1t on behalf of syntaqx`. |
7878 | Run | The agent run, with its kind: `implement`, `review`, `update` and so on; or the workflow run whose job's token did it, as `workflow_job`. |
79−| Credential | The id of the token used. |
79+| Credential | The id of the token used: on the API, the MCP server or git, or on g1t.sh by a token [used on the website](/guides/authentication/#use-a-token-on-the-website). Empty for a person signed in on g1t.sh. |
8080 | Action | The API or MCP operation, such as `create_issue`, or `git.push` and `git.fetch`. |
8181 | Target | The repository, the issue or pull request number, and for git the refs it moved. |
8282 | Outcome | `allowed` or `denied`. |
+108−5
577577
578578 ## Access tokens
579579
580−A token stands in for your password everywhere outside the website:
580+A token stands in for your password everywhere outside the website, and on
581+the website too when you turn that on for it:
581582
582583 | Where | How to send it |
583584 | --- | --- |
584585 | git | As the password, with your username. |
585586 | API | `Authorization: Bearer g1t_…` |
586587 | MCP | The same header, set when you add the server. |
588+| The website | The same header on every request, from automation that drives a browser. Only a token with **Use the website as you** turned on. See [use a token on the website](#use-a-token-on-the-website). |
587589
588590 A token is shown once, when it is created; g1t stores only a hash of it.
589591 If you lose one, delete it and create another. Delete a token the moment
627629 5. Under **Permissions**, set each resource the token needs to a level.
628630 **Read only**, **Agent** and **CI** fill in a [preset](#presets);
629631 **Clear** sets everything back to no access.
630−6. Select **Generate token**, and copy it. It is not shown again.
632+6. Leave **Use the website as you**, under **Website**, off unless the
633+ token is for automation that drives a browser. See
634+ [use a token on the website](#use-a-token-on-the-website).
635+7. Select **Generate token**, and copy it. It is not shown again.
631636
632637 When you make a token for one workspace that
633638 [requires approval](#a-workspaces-rules-for-tokens), and you are not one of
641646 The list under Settings → Access tokens shows each token's name, status
642647 (pending, denied or revoked, with the owner's note), where it reaches, its
643648 permissions, and when it was made, last used and expires. Select a token to
644−open its page, where you can change its name, description, repositories
645−and permissions, and select **Save changes**. The token itself stays the
649+open its page, where you can change its name, description, repositories,
650+permissions and **Use the website as you**, and select **Save changes**.
651+A token that can use the website is marked **Uses the website**. The token
652+itself stays the
646653 same; the change applies from its next request. Widening a token made for
647654 a workspace that requires approval asks its owners again. Where it reaches
648655 and when it expires cannot change; make a new token instead.
649656
650657 **Delete token**, at the bottom of its page, stops it working at once.
651658
659+### Use a token on the website
660+
661+Automation that drives a browser, such as end-to-end tests or an agent
662+checking how a page looks, can use g1t.sh as you with an access token, so it
663+never types your password or a two-factor code. Each request it makes
664+carries the token in the `Authorization` header; no cookie is set and no
665+session is started.
666+
667+1. Open [Settings → Access tokens](https://g1t.sh/settings/tokens) and
668+ select **New token**, or open a token you have.
669+2. Give it an expiration, and the permissions it needs for git, the API and
670+ MCP, if any.
671+3. Under **Website**, tick **Use the website as you**. It is off unless you
672+ tick it, and a workspace's own token cannot have it.
673+4. Select **Generate token** (or **Save changes**), and keep the token in a
674+ file only the automation can read.
675+5. Send `Authorization: Bearer g1t_…` on every request to g1t.sh.
676+
677+With [Playwright](https://playwright.dev), set the header on the browser
678+context, reading the token from a file so it is never printed:
679+
680+```js
681+import { readFileSync } from "node:fs";
682+import { chromium } from "playwright";
683+
684+const token = readFileSync(process.env.G1T_TOKEN_FILE, "utf8").trim();
685+const browser = await chromium.launch();
686+const context = await browser.newContext({
687+ extraHTTPHeaders: { authorization: `Bearer ${token}` },
688+});
689+const page = await context.newPage();
690+await page.goto("https://g1t.sh/acme/rocket/pulls");
691+await page.screenshot({ path: "pulls.png", fullPage: true });
692+await browser.close();
693+```
694+
695+`extraHTTPHeaders` sends the header with every request the page makes,
696+including to other addresses it loads files from. To send it to g1t.sh
697+only, add it per request instead:
698+
699+```js
700+const context = await browser.newContext();
701+await context.route("https://g1t.sh/**", (route) =>
702+ route.continue({ headers: { ...route.request().headers(), authorization: `Bearer ${token}` } }),
703+);
704+```
705+
706+Any HTTP client works the same way:
707+
708+```sh
709+curl -H "Authorization: Bearer $(cat ~/.config/g1t/website-token)" https://g1t.sh/acme/rocket/pulls
710+```
711+
712+On the website, the token acts as you in the workspaces it
713+[reaches](#where-a-token-reaches). Its permissions are made for git, the API
714+and MCP, and the website does not hold it to them: treat it as able to do
715+anything there that you can. Keep it as safe as your password, and give it
716+an expiration.
717+
718+- **Only the header counts.** A token in a query string or a cookie is
719+ ignored. A request with the header is the token's, even if it also has a
720+ session cookie.
721+- **Checked on every request.** Deleting the token, its expiry, or a
722+ workspace [revoking it](#a-workspaces-rules-for-tokens) stops it at once.
723+- **A token that is not accepted is no one.** One that is not valid, has
724+ expired, or does not have **Use the website as you** loads pages as
725+ someone signed out, with a `WWW-Authenticate` header saying the token was
726+ refused; the data requests and form posts pages make answer `401`.
727+- **Form posts need nothing more.** Browsers never send the header by
728+ themselves, so a post with it needs no other proof it came from g1t.sh.
729+ A post from another site is still refused.
730+- **Limits follow the token**: 1,000 requests a minute, as on the API. See
731+ [rate limits](/reference/rate-limits/).
732+- **The audit log names it.** A change made this way is recorded as yours,
733+ with the token's id under **Credential**. See [the audit log](/guides/audit-log/).
734+
735+Some things always need you to sign in on g1t.sh yourself. With a token,
736+these pages answer **This needs you to sign in** (`403`):
737+
738+| What | Where |
739+| --- | --- |
740+| Access tokens, yours and a workspace's, and a workspace's rules for and approvals of members' tokens | Settings → Access tokens; a workspace's Settings → Access tokens and Personal access tokens |
741+| Two-factor authentication | Settings → Two-factor authentication |
742+| Your username, and deleting your account | Settings → Account |
743+| Email addresses, which reset your password | Settings → Emails |
744+| SSH keys | Settings → SSH keys |
745+| Applications you signed in to, and signing in with GitHub | Settings → Connected applications, Settings → GitHub |
746+| Letting a device or an application sign in | `g1t.sh/device`, `g1t.sh/oauth/authorize` |
747+| Deleting a workspace, and giving it to another owner | A workspace's Settings and People |
748+| Payment methods: the billing portal, adding a card, subscribing and buying AI credit | A workspace's Billing |
749+
652750 ### Permissions
653751
654752 A permission is a resource and a level. A higher level includes the lower
684782 | Billing | read, read and write | `billing:read`, `billing:write` |
685783 | Self-hosted runners | read, admin | `runners:read`, `runners:admin` |
686784 | AI Gateway | read, read and write | `models:read`, `models:write` |
785+| Artifacts | read, read and write, admin | `artifacts:read`, `artifacts:write`, `artifacts:admin` |
687786
688787 Account permissions are about you, wherever you are, and only a personal
689788 token can hold them:
788887 | `runners:admin` | Register and remove self-hosted runners, change their groups and settings |
789888 | `models:read` | See the workspace's [AI Gateway](/guides/ai-gateway/) requests: their models, tokens, cost and status |
790889 | `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. |
791893
792894 Every operation of the API and the MCP server needs exactly one of these,
793895 except `whoami` (`GET /user`), which any token may use. Each endpoint's page
9041006 personal token, starting on the CI preset. A workspace token reaches all
9051007 of that workspace's repositories, or the ones chosen, never another
9061008 workspace, and cannot manage people, tokens or workspaces. It holds no
907−account permissions.
1009+account permissions, and cannot use artifacts, which always belong to a
1010+person.
9081011
9091012 It has the Write role on the workspace's repositories, as a member does:
9101013 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
+73−2
235235
236236 An address such as `me@example.com` is never read as a mention.
237237
238+## Format a message
239+
240+Messages can have bold, italic and struck-through words, links, inline
241+code, code blocks, lists and quotes. Format as you write, three ways:
242+
243+- **The formatting bar** above the text. **T** at the bottom left of the
244+ composer shows or hides it, and your choice is kept on that device. On a
245+ phone it starts hidden; when it is shown, swipe it sideways for the rest.
246+- **Shortcuts.** Select words, or start typing after one. The keyboard
247+ button at the end of the bar lists them too.
248+- **Markdown, typed inline.** It turns into its formatting as you type:
249+ `*bold*` becomes **bold** when you type the closing `*`.
250+
251+| Formatting | Shortcut (⌘ on a Mac) | Or type |
252+| --- | --- | --- |
253+| Bold | Ctrl+B | `*bold*` or `**bold**` |
254+| Italic | Ctrl+I | `_italic_` |
255+| Strikethrough | Ctrl+Shift+X | `~struck~` or `~~struck~~` |
256+| Link | Ctrl+K | Select words and paste an address over them |
257+| Inline code | Ctrl+Shift+C or Ctrl+E | `` `code` `` |
258+| Code block | Ctrl+Alt+Shift+C | ` ``` ` then Enter, or ` ```ts ` to name the language |
259+| Bulleted list | Ctrl+Shift+8 | `- ` at the start of a line |
260+| Numbered list | Ctrl+Shift+7 | `1. ` at the start of a line |
261+| Quote | Ctrl+Shift+9 | `> ` at the start of a line |
262+
263+**Ctrl+K** asks where the link goes, and for its text when nothing is
264+selected. On a link already, it changes the address or removes the link.
265+A link goes to a web address, an email address or a page on g1t; anything
266+else is refused. Undo (**Ctrl+Z**) takes formatting back off.
267+
268+### Enter and new lines
269+
270+| Key | In a line of text | In a list | In a code block |
271+| --- | --- | --- | --- |
272+| **Enter** | Sends | Adds an item; on an empty item, leaves the list | Adds a line; three in a row leave the block |
273+| **Shift+Enter** | Starts a new line | Adds an item | Adds a line |
274+| **Ctrl+Enter** | Sends | Sends | Sends |
275+
276+In a quote, Enter sends and Shift+Enter starts the next line of the quote.
277+
278+### What a message shows
279+
280+Every message, a person's or an agent's, is shown with its formatting.
281+Code blocks with a language g1t knows (`ts`, `rust`, `python`, `sh`,
282+`sql`, `json` and others) are coloured, and have a copy button. A web
283+address on its own becomes a link, and links open in a new tab. Headings
284+an agent writes show as a bold line, and a rule as a line across.
285+
286+Chat never shows HTML: `<b>` reads as typed. An image written in Markdown
287+shows as a link to it, not the image.
288+
289+### Messages are Markdown
290+
291+What you send is kept as Markdown, the formatting written as `**bold**`,
292+`_italic_`, `~~struck~~` and so on. That is what agents read and write,
293+and what you edit when you change a message, so formatting survives the
294+round trip. Text you paste without formatting, such as from a terminal, is
295+read as Markdown too; paste with **Ctrl+Shift+V** to keep it exactly as it
296+is. Notifications, pop-ups and browser notifications show a message's
297+words without the marks: `**Release** is _done_` reads *Release is done*.
298+
299+To show a character that would format, put a backslash before it: `\*`
300+shows `*`.
301+
238302 ## Agents in channels
239303
240304 Invite an agent to a channel the way you invite a person. Only agents of
343407
344408 ## Edit and delete
345409
346−You can edit or delete your own messages. An edited message says so. A deleted message is removed for everyone; replies under it stay.
347−A message can be up to 40,000 characters, and is written in Markdown.
410+You can edit or delete your own messages. On a computer, hover your message,
411+open its **⋯** and choose **Edit message**: it opens in the composer, in
412+place, with its formatting. Enter or **Save** keeps the change, Esc or
413+**Cancel** leaves it as it was. On a phone, press and hold the message and
414+choose **Edit**. An edited message says so. A deleted message is removed
415+for everyone; replies under it stay.
416+
417+A message can be up to 40,000 characters, and is written in Markdown (see
418+[format a message](#format-a-message)).
348419
349420 ## Reactions
350421
+14−6
293293 | `upload` | Only for a push: handing it to the git store and its answer |
294294 | `refs` | Only for a push: recording that the repository's refs changed |
295295 | `total` | Everything g1t did |
296+| `repos` | The same, measured where your request arrived |
296297
297298 A push's checks run side by side, so each also has its own entry, after
298−the steps and not counted in the total: `read` (reading the push's objects
299−and fetching what they build on from the repository), `rules`, `scan`
300−(secrets and email addresses) and, for an access token, `gate` (workflow
301−files).
302−| `repos` | The same, measured where your request arrived |
299+the steps and not counted in the total: `read` (reading the push's
300+objects), `rules`, `scan` (secrets and email addresses) and, for an access
301+token, `gate` (workflow files).
302+
303+g1t asks git to send each push whole: every object a delta in it builds on
304+is in the push too (the `no-thin` capability), so checking a push reads
305+nothing from the repository. A push that changes a large file a little
306+uploads more than it would otherwise, and stays within the
307+[size limits](#size-limits). If your git sends a push that builds on
308+objects outside it anyway, g1t reads up to 200 of those from the
309+repository to check it, and says `thin;desc=yes`.
303310
304−Two entries say how a step went rather than how long it took:
311+Some entries say how a step went rather than how long it took:
305312
306313 | Entry | Values |
307314 | --- | --- |
308315 | `refs;desc=` | `hit-colo` or `hit-shared` when the ref listing came from g1t's cache, `miss` when the git store was asked |
309316 | `pack;desc=` | Only for a fresh clone: `hit` when its pack came from g1t's cache, `miss` when the git store built it |
310317 | `cred;desc=` | `isolate` or `shared` for a store credential made a moment ago, `mint` for a new one |
318+| `thin;desc=` | Only for a push: `no` when it came whole, `yes` when it built on objects outside it |
311319
312320 The ref listing git asks for first on every clone and fetch is kept for up
313321 to a minute, and only the same question about the same refs gets the same
+2−1
106106 </Card>
107107 <Card title="Everything has an API" icon="book-open">
108108 Repositories, issues, pull requests, agents and workflows are all a [REST endpoint](/reference/api/) and an [MCP tool action](/reference/mcp/) away, with
109− [webhooks](/guides/webhooks/) for every event.
109+ [webhooks](/guides/webhooks/) for every event. Tests that drive a browser
110+ [use the website with a token](/guides/authentication/#use-a-token-on-the-website).
110111 </Card>
111112 </CardGrid>
112113
+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.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.