pr_01m47d24b0e6n91zwymwxg0vpx/apps/api/src/operations.ts

350 lines10,472 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.",
72 input: { type: "object", properties: {} },
73 run: authed(async (_env, user) => ok(user)),
74 },
75 {
76 name: "list_repos",
77 description: "Repositories you can see, optionally filtered by a search query.",
78 input: {
79 type: "object",
80 properties: { query: { type: "string", description: "Matches name or description." } },
81 },
82 run: async (env, viewer, input) =>
83 ok(await env.REPOS.list(viewer, { query: text(input, "query") })),
84 },
85 {
86 name: "get_repo",
87 description: "One repository's details.",
88 input: { type: "object", properties: { repo: REPO }, required: ["repo"] },
89 run: async (env, viewer, input) => {
90 const path = repoPath(input);
91 return path ? env.REPOS.get(path, viewer) : BAD_REPO;
92 },
93 },
94 {
95 name: "create_repo",
96 description: "Create a repository under your account.",
97 input: {
98 type: "object",
99 properties: {
100 name: { type: "string" },
101 description: { type: "string" },
102 private: { type: "boolean" },
103 },
104 required: ["name"],
105 },
106 run: authed((env, user, input) =>
107 env.REPOS.create(user, {
108 name: text(input, "name"),
109 description: text(input, "description"),
110 isPrivate: input.private === true,
111 }),
112 ),
113 },
114 {
115 name: "list_intents",
116 description:
117 "Intents on a repository. An intent is a goal that agents attempt in parallel; it replaces issues and pull requests.",
118 input: {
119 type: "object",
120 properties: {
121 repo: REPO,
122 status: { type: "string", enum: ["open", "shipped", "withdrawn"] },
123 },
124 required: ["repo"],
125 },
126 run: async (env, viewer, input) => {
127 const path = repoPath(input);
128 const status = text(input, "status");
129 if (!path) return BAD_REPO;
130 return env.WORK.listIntents(
131 path,
132 viewer,
133 status === "open" || status === "shipped" || status === "withdrawn"
134 ? status
135 : undefined,
136 );
137 },
138 },
139 {
140 name: "get_intent",
141 description:
142 "An intent's brief, its acceptance checks, and every attempt made at it so far. Read this before starting an attempt.",
143 input: {
144 type: "object",
145 properties: { repo: REPO, number: { type: "integer" } },
146 required: ["repo", "number"],
147 },
148 run: async (env, viewer, input) => {
149 const path = repoPath(input);
150 return path
151 ? env.WORK.getIntent(path, Number(input.number), viewer)
152 : BAD_REPO;
153 },
154 },
155 {
156 name: "open_intent",
157 description: "State a new goal for a repository.",
158 input: {
159 type: "object",
160 properties: {
161 repo: REPO,
162 title: { type: "string", description: "The goal in one line." },
163 brief: {
164 type: "string",
165 description: "What an agent needs to do the work: outcome, constraints, context.",
166 },
167 checks: {
168 type: "array",
169 items: { type: "string" },
170 description: "Commands that must pass for an attempt to be accepted.",
171 },
172 },
173 required: ["repo", "title", "brief"],
174 },
175 run: authed(async (env, user, input) => {
176 const path = repoPath(input);
177 if (!path) return BAD_REPO;
178 return env.WORK.openIntent(user, path, {
179 title: text(input, "title"),
180 brief: text(input, "brief"),
181 checks: Array.isArray(input.checks) ? input.checks.map(String) : [],
182 });
183 }),
184 },
185 {
186 name: "start_attempt",
187 description:
188 "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.",
189 input: {
190 type: "object",
191 properties: {
192 intent_id: { type: "string" },
193 agent: {
194 type: "string",
195 description: 'A label for the agent doing the work, e.g. "claude-code".',
196 },
197 },
198 required: ["intent_id"],
199 },
200 run: authed(async (env, user, input) => {
201 const started = await env.WORK.startAttempt(user, text(input, "intent_id"), {
202 agent: text(input, "agent") || "agent",
203 runtime: "external",
204 });
205 if (!started.ok) return started;
206 const { fork } = started.value;
207 return ok({
208 attempt: started.value,
209 git: {
210 remote: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
211 username: user.username,
212 password: "your g1t access token",
213 },
214 });
215 }),
216 },
217 {
218 name: "get_attempt",
219 description: "An attempt's status, head commit and the intent it belongs to.",
220 input: {
221 type: "object",
222 properties: { attempt_id: { type: "string" } },
223 required: ["attempt_id"],
224 },
225 run: (env, viewer, input) =>
226 env.WORK.getAttempt(text(input, "attempt_id"), viewer),
227 },
228 {
229 name: "record_session",
230 description:
231 "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.",
232 input: {
233 type: "object",
234 properties: {
235 attempt_id: { type: "string" },
236 entries: {
237 type: "array",
238 items: {
239 type: "object",
240 properties: {
241 kind: {
242 type: "string",
243 enum: ["prompt", "message", "tool_call", "tool_result", "note"],
244 },
245 text: { type: "string" },
246 tool: { type: "string", description: "Tool name, for tool entries." },
247 },
248 required: ["kind", "text"],
249 },
250 },
251 },
252 required: ["attempt_id", "entries"],
253 },
254 run: authed(async (env, user, input) => {
255 if (!Array.isArray(input.entries)) {
256 return fail("invalid", "entries must be an array.");
257 }
258 return env.WORK.appendSession(
259 user,
260 text(input, "attempt_id"),
261 input.entries as NewSessionEntry[],
262 );
263 }),
264 },
265 {
266 name: "read_session",
267 description: "The recorded session of an attempt, oldest entry first.",
268 input: {
269 type: "object",
270 properties: {
271 attempt_id: { type: "string" },
272 after: { type: "integer", description: "Only entries after this sequence number." },
273 },
274 required: ["attempt_id"],
275 },
276 run: (env, viewer, input) =>
277 env.WORK.readSession(
278 text(input, "attempt_id"),
279 viewer,
280 Number(input.after) || 0,
281 ),
282 },
283 {
284 name: "submit_attempt",
285 description:
286 "Mark an attempt ready to be compared and shipped. Push your commits first. The summary should say what changed and why.",
287 input: {
288 type: "object",
289 properties: { attempt_id: { type: "string" }, summary: { type: "string" } },
290 required: ["attempt_id", "summary"],
291 },
292 run: authed((env, user, input) =>
293 env.WORK.submitAttempt(user, text(input, "attempt_id"), text(input, "summary")),
294 ),
295 },
296 {
297 name: "abandon_attempt",
298 description: "Give up on an attempt.",
299 input: {
300 type: "object",
301 properties: { attempt_id: { type: "string" } },
302 required: ["attempt_id"],
303 },
304 run: authed((env, user, input) =>
305 env.WORK.abandonAttempt(user, text(input, "attempt_id")),
306 ),
307 },
308 {
309 name: "ship_attempt",
310 description:
311 "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.",
312 input: {
313 type: "object",
314 properties: { attempt_id: { type: "string" } },
315 required: ["attempt_id"],
316 },
317 run: authed((env, user, input) =>
318 env.WORK.shipAttempt(user, text(input, "attempt_id")),
319 ),
320 },
321 {
322 name: "list_events",
323 description:
324 "The timeline of a repository: pushes, intents, attempts and session activity, newest first.",
325 input: {
326 type: "object",
327 properties: {
328 repo: REPO,
329 before: { type: "string", description: "Event id to page back from." },
330 },
331 required: ["repo"],
332 },
333 run: async (env, viewer, input) => {
334 const path = repoPath(input);
335 if (!path) return BAD_REPO;
336 const repo = await env.REPOS.get(path, viewer);
337 if (!repo.ok) return repo;
338 return ok(
339 await env.EVENTS.list({
340 repoId: repo.value.id,
341 before: text(input, "before") || undefined,
342 }),
343 );
344 },
345 },
346];
347
348export const operationsByName = new Map(
349 operations.map((operation) => [operation.name, operation]),
350);