Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-mcp/src/core.ts

Snapshot at 34e3175

Containing directory

9,077 bytes · typescriptView raw
1/** Verified core identity. The FH-012 adapter must reject owner credentials. */
2export interface AgentIdentity {
3  project: string;
4  agent: string;
5}
6/** A live, immutable D1 binding returned by core. */
7export interface ClaimBinding {
8  packet: string;
9  agent: string;
10  repoName: string;
11}
12export interface StateSnapshot {
13  claims: ClaimBinding[];
14  packets?: { id: string; mode?: "standard" | "race"; [key: string]: unknown }[];
15  races?: import("./races").RaceState[];
16  [key: string]: unknown;
17}
18export interface WorkFile {
19  path: string;
20  content: string;
21}
22/** Owner identity supplied only after FH-012 verifies its Access JWT and owner policy. */
23export interface OwnerIdentity {
24  subject: string;
25}
26export interface EnrolledAgent extends AgentIdentity {
27  apiToken: string;
28}
29
30export type CoreOperation =
31  | { kind: "state"; project: string }
32  | { kind: "me"; project: string }
33  | { kind: "refresh_token"; project: string; repoName: string; ttlSeconds: number }
34  | { kind: "activity"; project: string; after?: number; limit?: number }
35  | { kind: "join"; project: string; agent: string; ttlSeconds?: number }
36  | { kind: "claim"; project: string; packet: string; repoName?: string }
37  | {
38      kind: "usage";
39      project: string;
40      packet: string;
41      inputTokens: number;
42      outputTokens: number;
43      model?: string;
44    }
45  | { kind: "review"; project: string; packet: string; repoName: string; sha: string }
46  | { kind: "review_detail"; project: string; review: string }
47  | { kind: "review_diff"; project: string; review: string; path: string }
48  | { kind: "verdict"; project: string; packet: string; ranking: string[]; rationale: string }
49  | {
50      kind: "commit";
51      project: string;
52      packet: string;
53      repoName: string;
54      files: WorkFile[];
55      message: string;
56    };
57
58/** Narrow adapter seam: no owner HTTP route is reachable through an MCP operation. */
59export interface CoreClient {
60  authenticate(token: string, signal: AbortSignal): Promise<AgentIdentity | null>;
61  execute(token: string, operation: CoreOperation, signal: AbortSignal): Promise<unknown>;
62  /** Maps to FH-005 enrollment, using owner authority held inside the adapter. */
63  enrollAgent(
64    owner: OwnerIdentity,
65    project: string,
66    agent: string,
67    signal: AbortSignal,
68  ): Promise<EnrolledAgent>;
69}
70
71/** Public messages are fixed locally; provider exception text must never reach clients. */
72export class PublicError extends Error {
73  constructor(
74    public status: number,
75    message: string,
76  ) {
77    super(message);
78  }
79}
80export const MAX_BYTES = 1024 * 1024;
81export const CALL_TIMEOUT_MS = 25_000;
82
83export async function readBounded(
84  body: ReadableStream<Uint8Array> | null,
85  max = MAX_BYTES - 1,
86  signal?: AbortSignal,
87): Promise<string> {
88  if (!body) return "";
89  signal?.throwIfAborted();
90  const reader = body.getReader();
91  const cancel = () => {
92    void reader.cancel().catch(() => {});
93  };
94  signal?.addEventListener("abort", cancel, { once: true });
95  const chunks: Uint8Array[] = [];
96  let size = 0;
97  try {
98    while (true) {
99      const { value, done } = await reader.read();
100      signal?.throwIfAborted();
101      if (done) break;
102      size += value.byteLength;
103      if (size > max) {
104        void reader.cancel().catch(() => {});
105        throw new PublicError(413, "Payload exceeds the supported size.");
106      }
107      chunks.push(value);
108    }
109  } finally {
110    signal?.removeEventListener("abort", cancel);
111    reader.releaseLock();
112  }
113  const bytes = new Uint8Array(size);
114  let offset = 0;
115  for (const chunk of chunks) {
116    bytes.set(chunk, offset);
117    offset += chunk.length;
118  }
119  return new TextDecoder().decode(bytes);
120}
121
122/** Whole-call deadline includes authorization, core I/O, and response buffering. */
123export async function boundedCall<T>(
124  operation: (signal: AbortSignal) => Promise<T>,
125  timeoutMs = CALL_TIMEOUT_MS,
126  parent?: AbortSignal,
127): Promise<T> {
128  parent?.throwIfAborted();
129  const abort = new AbortController();
130  let onParentAbort: (() => void) | undefined;
131  let timer: ReturnType<typeof setTimeout> | undefined;
132  const timeout = new Promise<never>((_, reject) => {
133    onParentAbort = () => {
134      abort.abort();
135      reject(new PublicError(504, "Call cancelled; inspect status before retrying a write."));
136    };
137    parent?.addEventListener("abort", onParentAbort, { once: true });
138    timer = setTimeout(() => {
139      abort.abort();
140      reject(new PublicError(504, "Call timed out; inspect status before retrying a write."));
141    }, timeoutMs);
142  });
143  try {
144    return await Promise.race([operation(abort.signal), timeout]);
145  } finally {
146    clearTimeout(timer);
147    if (onParentAbort) parent?.removeEventListener("abort", onParentAbort);
148    abort.abort();
149  }
150}
151
152/** Exact FH-003/FH-005 route mapping; this Request goes to a trusted binding, never public fetch. */
153export function coreRequest(token: string, operation: CoreOperation, signal: AbortSignal): Request {
154  const component = encodeURIComponent;
155  const base = `/projects/${component(operation.project)}`;
156  let path: string;
157  let data: unknown;
158  switch (operation.kind) {
159    case "state":
160      path = `${base}/state`;
161      break;
162    case "me":
163      path = `${base}/me`;
164      break;
165    case "refresh_token":
166      path = `${base}/forks/${component(operation.repoName)}/token`;
167      data = { scope: "write", ttlSeconds: operation.ttlSeconds };
168      break;
169    case "activity": {
170      const query = new URLSearchParams();
171      if (operation.after !== undefined) query.set("after", String(operation.after));
172      if (operation.limit !== undefined) query.set("limit", String(operation.limit));
173      path = `${base}/activity?${query}`;
174      break;
175    }
176    case "join":
177      path = `${base}/join`;
178      data = { agent: operation.agent, ttlSeconds: operation.ttlSeconds };
179      break;
180    case "claim":
181      path = `${base}/packets/${component(operation.packet)}/claim`;
182      data = { repoName: operation.repoName };
183      break;
184    case "review":
185      path = `${base}/reviews`;
186      data = { repoName: operation.repoName, sha: operation.sha, packet: operation.packet };
187      break;
188    case "review_detail":
189      path = `${base}/reviews/${component(operation.review)}`;
190      break;
191    case "review_diff": {
192      const query = new URLSearchParams({ path: operation.path });
193      path = `${base}/reviews/${component(operation.review)}/diff?${query}`;
194      break;
195    }
196    case "verdict":
197      path = `${base}/races/${component(operation.packet)}/verdict`;
198      data = { ranking: operation.ranking, rationale: operation.rationale };
199      break;
200    case "usage":
201      path = `${base}/usage`;
202      data = {
203        packet: operation.packet,
204        inputTokens: operation.inputTokens,
205        outputTokens: operation.outputTokens,
206        model: operation.model,
207      };
208      break;
209    case "commit":
210      path = `${base}/forks/${component(operation.repoName)}/commit`;
211      data = { packet: operation.packet, files: operation.files, message: operation.message };
212      break;
213  }
214  const body = data === undefined ? undefined : JSON.stringify(data);
215  if (body && new TextEncoder().encode(body).byteLength > MAX_BYTES)
216    throw new PublicError(413, "Commit exceeds 1 MiB.");
217  return new Request(`https://core.invalid${path}`, {
218    method: body === undefined ? "GET" : "POST",
219    signal,
220    redirect: "manual",
221    headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
222    body,
223  });
224}
225
226/** Compose the HTTP part with trusted auth/enrollment capabilities supplied by FH-012. */
227export function createCoreClient(
228  options: Pick<CoreClient, "authenticate" | "enrollAgent"> & {
229    fetch(request: Request): Promise<Response>;
230  },
231): CoreClient {
232  return {
233    authenticate: options.authenticate,
234    enrollAgent: options.enrollAgent,
235    async execute(token, operation, signal) {
236      signal.throwIfAborted();
237      const response = await options.fetch(coreRequest(token, operation, signal));
238      // workerd supports manual redirect handling, not the Fetch "error" mode.
239      // Refuse every upstream 3xx without reading or exposing its Location/body.
240      if (response.status >= 300 && response.status < 400) {
241        void response.body?.cancel().catch(() => {});
242        throw new PublicError(502, "Core redirect refused.");
243      }
244      if (!response.ok) {
245        void response.body?.cancel().catch(() => {});
246        const status = [400, 401, 403, 404, 409, 413, 429].includes(response.status)
247          ? response.status
248          : 502;
249        throw new PublicError(
250          status,
251          status === 409
252            ? "Core conflict; refresh status before retrying."
253            : `Core request refused (${status}).`,
254        );
255      }
256      try {
257        return JSON.parse(await readBounded(response.body, MAX_BYTES - 1, signal));
258      } catch (error) {
259        if (error instanceof PublicError) throw error;
260        throw new PublicError(502, "Core returned an invalid response.");
261      }
262    },
263  };
264}
265