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

packages/forkhorse-gatekeeper/src/worker/workflow.ts

Snapshot at 34e3175

Containing directory

12,657 bytes · typescriptView raw
1import { evidence, outcome, uncertain, type Evidence } from "./workflow-evidence.ts";
2import { actionKinds } from "./action-kinds.ts";
3import type { ReadReviewResponse, ReadReceiptsResponse } from "../../../forkhorse-stable/src/index.ts";
4import type { ApprovalQueue, ActionKind } from "@gadgets/workshop-shared/gatekeeper";
5import { SerialTaskQueue } from "@gadgets/gatekeeper-kit/serial-queue";
6
7import type { WorkflowContext } from "../workflow-types.d.ts";
8export type { WorkflowContext } from "../workflow-types.d.ts";
9
10export type WorkflowWrite =
11  | { kind: "join" }
12  | { kind: "claim"; packet: string; repoName?: string }
13  | { kind: "requestReview"; packet: string; repoName: string; sha: string }
14  | { kind: "release"; packet: string };
15
16/** Only the public fields needed to prepare an approval card. */
17export interface WorkflowSubmission {
18  project: { id: string };
19  forks: Pick<WorkflowContext["forks"][number], "agent" | "repoName" | "createdAt">[];
20  packets: Pick<WorkflowContext["packets"][number], "id" | "mode">[];
21}
22
23/** Credential-free seam. Production implementation must use the accepted Stable provider. */
24export interface WorkflowSource {
25  authorize(): Promise<{ agent: string }>;
26  context(): Promise<WorkflowContext>;
27  submission(operation: WorkflowWrite): Promise<WorkflowSubmission>;
28  readReview(review: string): Promise<ReadReviewResponse>;
29  readReceipts(review: string): Promise<ReadReceiptsResponse>;
30  execute(operation: WorkflowWrite): Promise<void>;
31}
32interface Action {
33  operation: WorkflowWrite;
34  actionKind: ActionKind;
35  status: "preparing" | "pending" | "applying" | "retryable" | "applied" | "rejected";
36  before?: Evidence;
37  attempts?: number;
38  copy?: { title: string; description: string };
39}
40type Store = Pick<DurableObjectStorage["kv"], "get" | "put" | "list">;
41const component = (value: string) => value !== "." && value !== ".." && /^[a-zA-Z0-9._-]{1,255}$/.test(value);
42
43function validate(operation: WorkflowWrite): void {
44  if ("packet" in operation && !component(operation.packet)) throw new Error("Invalid packet ID.");
45  if ("repoName" in operation && operation.repoName !== undefined && !component(operation.repoName)) {
46    throw new Error("Invalid fork name.");
47  }
48  if (operation.kind === "requestReview" && (!/^[a-f0-9]{40}$/.test(operation.sha) || /^0+$/.test(operation.sha))) {
49    throw new Error("A full, nonzero commit ID is required.");
50  }
51}
52function sameWrite(requested: WorkflowWrite, stored: WorkflowWrite): boolean {
53  if (requested.kind !== stored.kind) return false;
54  if ("packet" in requested && "packet" in stored && requested.packet !== stored.packet) return false;
55  if (requested.kind === "claim" && stored.kind === "claim") return requested.repoName === undefined || requested.repoName === stored.repoName;
56  if (requested.kind === "requestReview" && stored.kind === "requestReview") return requested.repoName === stored.repoName && requested.sha === stored.sha;
57  return true;
58}
59function description(operation: WorkflowWrite, project: string, agent: string, race: boolean) {
60  switch (operation.kind) {
61    case "join": return { title: "Create an agent fork", description: `Create one new fork of ${project} for ${agent}.` };
62    case "claim": return race ? { title: `Enter race ${operation.packet}`, description: `Enter ${agent} in race ${operation.packet} on fork ${operation.repoName}.` } : { title: `Claim ${operation.packet}`, description: `Claim packet ${operation.packet}${operation.repoName ? ` on fork ${operation.repoName}` : " on the connected agent's latest registered fork"}.` };
63    case "requestReview": return { title: `Request review of ${operation.packet}`, description: `Request review of commit ${operation.sha.slice(0, 7)} on fork ${operation.repoName} for ${operation.packet}.` };
64    case "release": return { title: `Release ${operation.packet}`, description: `Release the connected agent's claim on packet ${operation.packet}.` };
65  }
66}
67
68/**
69 * Durable approval controller. Applied operations use the connected account
70 * and its Stable credential provider, never model-supplied authority.
71 */
72export class WorkflowActions {
73  constructor(
74    private readonly store: Store,
75    private readonly source: WorkflowSource,
76    // Share with push when integrating, including its existing nextAction counter.
77    private readonly serial = new SerialTaskQueue(),
78    private readonly beforeSubmit: () => void | Promise<void> = () => {},
79  ) {}
80
81  has(id: number): boolean { return !!this.store.get<Action>(`workflow:${id}`); }
82  hasPending(): boolean {
83    return [...this.store.list<Action>({ prefix: "workflow:" })]
84      .some(([, action]) => ["preparing", "pending", "applying", "retryable"].includes(action.status));
85  }
86  #kind(operation: WorkflowWrite, context: Pick<WorkflowSubmission, "packets">): ActionKind {
87    if (operation.kind !== "claim") return actionKinds[operation.kind];
88    const packet = context.packets.find(candidate => candidate.id === operation.packet);
89    if (!packet) throw new Error("Packet is unavailable.");
90    return packet.mode === "race" ? actionKinds.raceEntry : actionKinds.claim;
91  }
92  async #reconcile(context?: WorkflowContext): Promise<void> {
93    for (const [key, action] of this.store.list<Action>({ prefix: "workflow:" })) {
94      if (action.status !== "applying" && action.status !== "retryable") continue;
95      const identity = await this.source.authorize();
96      let snapshot: WorkflowContext;
97      try { snapshot = context ?? await this.source.context(); } catch { throw uncertain(action.operation); }
98      await this.source.authorize();
99      if (!action.before || identity.agent !== action.before.agent) continue;
100      const result = outcome(snapshot, action.before, action.operation, action.actionKind.tag === actionKinds.raceEntry.tag);
101      if (result === "applied") this.store.put(key, { ...action, status: "applied" });
102      else this.store.put(key, { ...action, status: result === "absent" && (action.attempts ?? 1) < 2 ? "retryable" : "applying" });
103    }
104  }
105  // Caller must already hold the shared push/workflow serial queue.
106  async reconcileInsideQueue(): Promise<void> { await this.#reconcile(); }
107  async readReview(queue: Pick<ApprovalQueue, "authorizeObservation">, review: string): Promise<ReadReviewResponse> {
108    await queue.authorizeObservation({ title: "Read review", description: "Read the connected agent's review at its recorded commit." });
109    await this.source.authorize();
110    await this.serial.run(() => this.#reconcile());
111    const result = await this.source.readReview(review);
112    await this.source.authorize();
113    return result;
114  }
115  async readReceipts(queue: Pick<ApprovalQueue, "authorizeObservation">, review: string): Promise<ReadReceiptsResponse> {
116    await queue.authorizeObservation({ title: "Read receipt summary", description: "Read the public check summary on the connected agent's review." });
117    await this.source.authorize();
118    await this.serial.run(() => this.#reconcile());
119    const result = await this.source.readReceipts(review);
120    await this.source.authorize();
121    return result;
122  }
123
124  async context(queue: Pick<ApprovalQueue, "authorizeObservation">): Promise<WorkflowContext> {
125    await queue.authorizeObservation({ title: "Read project context", description: "Read project state and the connected agent's work to choose the next step." });
126    return this.serial.run(async () => {
127      await this.source.authorize();
128      const result = await this.source.context();
129      await this.source.authorize();
130      await this.#reconcile(result);
131      return result;
132    });
133  }
134
135  async submit(queue: Pick<ApprovalQueue, "submitAction">, operation: WorkflowWrite): Promise<void> {
136    validate(operation);
137    await this.serial.run(async () => {
138      const identity = await this.source.authorize();
139      await this.beforeSubmit();
140      const prior = [...this.store.list<Action>({ prefix: "workflow:" })].find(([, action]) => action.status === "applying" || action.status === "retryable");
141      await this.#reconcile();
142      if (prior && this.store.get<Action>(prior[0])?.status === "applied" && sameWrite(operation, prior[1].operation)) return;
143      const unresolved = [...this.store.list<Action>({ prefix: "workflow:" })].find(([, action]) => ["preparing", "pending", "applying", "retryable"].includes(action.status));
144      let id: number;
145      let action: Action;
146      if (unresolved) {
147        const [key, existing] = unresolved;
148        if (existing.status === "applying") throw uncertain(existing.operation);
149        // Omitted fork is resolved once, before approval, and stays fixed on retry.
150        if (!sameWrite(operation, existing.operation)) throw new Error("Resolve the pending workflow action before submitting another.");
151        if (existing.status === "pending") return;
152        id = Number(key.slice("workflow:".length));
153        action = existing;
154      } else {
155        const snapshot = await this.source.submission(operation);
156        await this.source.authorize();
157        if (operation.kind === "claim" && !operation.repoName) {
158          const forks = snapshot.forks.filter(fork => fork.agent === identity.agent);
159          const latest = forks.toSorted((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
160          if (!latest) throw new Error("The connected agent has no fork to claim.");
161          operation = { ...operation, repoName: latest.repoName };
162        }
163        const actionKind = this.#kind(operation, snapshot);
164        id = (this.store.get<number>("nextAction") ?? 0) + 1;
165        this.store.put("nextAction", id);
166        action = { operation, actionKind, status: "preparing", copy: description(operation, snapshot.project.id, identity.agent, actionKind.tag === actionKinds.raceEntry.tag) };
167      }
168      this.store.put(`workflow:${id}`, { ...action, status: "preparing" });
169      try {
170        await queue.submitAction(id, { ...action.copy!, implementsRevert: false, awaitDecision: true, actionKind: action.actionKind, autoApprovable: true });
171      } catch {
172        throw new Error("Workflow submission interrupted. Retry the same call to recover its approval request.");
173      }
174      this.store.put(`workflow:${id}`, { ...action, status: "pending" });
175    });
176  }
177
178  async apply(id: number): Promise<void> {
179    await this.serial.run(async () => {
180      let action = this.store.get<Action>(`workflow:${id}`);
181      if (action && action.status !== "applied" && action.status !== "rejected" && (action.attempts ?? 0) > 0) {
182        // Recheck even a previously observed absence immediately before retrying.
183        this.store.put(`workflow:${id}`, { ...action, status: "applying" });
184        await this.#reconcile();
185        action = this.store.get<Action>(`workflow:${id}`);
186      }
187      if (action?.status === "applied") return;
188      if (action?.status === "applying") throw uncertain(action.operation);
189      if (!action || !["preparing", "pending", "retryable"].includes(action.status)) throw new Error("Workflow action is not pending approval.");
190      const identity = await this.source.authorize();
191      const snapshot = await this.source.context();
192      await this.source.authorize();
193      if (this.#kind(action.operation, snapshot).tag !== action.actionKind.tag) throw new Error("Packet action kind changed; discard this action.");
194      if (action.before && (action.attempts ?? 0) > 0) {
195        const result = outcome(snapshot, action.before, action.operation, action.actionKind.tag === actionKinds.raceEntry.tag);
196        if (result === "applied") { this.store.put(`workflow:${id}`, { ...action, status: "applied" }); return; }
197        if (result !== "absent" || (action.attempts ?? 0) >= 2) throw uncertain(action.operation);
198      }
199      const before = evidence(snapshot, identity.agent, action.operation);
200      const sent: Action = { ...action, before, attempts: (action.attempts ?? 0) + 1, status: "applying" };
201      this.store.put(`workflow:${id}`, sent);
202      try {
203        await this.source.execute(action.operation);
204      } catch {
205        throw uncertain(action.operation);
206      }
207      this.store.put(`workflow:${id}`, { ...sent, status: "applied" });
208    });
209  }
210
211  async reject(id: number): Promise<void> {
212    await this.serial.run(async () => {
213      await this.#reconcile();
214      const action = this.store.get<Action>(`workflow:${id}`);
215      if (!action || action.status === "rejected") return;
216      if (action.status === "applied" || action.status === "applying") throw new Error("Workflow action was already sent; inspect context.");
217      this.store.put(`workflow:${id}`, { ...action, status: "rejected" });
218    });
219  }
220}
221