Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-gatekeeper/src/worker/fork.ts
Snapshot at 34e3175
1import { forkAddress, type ReadReviewResponse, type ReadReceiptsResponse } from "../../../forkhorse-stable/src/index.ts";
2import { WorkflowActions } from "./workflow.ts";
3import { WorkflowSession } from "./workflow-session.ts";
4import { actionKinds } from "./action-kinds.ts";
5import { DurableObject, RpcStub } from "cloudflare:workers";
6import { validateRpc } from "capnweb-validate";
7import { SerialTaskQueue } from "@gadgets/gatekeeper-kit/serial-queue";
8import { validateBranchName } from "@gadgets/gatekeeper-kit/git-transport";
9import type {
10 ApprovalQueue, Gatekeeper, GatekeeperUserVerifier, GitCache, GitOid, GitPullHints, ResourceDescription, ActionKind,
11} from "@gadgets/workshop-shared/gatekeeper";
12import type { AgentForkSession } from "../fork-types.d.ts";
13import type { AgentVerifier, Env, UserAccount } from "./account.ts";
14import { oid } from "./core-client.ts";
15import { TYPES } from "./api-types.ts";
16
17interface Props { accountId: string; repoName: string }
18interface Push { branch: string; oldSha: string; newSha: string; status: "preparing" | "pending" | "applying" | "retryable" | "applied" | "rejected"; attempts?: number }
19
20@validateRpc()
21export class ForkGatekeeper extends DurableObject<Env, Props> implements Gatekeeper<AgentForkSession> {
22 readonly #serial = new SerialTaskQueue();
23 #account(): DurableObjectStub<UserAccount> {
24 return this.ctx.exports.UserAccount.get(this.ctx.exports.UserAccount.idFromString(this.ctx.props.accountId));
25 }
26 #workflow(): WorkflowActions {
27 const account = this.#account();
28 return new WorkflowActions(this.ctx.storage.kv, {
29 authorize: async () => { await account.fork(this.ctx.props.repoName); return account.identity(); },
30 context: () => account.workflowContext(),
31 submission: operation => account.workflowSubmission(operation),
32 execute: operation => account.executeWorkflow(operation),
33 readReview: (review): Promise<ReadReviewResponse> => account.readReview(review),
34 readReceipts: (review): Promise<ReadReceiptsResponse> => account.readReceipts(review),
35 }, this.#serial, async () => {
36 // Only uncertain pushes need head/state evidence. Keep the conflict fence
37 // for every pending push without loading /state on a fresh workflow write.
38 if ([...this.ctx.storage.kv.list<Push>({ prefix: "push:" })].some(([, action]) => ["applying", "retryable"].includes(action.status))) {
39 this.#reconcilePush(await this.readHead());
40 }
41 if ([...this.ctx.storage.kv.list<Push>({ prefix: "push:" })].some(([, action]) => ["preparing", "pending", "applying", "retryable"].includes(action.status))) {
42 throw new Error("Resolve the pending push before submitting a workflow action.");
43 }
44 });
45 }
46 async describe(): Promise<ResourceDescription> {
47 const source = this.#account();
48 await source.fork(this.ctx.props.repoName);
49 const meta = await source.metadata();
50 const url = forkAddress({ origin: meta.coreOrigin, project: meta.project, repoName: this.ctx.props.repoName });
51 return { url,
52 title: this.ctx.props.repoName, snippet: `Agent-owned Git fork. Attach this exact address: ${url}`, tsType: "AgentForkSession", suggestedBindingName: "FORK" };
53 }
54 async getTypeScriptTypes(): Promise<string> { return TYPES; }
55 async getAutoApprovableActions(): Promise<ActionKind[]> { return Object.values(actionKinds); }
56 async startSession(queue: RpcStub<ApprovalQueue>): Promise<AgentForkSession> {
57 await this.#account().fork(this.ctx.props.repoName);
58 return new ForkSession(this, queue.dup(), this.#workflow());
59 }
60 async readHead(): Promise<{ repoName: string; branch: string; commitId: string }> {
61 const source = this.#account();
62 const fork = await source.fork(this.ctx.props.repoName);
63 const state = await source.state();
64 return { repoName: fork.repoName, branch: state.project.defaultBranch, commitId: fork.head };
65 }
66 #reconcilePush(head: { branch: string; commitId: string }): void {
67 for (const [key, action] of this.ctx.storage.kv.list<Push>({ prefix: "push:" })) {
68 if (action.status !== "applying" && action.status !== "retryable") continue;
69 if (head.branch !== action.branch) { this.ctx.storage.kv.put(key, { ...action, status: "applying" }); continue; }
70 if (head.commitId === action.newSha) this.ctx.storage.kv.put(key, { ...action, status: "applied" });
71 else this.ctx.storage.kv.put(key, { ...action, status: head.commitId === action.oldSha && (action.attempts ?? 1) < 2 ? "retryable" : "applying" });
72 }
73 }
74 async readAndReconcileHead(): Promise<{ repoName: string; branch: string; commitId: string }> {
75 return this.#serial.run(async () => {
76 const head = await this.readHead();
77 this.#reconcilePush(head);
78 return head;
79 });
80 }
81 async submitPush(queue: RpcStub<ApprovalQueue>, branch: string, commitId: string): Promise<void> {
82 await this.#serial.run(async () => {
83 await this.#workflow().reconcileInsideQueue();
84 if (this.#workflow().hasPending()) throw new Error("Resolve the pending workflow action before pushing.");
85 validateBranchName(branch);
86 if (!oid(commitId) || /^0+$/.test(commitId)) throw new Error("A full, nonzero commit ID is required.");
87 const head = await this.readHead();
88 this.#reconcilePush(head);
89 if (branch !== head.branch) throw new Error("Only the fork's default branch is supported.");
90 if (head.commitId === commitId) return;
91 const actions = [...this.ctx.storage.kv.list<Push>({ prefix: "push:" })];
92 const interrupted = actions.find(([, a]) => a.status === "preparing" || a.status === "retryable");
93 if (interrupted) {
94 const [key, action] = interrupted;
95 if (action.branch !== branch || action.newSha !== commitId) {
96 throw new Error("Retry the interrupted push with the same branch and commit before submitting another.");
97 }
98 // Reuse the gatekeeper action ID after an ambiguous reply/crash. The OS may
99 // queue a duplicate, but applying this ID is serialized and idempotent.
100 await this.#queuePush(queue, Number(key.slice(5)), action, head.repoName);
101 return;
102 }
103 if (actions.some(([, a]) => a.status === "applying")) throw new Error("Push outcome uncertain. Check the fork head against the approved commit; this call will not blindly resend it.");
104 if (actions.some(([, a]) => a.status === "pending")) {
105 throw new Error("Resolve the pending push before submitting another.");
106 }
107 using cache = await queue.getGitCache();
108 if (!/^0+$/.test(head.commitId) && !await cache.isAncestor(head.commitId, commitId)) {
109 throw new Error("Push must be a fast-forward. Rebase on the current fork head.");
110 }
111 const id = (this.ctx.storage.kv.get<number>("nextAction") ?? 0) + 1;
112 this.ctx.storage.kv.put("nextAction", id);
113 const action: Push = { branch, oldSha: head.commitId, newSha: commitId, status: "preparing" };
114 this.ctx.storage.kv.put(`push:${id}`, action);
115 await this.#queuePush(queue, id, action, head.repoName);
116 });
117 }
118 async #queuePush(queue: RpcStub<ApprovalQueue>, id: number, action: Push, repoName: string): Promise<void> {
119 try {
120 await queue.submitAction(id, {
121 title: `Push ${action.newSha.slice(0, 7)} to ${action.branch}`,
122 description: `Move ${action.branch} on fork ${repoName} from ${action.oldSha.slice(0, 7)} to ${action.newSha.slice(0, 7)}. This fails if the branch moved.`,
123 pushedCommits: [action.newSha], implementsRevert: false, awaitDecision: true,
124 actionKind: actionKinds.push, autoApprovable: true,
125 });
126 this.ctx.storage.kv.put(`push:${id}`, { ...action, status: "pending" });
127 } catch {
128 // Keep the intent: the OS may have durably queued it before the reply failed.
129 throw new Error("Push submission was interrupted. Retry the same branch and commit.");
130 }
131 }
132 async gitPull(oids: GitOid[], cache: RpcStub<GitCache>, hints: GitPullHints): Promise<void> {
133 await this.#account().pull(this.ctx.props.repoName, oids, hints, cache);
134 }
135 async applyAction(id: number, cache: RpcStub<GitCache>): Promise<void> {
136 const workflow = this.#workflow();
137 if (workflow.has(id)) return workflow.apply(id);
138 await this.#serial.run(async () => {
139 const head = await this.readHead();
140 this.#reconcilePush(head);
141 const action = this.ctx.storage.kv.get<Push>(`push:${id}`);
142 if (action?.status === "applied") return;
143 if (action?.status === "applying") throw new Error("Push outcome uncertain. Check the fork head against the approved commit; this call will not blindly resend it.");
144 // A queued OS action is still approvable if its submission reply was lost.
145 if (action?.status !== "pending" && action?.status !== "preparing" && action?.status !== "retryable") throw new Error("Push is not pending approval.");
146 // Recover a lost successful reply; the approved exact commit is already present.
147 if (head.commitId !== action.newSha) {
148 if (head.branch !== action.branch || head.commitId !== action.oldSha) throw new Error("Fork moved; discard this push and rebase.");
149 if (!/^0+$/.test(action.oldSha) && !await cache.isAncestor(action.oldSha, action.newSha)) {
150 throw new Error("Push is not a fast-forward.");
151 }
152 const pack = await cache.buildPack();
153 this.ctx.storage.kv.put(`push:${id}`, { ...action, status: "applying", attempts: (action.attempts ?? 0) + 1 });
154 await this.#account().push(this.ctx.props.repoName, action.branch, action.oldSha, action.newSha, pack);
155 }
156 this.ctx.storage.kv.put(`push:${id}`, { ...action, status: "applied" });
157 });
158 }
159 async rejectAction(id: number): Promise<void> {
160 const workflow = this.#workflow();
161 if (workflow.has(id)) return workflow.reject(id);
162 await this.#serial.run(async () => {
163 this.#reconcilePush(await this.readHead());
164 const action = this.ctx.storage.kv.get<Push>(`push:${id}`);
165 if (!action || action.status === "rejected") return;
166 if (action.status === "applied" || action.status === "applying") throw new Error("Push already sent; inspect the fork head.");
167 this.ctx.storage.kv.put(`push:${id}`, { ...action, status: "rejected" });
168 });
169 }
170 async revertAction(): Promise<never> { throw new Error("Create and submit a new reverting commit; history rewrites are not supported."); }
171 async addObserver(_id: string, user: Fetcher<GatekeeperUserVerifier>): Promise<void> {
172 const meta = await this.#account().metadata();
173 if (!await (user as Fetcher<AgentVerifier>).owns(meta.coreOrigin, meta.project, this.ctx.props.repoName)) {
174 throw new Error("Observer cannot access this agent fork.");
175 }
176 }
177 async removeObserver(_id: string): Promise<void> {}
178}
179
180@validateRpc()
181class ForkSession extends WorkflowSession implements AgentForkSession {
182 constructor(private readonly gatekeeper: ForkGatekeeper, queue: RpcStub<ApprovalQueue>, actions: WorkflowActions) { super(actions, queue); }
183 async getHead(): Promise<{ repoName: string; branch: string; commitId: string }> {
184 await this.queue.authorizeObservation({ title: "Read fork head", description: "Read the connected fork's default branch and commit." });
185 const head = await this.gatekeeper.readAndReconcileHead();
186 using cache = await this.queue.getGitCache();
187 if (!/^0+$/.test(head.commitId)) await cache.advertiseCommit(head.commitId);
188 return head;
189 }
190 async push(branch: string, commitId: string): Promise<void> { await this.gatekeeper.submitPush(this.queue, branch, commitId); }
191 [Symbol.dispose](): void { this.queue[Symbol.dispose](); }
192}
193