Skip to content
153 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.

API: pinned projects over REST and MCP1//! Pinned projects: the projects a person keeps at the top of a
2//! workspace's sidebar, in their order, up to eight a workspace. The
3//! projects service keeps them; this is their public shape, in snake_case.
4//!
5//! A person's own: a personal access token or a session, never a
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.6//! workspace's token or a run's token.
API: pinned projects over REST and MCP7
8use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Viewer};
9use serde_json::{Value, json};
10use worker::Result;
11
12use crate::operations::{Op, Services};
13
14fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
15 Ok(Outcome::fail(code, message))
16}
17
18fn text(input: &Value, key: &str) -> Option<String> {
19 input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_lowercase)
20}
21
22/// A place in the pins, 0 first, given as a number or as digits.
23fn position(input: &Value) -> Option<u32> {
24 match &input["position"] {
25 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
26 Value::String(digits) => digits.trim().parse().ok(),
27 _ => None,
28 }
29}
30
31/// The slugs `projects` names, in order: strings, or objects with a `slug`.
32pub(crate) fn slugs(input: &Value) -> Option<Vec<String>> {
33 input["projects"].as_array().map(|items| {
34 items
35 .iter()
36 .filter_map(|item| item.as_str().or_else(|| item["slug"].as_str()))
37 .map(|slug| slug.trim().to_lowercase())
38 .filter(|slug| !slug.is_empty())
39 .collect()
40 })
41}
42
43/// A project as the projects service answers (camelCase), as the API shows
44/// a pin: its place, what it is, and where it is.
45pub(crate) fn pin_json(project: &Value, position: usize, site: &str) -> Value {
46 let workspace = project["workspace"].as_str().unwrap_or_default();
47 let slug = project["slug"].as_str().unwrap_or_default();
48 json!({
49 "position": position,
50 "id": project["id"],
51 "workspace": workspace,
52 "slug": slug,
53 "name": project["name"],
54 "description": project["description"],
55 "private": project["private"],
56 "archived": project["archived"],
57 "kind": project["kind"],
58 "url": format!("{}/{workspace}/{slug}", site.trim_end_matches('/')),
59 "pushed_at": project["pushedAt"],
60 "updated_at": project["updatedAt"],
61 })
62}
63
64fn pins_json(projects: &[Value], site: &str) -> Value {
65 Value::Array(projects.iter().enumerate().map(|(at, project)| pin_json(project, at, site)).collect())
66}
67
68/// Runs one of the pin operations.
69pub async fn run(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
70 let user = match viewer {
71 Some(user) if user.kind == PrincipalKind::User => user,
72 Some(_) => {
73 return failed(
74 FailureCode::Forbidden,
75 "Pins are a person's own: use a personal access token, not a workspace's or an agent's.",
76 );
77 }
78 None => return failed(FailureCode::Unauthenticated, "This needs a g1t access token."),
79 };
80 let Some(workspace) = text(input, "workspace") else {
81 return failed(FailureCode::Invalid, "Give the workspace's slug.");
82 };
83 let site = services.addresses.site.as_str();
84 let changed = |outcome: Outcome<Vec<Value>>| -> Result<Outcome<Value>> {
85 Ok(match outcome {
86 Outcome::Ok(projects) => Outcome::Ok(pins_json(&projects, site)),
87 Outcome::Fail(failure) => Outcome::Fail(failure),
88 })
89 };
90 match op {
91 Op::ListPinnedProjects => {
92 let shortcuts: Value = g1t_kit::call(
93 &services.projects,
94 "shortcuts",
95 &json!({ "workspace": workspace, "viewer": viewer }),
96 )
97 .await?;
98 let pinned = shortcuts["pinned"].as_array().cloned().unwrap_or_default();
99 Ok(Outcome::Ok(pins_json(&pinned, site)))
100 }
101 Op::PinProject | Op::UnpinProject => {
102 let Some(slug) = text(input, "project") else {
103 return failed(FailureCode::Invalid, "Give the project's slug.");
104 };
105 let (method, args) = if op == Op::PinProject {
106 ("pin", json!({ "actor": user, "workspace": workspace, "slug": slug, "position": position(input) }))
107 } else {
108 ("unpin", json!({ "actor": user, "workspace": workspace, "slug": slug }))
109 };
110 changed(g1t_kit::call(&services.projects, method, &args).await?)
111 }
112 Op::ReorderPinnedProjects => {
113 let Some(order) = slugs(input) else {
114 return failed(FailureCode::Invalid, "Give projects: every pinned project's slug, in the order you want them.");
115 };
116 changed(
117 g1t_kit::call(
118 &services.projects,
119 "reorder_pins",
120 &json!({ "actor": user, "workspace": workspace, "slugs": order }),
121 )
122 .await?,
123 )
124 }
125 _ => failed(FailureCode::Invalid, "Not a pin operation."),
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn a_pin_is_snake_case_with_its_place_and_address() {
135 let project = json!({
136 "id": "prj_1", "workspace": "acme", "slug": "web", "name": "Web", "description": null,
137 "private": true, "archived": false, "kind": "app", "pushedAt": "2026-10-07T10:00:00.000Z",
138 "updatedAt": "2026-10-01T10:00:00.000Z", "descriptionInherited": false,
139 });
140 let pin = pin_json(&project, 2, "https://g1t.sh/");
141 assert_eq!(pin["position"], 2);
142 assert_eq!(pin["url"], "https://g1t.sh/acme/web");
143 assert_eq!(pin["pushed_at"], "2026-10-07T10:00:00.000Z");
144 assert!(pin.get("pushedAt").is_none());
145 assert!(pin.get("descriptionInherited").is_none());
146 }
147
148 #[test]
149 fn the_order_is_read_from_slugs_or_objects() {
150 assert_eq!(slugs(&json!({ "projects": ["Web", { "slug": "api" }, ""] })), Some(vec!["web".into(), "api".into()]));
151 assert_eq!(slugs(&json!({})), None);
152 }
153}