g1t/apps/api/src/operations.ts

393 lines12,150 bytesCodeBlame
1import {
2 type EventsApi,
3 type IdentityApi,
4 type NewSessionEntry,
5 type RepoPath,
6 type ReposApi,
7 type Result,
8 type User,
9 type Viewer,
10 type WorkApi,
11 fail,
12 ok,
13} from "@g1t/contracts";
14
15export interface ApiEnv {
16 IDENTITY: IdentityApi;
17 REPOS: ReposApi;
18 WORK: WorkApi;
19 EVENTS: EventsApi;
20}
21
22type Input = Record<string, unknown>;
23
24type JsonSchema = {
25 type: "object";
26 properties: Record<string, object>;
27 required?: string[];
28};
29
30/**
31 * One thing a client can do. REST routes and MCP tools are both generated
32 * from this list, so the two surfaces cannot drift apart.
33 */
34export type Operation = {
35 name: string;
36 description: string;
37 input: JsonSchema;
38 run(env: ApiEnv, viewer: Viewer, input: Input): Promise<Result<unknown>>;
39};
40
41const SIGN_IN = fail("unauthenticated", "This needs a g1t access token.");
42
43const REPO = {
44 type: "string",
45 description: 'Repository as "owner/name", e.g. "syntaqx/hello".',
46};
47
48function text(input: Input, key: string): string {
49 const value = input[key];
50 return typeof value === "string" ? value : "";
51}
52
53function repoPath(input: Input): RepoPath | null {
54 const [namespace, name, ...rest] = text(input, "repo").split("/");
55 return namespace && name && rest.length === 0 ? { namespace, name } : null;
56}
57
58const BAD_REPO = fail("invalid", 'Give the repository as "owner/name".');
59
60/** Wraps an operation that needs a signed-in user. */
61function authed(
62 run: (env: ApiEnv, user: User, input: Input) => Promise<Result<unknown>>,
63): Operation["run"] {
64 return (env, viewer, input) =>
65 viewer ? run(env, viewer, input) : Promise.resolve(SIGN_IN);
66}
67
68export const operations: Operation[] = [
69 {
70 name: "whoami",
71 description: "The account the access token belongs to, and its workspaces.",
72 input: { type: "object", properties: {} },
73 run: authed(async (_env, user) => ok(user)),
74 },
75 {
76 name: "create_workspace",
77 description:
78 "Create a workspace. A workspace owns repositories and is the first part of their address, g1t.sh/<workspace>/<repo>. The whoami tool lists the ones you already belong to.",
79 input: {
80 type: "object",
81 properties: {
82 slug: {
83 type: "string",
84 description: "Its name in URLs: lowercase letters, digits and single hyphens.",
85 },
86 name: { type: "string", description: "A display name." },
87 },
88 required: ["slug"],
89 },
90 run: authed((env, user, input) =>
91 env.IDENTITY.createWorkspace(user, text(input, "slug"), text(input, "name")),
92 ),
93 },
94 {
95 name: "list_repos",
96 description: "Repositories you can see, optionally filtered by a search query.",
97 input: {
98 type: "object",
99 properties: { query: { type: "string", description: "Matches name or description." } },
100 },
101 run: async (env, viewer, input) =>
102 ok(await env.REPOS.list(viewer, { query: text(input, "query") })),
103 },
104 {
105 name: "get_repo",
106 description: "One repository's details.",
107 input: { type: "object", properties: { repo: REPO }, required: ["repo"] },
108 run: async (env, viewer, input) => {
109 const path = repoPath(input);
110 return path ? env.REPOS.get(path, viewer) : BAD_REPO;
111 },
112 },
113 {
114 name: "create_repo",
115 description: "Create a repository in one of your workspaces.",
116 input: {
117 type: "object",
118 properties: {
119 workspace: {
120 type: "string",
121 description:
122 "The workspace to create it in. May be left out if you belong to exactly one.",
123 },
124 name: { type: "string" },
125 description: { type: "string" },
126 private: { type: "boolean" },
127 },
128 required: ["name"],
129 },
130 run: authed((env, user, input) =>
131 env.REPOS.create(user, {
132 namespace:
133 text(input, "workspace") ||
134 (user.workspaces?.length === 1 ? user.workspaces[0].slug : ""),
135 name: text(input, "name"),
136 description: text(input, "description"),
137 isPrivate: input.private === true,
138 }),
139 ),
140 },
141 {
142 name: "list_intents",
143 description:
144 "Intents on a repository. An intent is a goal that agents attempt in parallel; it replaces issues and pull requests.",
145 input: {
146 type: "object",
147 properties: {
148 repo: REPO,
149 status: { type: "string", enum: ["open", "shipped", "withdrawn"] },
150 },
151 required: ["repo"],
152 },
153 run: async (env, viewer, input) => {
154 const path = repoPath(input);
155 const status = text(input, "status");
156 if (!path) return BAD_REPO;
157 return env.WORK.listIntents(
158 path,
159 viewer,
160 status === "open" || status === "shipped" || status === "withdrawn"
161 ? status
162 : undefined,
163 );
164 },
165 },
166 {
167 name: "get_intent",
168 description:
169 "An intent's brief, its acceptance checks, and every attempt made at it so far. Read this before starting an attempt.",
170 input: {
171 type: "object",
172 properties: { repo: REPO, number: { type: "integer" } },
173 required: ["repo", "number"],
174 },
175 run: async (env, viewer, input) => {
176 const path = repoPath(input);
177 return path
178 ? env.WORK.getIntent(path, Number(input.number), viewer)
179 : BAD_REPO;
180 },
181 },
182 {
183 name: "open_intent",
184 description: "State a new goal for a repository.",
185 input: {
186 type: "object",
187 properties: {
188 repo: REPO,
189 title: { type: "string", description: "The goal in one line." },
190 brief: {
191 type: "string",
192 description: "What an agent needs to do the work: outcome, constraints, context.",
193 },
194 checks: {
195 type: "array",
196 items: { type: "string" },
197 description: "Commands that must pass for an attempt to be accepted.",
198 },
199 },
200 required: ["repo", "title", "brief"],
201 },
202 run: authed(async (env, user, input) => {
203 const path = repoPath(input);
204 if (!path) return BAD_REPO;
205 return env.WORK.openIntent(user, path, {
206 title: text(input, "title"),
207 brief: text(input, "brief"),
208 checks: Array.isArray(input.checks) ? input.checks.map(String) : [],
209 });
210 }),
211 },
212 {
213 name: "start_attempt",
214 description:
215 "Begin working on an intent. Creates a private fork for this attempt and returns its git remote. Clone it, commit your work there, push, record your session as you go, then call submit_attempt.",
216 input: {
217 type: "object",
218 properties: {
219 intent_id: { type: "string" },
220 agent: {
221 type: "string",
222 description: 'A label for the agent doing the work, e.g. "claude-code".',
223 },
224 },
225 required: ["intent_id"],
226 },
227 run: authed(async (env, user, input) => {
228 const started = await env.WORK.startAttempt(user, text(input, "intent_id"), {
229 agent: text(input, "agent") || "agent",
230 runtime: "external",
231 });
232 if (!started.ok) return started;
233 const { fork } = started.value;
234 return ok({
235 attempt: started.value,
236 git: {
237 remote: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
238 username: user.username,
239 password: "your g1t access token",
240 },
241 });
242 }),
243 },
244 {
245 name: "get_attempt",
246 description: "An attempt's status, head commit and the intent it belongs to.",
247 input: {
248 type: "object",
249 properties: { attempt_id: { type: "string" } },
250 required: ["attempt_id"],
251 },
252 run: (env, viewer, input) =>
253 env.WORK.getAttempt(text(input, "attempt_id"), viewer),
254 },
255 {
256 name: "record_session",
257 description:
258 "Append entries to an attempt's session: the prompt you were given, your reasoning, the tools you ran. This is how people later see why a change was made, so record as you work, not only at the end.",
259 input: {
260 type: "object",
261 properties: {
262 attempt_id: { type: "string" },
263 entries: {
264 type: "array",
265 items: {
266 type: "object",
267 properties: {
268 kind: {
269 type: "string",
270 enum: ["prompt", "message", "tool_call", "tool_result", "note"],
271 },
272 text: { type: "string" },
273 tool: { type: "string", description: "Tool name, for tool entries." },
274 },
275 required: ["kind", "text"],
276 },
277 },
278 },
279 required: ["attempt_id", "entries"],
280 },
281 run: authed(async (env, user, input) => {
282 if (!Array.isArray(input.entries)) {
283 return fail("invalid", "entries must be an array.");
284 }
285 return env.WORK.appendSession(
286 user,
287 text(input, "attempt_id"),
288 input.entries as NewSessionEntry[],
289 );
290 }),
291 },
292 {
293 name: "read_session",
294 description: "The recorded session of an attempt, oldest entry first.",
295 input: {
296 type: "object",
297 properties: {
298 attempt_id: { type: "string" },
299 after: { type: "integer", description: "Only entries after this sequence number." },
300 },
301 required: ["attempt_id"],
302 },
303 run: (env, viewer, input) =>
304 env.WORK.readSession(
305 text(input, "attempt_id"),
306 viewer,
307 Number(input.after) || 0,
308 ),
309 },
310 {
311 name: "submit_attempt",
312 description:
313 "Mark an attempt ready to be compared and shipped. Push your commits first. The summary should say what changed and why.",
314 input: {
315 type: "object",
316 properties: { attempt_id: { type: "string" }, summary: { type: "string" } },
317 required: ["attempt_id", "summary"],
318 },
319 run: authed((env, user, input) =>
320 env.WORK.submitAttempt(user, text(input, "attempt_id"), text(input, "summary")),
321 ),
322 },
323 {
324 name: "abandon_attempt",
325 description: "Give up on an attempt.",
326 input: {
327 type: "object",
328 properties: { attempt_id: { type: "string" } },
329 required: ["attempt_id"],
330 },
331 run: authed((env, user, input) =>
332 env.WORK.abandonAttempt(user, text(input, "attempt_id")),
333 ),
334 },
335 {
336 name: "get_attempt_changes",
337 description:
338 "What an attempt changed: the files it touched and their line-by-line diff against the commit it started from. Use it to review an attempt or to compare several attempts at the same intent.",
339 input: {
340 type: "object",
341 properties: { attempt_id: { type: "string" } },
342 required: ["attempt_id"],
343 },
344 run: async (env, viewer, input) => {
345 const found = await env.WORK.getAttempt(text(input, "attempt_id"), viewer);
346 if (!found.ok) return found;
347 const { forkRepoId, landedBase } = found.value.attempt;
348 return env.REPOS.compare(forkRepoId, viewer, landedBase);
349 },
350 },
351 {
352 name: "ship_attempt",
353 description:
354 "Land an attempt on the repository's main branch and close its intent. Only the repository's owner can ship. Fails if main has moved since the attempt started; the attempt must then pull main into its fork and push before shipping again.",
355 input: {
356 type: "object",
357 properties: { attempt_id: { type: "string" } },
358 required: ["attempt_id"],
359 },
360 run: authed((env, user, input) =>
361 env.WORK.shipAttempt(user, text(input, "attempt_id")),
362 ),
363 },
364 {
365 name: "list_events",
366 description:
367 "The timeline of a repository: pushes, intents, attempts and session activity, newest first.",
368 input: {
369 type: "object",
370 properties: {
371 repo: REPO,
372 before: { type: "string", description: "Event id to page back from." },
373 },
374 required: ["repo"],
375 },
376 run: async (env, viewer, input) => {
377 const path = repoPath(input);
378 if (!path) return BAD_REPO;
379 const repo = await env.REPOS.get(path, viewer);
380 if (!repo.ok) return repo;
381 return ok(
382 await env.EVENTS.list({
383 repoId: repo.value.id,
384 before: text(input, "before") || undefined,
385 }),
386 );
387 },
388 },
389];
390
391export const operationsByName = new Map(
392 operations.map((operation) => [operation.name, operation]),
393);