Merge branch 'worktree-agent-a5624039392e490a2' into integrate
18 files+2041−340/18 viewed
| 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 | + | } |
| 22 | 22 | mod oauth; | |
| 23 | 23 | mod oidc; | |
| 24 | 24 | mod packages; | |
| 25 | + | mod folios; | |
| 25 | 26 | mod people; | |
| 26 | 27 | mod openapi; | |
| 27 | 28 | mod pins; |
| 13 | 13 | use crate::mirrors::MirrorsOp; | |
| 14 | 14 | use crate::deployments::DeploymentsOp; | |
| 15 | 15 | use crate::packages::PackagesOp; | |
| 16 | + | use crate::folios::FoliosOp; | |
| 16 | 17 | use crate::protection::ProtectionOp; | |
| 17 | 18 | use crate::token_policy::TokenOp; | |
| 18 | 19 | use crate::operations::Op; | |
| ⋯ | |||
| 536 | 537 | Op::ImportIssue, | |
| 537 | 538 | ], | |
| 538 | 539 | ), | |
| 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 | + | ), | |
| 539 | 563 | ]; | |
| 540 | 564 | ||
| 541 | 565 | /// The section of the API reference an operation is listed under. | |
| ⋯ | |||
| 752 | 776 | Op::DeployKeys(op) => op.title(), | |
| 753 | 777 | Op::Mirrors(op) => op.title(), | |
| 754 | 778 | Op::Packages(op) => op.title(), | |
| 779 | + | Op::Folios(op) => op.title(), | |
| 755 | 780 | } | |
| 756 | 781 | } | |
| 757 | 782 | ||
| 33 | 33 | use crate::mirrors::MirrorsOp; | |
| 34 | 34 | use crate::deployments::DeploymentsOp; | |
| 35 | 35 | use crate::packages::PackagesOp; | |
| 36 | + | use crate::folios::FoliosOp; | |
| 36 | 37 | use crate::protection::ProtectionOp; | |
| 37 | 38 | use crate::token_policy::TokenOp; | |
| 38 | 39 | use crate::rules::RulesOp; | |
| ⋯ | |||
| 68 | 69 | pub deployments: Fetcher, | |
| 69 | 70 | /// Packages: their settings, versions, deleting and restoring them. | |
| 70 | 71 | 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, | |
| 71 | 75 | /// Where the request came in, for its audit entries. | |
| 72 | 76 | pub audit: crate::audit::AuditContext, | |
| 73 | 77 | /// Set for a request made with an agent's token: all it may do. | |
| ⋯ | |||
| 94 | 98 | projects: env.service("PROJECTS")?, | |
| 95 | 99 | deployments: env.service("DEPLOYMENTS")?, | |
| 96 | 100 | packages: env.service("PACKAGES")?, | |
| 101 | + | docs: env.service("DOCS")?, | |
| 97 | 102 | scope: None, | |
| 98 | 103 | audit: crate::audit::AuditContext::default(), | |
| 99 | 104 | addresses: crate::addresses::Addresses::from_env(env), | |
| ⋯ | |||
| 321 | 326 | /// A workspace's packages, their versions, deleting and restoring | |
| 322 | 327 | /// them, and who may use them: packages.rs. | |
| 323 | 328 | Packages(PackagesOp), | |
| 329 | + | /// Artifacts mode's docs, slides, designs and dashboards, kept by the | |
| 330 | + | /// docs service: folios.rs. | |
| 331 | + | Folios(FoliosOp), | |
| 324 | 332 | } | |
| 325 | 333 | ||
| 326 | 334 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| ⋯ | |||
| 690 | 698 | } | |
| 691 | 699 | ||
| 692 | 700 | impl Op { | |
| 693 | − | pub const ALL: [Op; 328] = [ | |
| 701 | + | pub const ALL: [Op; 345] = [ | |
| 694 | 702 | Op::Whoami, | |
| 695 | 703 | Op::GetWorkspace, | |
| 696 | 704 | Op::CreateWorkspace, | |
| ⋯ | |||
| 1019 | 1027 | Op::Packages(PackagesOp::RestorePackage), | |
| 1020 | 1028 | Op::Packages(PackagesOp::DeleteVersion), | |
| 1021 | 1029 | 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), | |
| 1022 | 1047 | ]; | |
| 1023 | 1048 | ||
| 1024 | 1049 | pub fn by_name(name: &str) -> Option<Op> { | |
| ⋯ | |||
| 1231 | 1256 | Op::DeployKeys(op) => op.name(), | |
| 1232 | 1257 | Op::Mirrors(op) => op.name(), | |
| 1233 | 1258 | Op::Packages(op) => op.name(), | |
| 1259 | + | Op::Folios(op) => op.name(), | |
| 1234 | 1260 | } | |
| 1235 | 1261 | } | |
| 1236 | 1262 | ||
| ⋯ | |||
| 1787 | 1813 | Op::DeployKeys(op) => op.description(), | |
| 1788 | 1814 | Op::Mirrors(op) => op.description(), | |
| 1789 | 1815 | Op::Packages(op) => op.description(), | |
| 1816 | + | Op::Folios(op) => op.description(), | |
| 1790 | 1817 | } | |
| 1791 | 1818 | } | |
| 1792 | 1819 | ||
| ⋯ | |||
| 3272 | 3299 | Op::DeployKeys(op) => op.input(), | |
| 3273 | 3300 | Op::Mirrors(op) => op.input(), | |
| 3274 | 3301 | Op::Packages(op) => op.input(), | |
| 3302 | + | Op::Folios(op) => op.input(), | |
| 3275 | 3303 | } | |
| 3276 | 3304 | } | |
| 3277 | 3305 | ||
| ⋯ | |||
| 3346 | 3374 | if let Op::Packages(_) = self { | |
| 3347 | 3375 | return false; | |
| 3348 | 3376 | } | |
| 3377 | + | // An artifact belongs to its workspace. | |
| 3378 | + | if let Op::Folios(_) = self { | |
| 3379 | + | return false; | |
| 3380 | + | } | |
| 3349 | 3381 | if let Op::About(op) = self { | |
| 3350 | 3382 | return op.needs_repo(); | |
| 3351 | 3383 | } | |
| ⋯ | |||
| 5592 | 5624 | Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await, | |
| 5593 | 5625 | Op::Mirrors(op) => crate::mirrors::run(op, services, viewer, input).await, | |
| 5594 | 5626 | Op::Packages(op) => crate::packages::run(op, services, viewer, input).await, | |
| 5627 | + | Op::Folios(op) => crate::folios::run(op, services, viewer, input).await, | |
| 5595 | 5628 | Op::ReopenSecurityAlert => { | |
| 5596 | 5629 | let changed: Outcome<AlertChange> = call( | |
| 5597 | 5630 | &services.security, | |
| 12593 | 12593 | "id": "rmt_01kp4b3c4d5e6f7g8h9j0k1m2n" | |
| 12594 | 12594 | }, | |
| 12595 | 12595 | "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." | |
| 12596 | 13276 | } | |
| 12597 | 13277 | } |
| 127 | 127 | Op::DeployKeys(DeployKeysOp::DeleteDeployKey) => return through::<bool>(op, as_is), | |
| 128 | 128 | // Packages are shaped by the API itself, in `snake_case`. | |
| 129 | 129 | Op::Packages(_) => return as_is, | |
| 130 | + | // Artifacts are shaped by the API itself, in `snake_case`. | |
| 131 | + | Op::Folios(_) => return as_is, | |
| 130 | 132 | // Built by the API itself, in `snake_case`. | |
| 131 | 133 | Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is), | |
| 132 | 134 | Op::DismissSecurityAlert | Op::ReopenSecurityAlert => { |
| 8 | 8 | use crate::mirrors::MirrorsOp; | |
| 9 | 9 | use crate::deployments::DeploymentsOp; | |
| 10 | 10 | use crate::packages::PackagesOp; | |
| 11 | + | use crate::folios::FoliosOp; | |
| 11 | 12 | use crate::protection::ProtectionOp; | |
| 12 | 13 | use crate::token_policy::TokenOp; | |
| 13 | 14 | use crate::operations::Op; | |
| ⋯ | |||
| 961 | 962 | Op::MergePullRequest, | |
| 962 | 963 | &[], | |
| 963 | 964 | ), | |
| 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), &[]), | |
| 964 | 984 | ]; | |
| 965 | 985 | ||
| 966 | 986 | impl Route { | |
| 24 | 24 | use crate::mirrors::MirrorsOp; | |
| 25 | 25 | use crate::deployments::DeploymentsOp; | |
| 26 | 26 | use crate::packages::PackagesOp; | |
| 27 | + | use crate::folios::FoliosOp; | |
| 27 | 28 | use crate::protection::ProtectionOp; | |
| 28 | 29 | use crate::token_policy::TokenOp; | |
| 29 | 30 | use crate::operations::Op; | |
| ⋯ | |||
| 525 | 526 | a("decline_repository_invitation", Op::DeclineRepoInvitation, "Decline one"), | |
| 526 | 527 | ], | |
| 527 | 528 | }, | |
| 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 | + | }, | |
| 528 | 554 | ]; | |
| 529 | 555 | ||
| 530 | 556 | /// Operations that cannot be undone, or reach beyond g1t's own records: | |
| ⋯ | |||
| 561 | 587 | | Op::RemoveRunner | |
| 562 | 588 | | Op::DeleteRunnerGroup | |
| 563 | 589 | | Op::UpdateRunnerSettings | |
| 590 | + | | Op::Folios(FoliosOp::SetAccess | FoliosOp::Purge) | |
| 564 | 591 | ) | |
| 565 | 592 | } | |
| 566 | 593 | ||
| ⋯ | |||
| 811 | 838 | assert!(tool.action(default).is_some(), "{}", tool.name); | |
| 812 | 839 | } | |
| 813 | 840 | } | |
| 814 | − | assert!(TOOLS.len() <= 18, "{} tools", TOOLS.len()); | |
| 841 | + | assert!(TOOLS.len() <= 19, "{} tools", TOOLS.len()); | |
| 815 | 842 | } | |
| 816 | 843 | ||
| 817 | 844 | #[test] | |
| ⋯ | |||
| 992 | 1019 | assert!(!reads_only(Op::RequestReviewers)); | |
| 993 | 1020 | } | |
| 994 | 1021 | ||
| 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 | + | ||
| 995 | 1066 | /// How much smaller `tools/list` is than one tool per operation. Run | |
| 996 | 1067 | /// with `--nocapture` to see the numbers. | |
| 997 | 1068 | #[test] | |
| 33 | 33 | // A person's pinned projects: list_pinned_projects and changing them. | |
| 34 | 34 | { "binding": "PROJECTS", "service": "g1t-projects" }, | |
| 35 | 35 | // 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" } | |
| 37 | 39 | ], | |
| 38 | 40 | // GitHub Actions artifacts older runners kept, in chunks, with KV's own | |
| 39 | 41 | // expiry, read until it passes (and cache |
| 782 | 782 | | Billing | read, read and write | `billing:read`, `billing:write` | | |
| 783 | 783 | | Self-hosted runners | read, admin | `runners:read`, `runners:admin` | | |
| 784 | 784 | | AI Gateway | read, read and write | `models:read`, `models:write` | | |
| 785 | + | | Artifacts | read, read and write, admin | `artifacts:read`, `artifacts:write`, `artifacts:admin` | | |
| 785 | 786 | ||
| 786 | 787 | Account permissions are about you, wherever you are, and only a personal | |
| 787 | 788 | token can hold them: | |
| ⋯ | |||
| 886 | 887 | | `runners:admin` | Register and remove self-hosted runners, change their groups and settings | | |
| 887 | 888 | | `models:read` | See the workspace's [AI Gateway](/guides/ai-gateway/) requests: their models, tokens, cost and status | | |
| 888 | 889 | | `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. | | |
| 889 | 893 | ||
| 890 | 894 | Every operation of the API and the MCP server needs exactly one of these, | |
| 891 | 895 | except `whoami` (`GET /user`), which any token may use. Each endpoint's page | |
| ⋯ | |||
| 1002 | 1006 | personal token, starting on the CI preset. A workspace token reaches all | |
| 1003 | 1007 | of that workspace's repositories, or the ones chosen, never another | |
| 1004 | 1008 | workspace, and cannot manage people, tokens or workspaces. It holds no | |
| 1005 | − | account permissions. | |
| 1009 | + | account permissions, and cannot use artifacts, which always belong to a | |
| 1010 | + | person. | |
| 1006 | 1011 | ||
| 1007 | 1012 | It has the Write role on the workspace's repositories, as a member does: | |
| 1008 | 1013 | it pushes, merges and works on issues and pull requests, within its | |
| 125 | 125 | `number`. [MCP tools](/reference/mcp/) lists every tool and action with its | |
| 126 | 126 | required inputs, its scope and its REST route. | |
| 127 | 127 | ||
| 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 | + | ||
| 128 | 167 | ## Staying out of each other's way | |
| 129 | 168 | ||
| 130 | 169 | `pull_request` with `get` returns `overlaps`: other pull requests in progress that |
| 3 | 3 | description: The g1t MCP server's resource tools, each action they take with its required inputs and scope, and how to call them. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 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 | |
| 7 | 7 | thing on g1t: `search`, `repository`, `issue`, `pull_request`, `agent`, | |
| 8 | 8 | `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 | |
| 10 | 10 | action is the same operation as a route of the [REST API](/reference/api/), | |
| 11 | 11 | with the same inputs, permissions and results, so the two always agree. | |
| 12 | 12 | ||
| ⋯ | |||
| 169 | 169 | | --- | --- | | |
| 170 | 170 | | `title` | The tool's name for people, such as `Pull requests`. | | |
| 171 | 171 | | `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. | | |
| 173 | 173 | | `idempotentHint` | The same as `readOnlyHint`. | | |
| 174 | 174 | | `openWorldHint` | Always `false`. | | |
| 175 | 175 | ||
| ⋯ | |||
| 768 | 768 | | [`decline_repository_invitation`](/reference/api/access/decline-repo-invitation/) | Decline one. | `id` | `account:write` | | |
| 769 | 769 | ||
| 770 | 770 | ||
| 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 | + | ||
| 771 | 802 | ## What g1t can use | |
| 772 | 803 | ||
| 773 | 804 | g1t works with a [run credential](/guides/working-with-g1t/#credentials): | |
| ⋯ | |||
| 792 | 823 | reopens a security alert, or the `security` actions that decide about | |
| 793 | 824 | security: `update_secret_alert`, `bypass`, `review_bypass`, the pattern | |
| 794 | 825 | 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 | |
| 796 | 828 | names must be its own. `tools/list` shows such a token only the tools and | |
| 797 | 829 | actions it may use; a call to any other is refused with the rule that | |
| 798 | 830 | refused it, and recorded in the workspace's [audit log](/guides/audit-log/), | |
Binary or large file; its contents are not shown.
| 478 | 478 | "restore_package", | |
| 479 | 479 | "delete_package_version", | |
| 480 | 480 | "restore_package_version", | |
| 481 | + | // Sharing an artifact and deleting one for good are for people: an | |
| 482 | + | // agent shares only through the agents service, with the people | |
| 483 | + | // already in its conversation (docs/ARTIFACTS_MODE.md, section 4.3). | |
| 484 | + | "set_workspace_artifact_access", | |
| 485 | + | "purge_workspace_artifact", | |
| 481 | 486 | ]; | |
| 482 | 487 | ||
| 483 | 488 | /// Reading what an agent needs to know about its repository. |
| 13 | 13 | //! shapes are snake_case; the tests keep field names, RPC methods and | |
| 14 | 14 | //! validators the same as the TypeScript (`folios.fixtures.json`). | |
| 15 | 15 | //! | |
| 16 | − | //! Nothing uses this yet: Phase 1 publishes the events, Phase 3 the API. | |
| 16 | + | //! The API (`apps/api/src/folios.rs`) calls the RPC with these; the docs | |
| 17 | + | //! service publishes the events. | |
| 17 | 18 | ||
| 18 | 19 | use serde::{Deserialize, Serialize}; | |
| 19 | 20 | use serde_json::Value; |
| 44 | 44 | Runners, | |
| 45 | 45 | Models, | |
| 46 | 46 | /// Artifacts mode's docs, slides, designs and dashboards (folios in | |
| 47 | − | /// code). Not offered yet: see [`Resource::offered`]. | |
| 47 | + | /// code): the `artifact` MCP tool and `/workspaces/{ws}/artifacts`. | |
| 48 | + | /// Not workflow runs' artifacts, which are `workflows:*`. | |
| 48 | 49 | Artifacts, | |
| 49 | 50 | } | |
| 50 | 51 | ||
| ⋯ | |||
| 129 | 130 | } | |
| 130 | 131 | } | |
| 131 | 132 | ||
| 132 | − | /// Whether tokens are offered it yet. A resource that is not is in the | |
| 133 | − | /// table (so its scopes parse, and the TypeScript mirror lists it under | |
| 134 | − | /// `UPCOMING_RESOURCES`) but nothing hands it out: presets, full | |
| 135 | − | /// access, OAuth and the token form leave it out, and no operation | |
| 136 | − | /// needs it. Artifacts is offered once its API ships (Phase 3 of | |
| 137 | − | /// docs/ARTIFACTS_MODE.md). | |
| 133 | + | /// Whether tokens are offered it yet. A resource being built can be in | |
| 134 | + | /// the table before its API ships (so its scopes parse, and the | |
| 135 | + | /// TypeScript mirror lists it under `UPCOMING_RESOURCES`) while nothing | |
| 136 | + | /// hands it out: presets, full access, OAuth and the token form leave | |
| 137 | + | /// it out, and no operation needs it. Every resource is offered now; | |
| 138 | + | /// Artifacts was the last, until Phase 3 of docs/ARTIFACTS_MODE.md. | |
| 138 | 139 | pub fn offered(self) -> bool { | |
| 139 | − | !matches!(self, Resource::Artifacts) | |
| 140 | + | let _ = self; | |
| 141 | + | true | |
| 140 | 142 | } | |
| 141 | 143 | } | |
| 142 | 144 | ||
| ⋯ | |||
| 1225 | 1227 | // The AI Gateway. Sending a request to a model needs `models:write`, | |
| 1226 | 1228 | // checked by the model proxy at models.g1t.sh, not here. | |
| 1227 | 1229 | ("list_gateway_requests", Scope::ModelsRead), | |
| 1230 | + | // Artifacts mode's docs, slides, designs and dashboards (the `artifact` | |
| 1231 | + | // MCP tool). Reading takes artifacts:read, making and changing them | |
| 1232 | + | // artifacts:write, and sharing them or deleting them for good | |
| 1233 | + | // artifacts:admin. The docs service then checks the person's own role | |
| 1234 | + | // on each one. | |
| 1235 | + | ("list_workspace_artifacts", Scope::ArtifactsRead), | |
| 1236 | + | ("search_workspace_artifacts", Scope::ArtifactsRead), | |
| 1237 | + | ("get_workspace_artifact", Scope::ArtifactsRead), | |
| 1238 | + | ("get_workspace_artifact_content", Scope::ArtifactsRead), | |
| 1239 | + | ("list_workspace_artifact_versions", Scope::ArtifactsRead), | |
| 1240 | + | ("get_workspace_artifact_access", Scope::ArtifactsRead), | |
| 1241 | + | ("list_workspace_artifact_templates", Scope::ArtifactsRead), | |
| 1242 | + | ("list_workspace_artifact_spaces", Scope::ArtifactsRead), | |
| 1243 | + | ("query_workspace_dataset", Scope::ArtifactsRead), | |
| 1244 | + | ("create_workspace_artifact", Scope::ArtifactsWrite), | |
| 1245 | + | ("update_workspace_artifact", Scope::ArtifactsWrite), | |
| 1246 | + | ("edit_workspace_artifact", Scope::ArtifactsWrite), | |
| 1247 | + | ("trash_workspace_artifact", Scope::ArtifactsWrite), | |
| 1248 | + | ("restore_workspace_artifact", Scope::ArtifactsWrite), | |
| 1249 | + | ("restore_workspace_artifact_version", Scope::ArtifactsWrite), | |
| 1250 | + | ("set_workspace_artifact_access", Scope::ArtifactsAdmin), | |
| 1251 | + | ("purge_workspace_artifact", Scope::ArtifactsAdmin), | |
| 1228 | 1252 | ]; | |
| 1229 | 1253 | ||
| 1230 | 1254 | /// Operations any token may use: saying who it is. | |
| ⋯ | |||
| 1496 | 1520 | } | |
| 1497 | 1521 | ||
| 1498 | 1522 | #[test] | |
| 1499 | − | fn artifacts_scopes_exist_but_are_not_offered_yet() { | |
| 1523 | + | fn artifacts_are_offered_read_by_presets_and_shared_only_with_admin() { | |
| 1500 | 1524 | for scope in [Scope::ArtifactsRead, Scope::ArtifactsWrite, Scope::ArtifactsAdmin] { | |
| 1501 | 1525 | assert_eq!(scope.resource(), Resource::Artifacts); | |
| 1502 | − | assert!(!scope.offered()); | |
| 1503 | − | assert_eq!(Scope::parse(scope.as_str()), Some(scope)); | |
| 1504 | − | // Nothing hands it out: not presets, full access, OAuth or the form. | |
| 1505 | − | for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] { | |
| 1506 | − | assert!(!preset.scopes().unwrap().contains(&scope), "{}", preset.as_str()); | |
| 1507 | − | } | |
| 1508 | − | assert!(!everything().contains(&scope)); | |
| 1509 | − | assert!(!offered_scopes().contains(&scope)); | |
| 1510 | − | assert!(!oauth_default().contains(&scope)); | |
| 1511 | − | assert!(parse_scopes(scope.as_str()).is_empty()); | |
| 1512 | − | // And no operation needs it yet. | |
| 1513 | − | assert!(OPERATIONS.iter().all(|(_, needed)| *needed != scope)); | |
| 1526 | + | assert!(scope.offered()); | |
| 1527 | + | assert!(offered_scopes().contains(&scope)); | |
| 1528 | + | assert_eq!(parse_scopes(scope.as_str()), vec![scope]); | |
| 1529 | + | assert!(OPERATIONS.iter().any(|(_, needed)| *needed == scope), "{scope:?} gates nothing"); | |
| 1530 | + | } | |
| 1531 | + | // Reading them is a read like any other; changing them is chosen. | |
| 1532 | + | for preset in [Preset::ReadOnly, Preset::Agent] { | |
| 1533 | + | let scopes = preset.scopes().unwrap(); | |
| 1534 | + | assert!(scopes.contains(&Scope::ArtifactsRead), "{}", preset.as_str()); | |
| 1535 | + | assert!(!scopes.contains(&Scope::ArtifactsWrite) && !scopes.contains(&Scope::ArtifactsAdmin), "{}", preset.as_str()); | |
| 1514 | 1536 | } | |
| 1537 | + | assert!(!Preset::Ci.scopes().unwrap().contains(&Scope::ArtifactsRead)); | |
| 1538 | + | assert!(everything().contains(&Scope::ArtifactsAdmin)); | |
| 1515 | 1539 | assert!(Scope::ArtifactsAdmin.includes(Scope::ArtifactsWrite)); | |
| 1516 | 1540 | assert!(Scope::ArtifactsAdmin.dangerous()); | |
| 1541 | + | assert!(!Scope::ArtifactsWrite.dangerous()); | |
| 1517 | 1542 | assert_eq!(Resource::Artifacts.group(), ResourceGroup::Workspace); | |
| 1518 | − | let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "read".to_owned())]); | |
| 1519 | − | assert_eq!(resolve_permissions(&asked, true), Err("There is no permission called artifacts.".to_owned())); | |
| 1520 | − | assert_eq!(offered_scopes().len(), Scope::ALL.len() - 3); | |
| 1543 | + | let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "write".to_owned())]); | |
| 1544 | + | assert_eq!(resolve_permissions(&asked, true), Ok(vec![Scope::ArtifactsWrite])); | |
| 1545 | + | assert_eq!(offered_scopes().len(), Scope::ALL.len()); | |
| 1546 | + | // Sharing and deleting for good need admin; editing needs write. | |
| 1547 | + | assert_eq!(scope_for("set_workspace_artifact_access"), Some(Scope::ArtifactsAdmin)); | |
| 1548 | + | assert_eq!(scope_for("purge_workspace_artifact"), Some(Scope::ArtifactsAdmin)); | |
| 1549 | + | assert_eq!(scope_for("edit_workspace_artifact"), Some(Scope::ArtifactsWrite)); | |
| 1550 | + | assert_eq!(scope_for("get_workspace_artifact_content"), Some(Scope::ArtifactsRead)); | |
| 1551 | + | let writer = token(&[Scope::ArtifactsWrite]); | |
| 1552 | + | assert!(decide(&writer, "edit_workspace_artifact", &json!({})).allowed); | |
| 1553 | + | assert!(decide(&writer, "list_workspace_artifacts", &json!({})).allowed, "write includes read"); | |
| 1554 | + | assert!(decide(&writer, "set_workspace_artifact_access", &json!({})).reason.unwrap().contains("artifacts:admin")); | |
| 1555 | + | // Workflow runs' artifacts are another thing, with their own scope. | |
| 1556 | + | let reader = token(&[Scope::ArtifactsRead]); | |
| 1557 | + | assert!(!decide(&reader, "list_artifacts", &json!({ "repo": "acme/web" })).allowed); | |
| 1558 | + | assert!(!decide(&token(&[Scope::WorkflowsRead]), "list_workspace_artifacts", &json!({})).allowed); | |
| 1521 | 1559 | } | |
| 1522 | 1560 | ||
| 1523 | 1561 | #[test] | |
| ⋯ | |||
| 1791 | 1829 | let offered: Vec<String> = Scope::ALL.iter().filter(|scope| scope.offered()).map(|scope| scope.as_str().to_owned()).collect(); | |
| 1792 | 1830 | let upcoming: Vec<String> = Scope::ALL.iter().filter(|scope| !scope.offered()).map(|scope| scope.as_str().to_owned()).collect(); | |
| 1793 | 1831 | assert_eq!(names("export const SCOPES = ["), offered); | |
| 1794 | − | assert_eq!(names("export const UPCOMING_SCOPES = ["), upcoming); | |
| 1832 | + | assert_eq!(names("export const UPCOMING_SCOPES"), upcoming); | |
| 1795 | 1833 | let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [") | |
| 1796 | 1834 | .lines() | |
| 1797 | 1835 | .filter_map(|line| { | |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.