Skip to content
1,053 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge main into Artifacts Phase 21//! Artifacts over REST and MCP: a workspace's docs, slides, designs and
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.2//! dashboards (Artifacts mode), at
Merge main into Artifacts Phase 23//! `/workspaces/{workspace}/artifacts` and as the `artifact` MCP tool.
4//! Code calls them folios; people, URLs, the tool and the scopes say
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.5//! "artifact". Workflow runs' artifacts are something else (run_artifacts.rs).
Merge main into Artifacts Phase 26//!
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.7//! The artifacts service (`services/artifacts`, the `ARTIFACTS` binding) decides who may
Merge main into Artifacts Phase 28//! 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
15use g1t_contracts::datasets::DatasetQuery;
16use g1t_contracts::folios::*;
17use g1t_contracts::identity::UsernameArgs;
18use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer};
19use serde::Serialize;
20use serde::de::DeserializeOwned;
21use serde_json::{Map, Value, json};
22use worker::Result;
23
24use crate::operations::Services;
25
26/// One operation on artifacts.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub 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
48impl 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
275fn 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).
280fn 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
287fn 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.
293pub(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
300fn 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
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.309/// A doc edit's target, as the artifacts service reads it: a string is the
Merge main into Artifacts Phase 2310/// kind, an object passes as given. Left out, append.
311fn 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.
320pub(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.
346pub(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
394fn 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
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.406/// A person, agent or team, from an artifacts service profile.
Merge main into Artifacts Phase 2407pub(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
420fn people_json(list: &Value) -> Value {
421 Value::Array(list.as_array().map(|list| list.iter().map(person_json).collect()).unwrap_or_default())
422}
423
424fn 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.
432pub(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.
444pub(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
479fn 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
494fn 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
507fn 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
520fn 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
531fn 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
563fn 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
575fn 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
590fn 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
598fn 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
605fn 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
611async fn call<T: DeserializeOwned>(services: &Services, method: &str, args: &impl Serialize) -> Result<Outcome<T>> {
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.612 g1t_kit::call(&services.artifacts, method, args).await
Merge main into Artifacts Phase 2613}
614
615/// The person acting, or the refusal: nobody, or a workspace's own token.
616pub(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`.
629async 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
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.652/// Who a share is for, as the artifacts service names them.
Merge main into Artifacts Phase 2653async 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
675pub 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)]
872mod 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"));
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.946 // Other kinds carry ops, which the artifacts service checks (and refuses
Merge main into Artifacts Phase 2947 // 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}

This file's history is long; its oldest lines are credited to the oldest commit read.