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

packages/forkhorse-gatekeeper/src/client.ts

Snapshot at 34e3175

Containing directory

9,380 bytes · typescriptView raw
1import { RpcTarget } from "capnweb";
2import { assertState } from "../../forkhorse-dashboard/src/model.ts";
3import type { DashboardTransport } from "../../forkhorse-dashboard/src/contracts.ts";
4import type { SidebarBatch, SidebarUi, Subscription, UpdateReceiver } from "./ui-types.ts";
5
6export const OWNER_UNAVAILABLE = "Approve and Promote are unavailable for this agent connection.";
7
8function disposeStub(value: object | undefined) {
9  if (value && Symbol.dispose in value) {
10    const dispose = value[Symbol.dispose];
11    if (typeof dispose === "function") dispose.call(value);
12  }
13}
14
15/** One active session and at most one abandoned remote call awaiting settlement. */
16export async function connectSidebar(
17  initialUi: SidebarUi,
18  onError: (message: string) => void,
19  resubscribeTheme: (isCurrent: () => boolean) => Promise<void> = async () => {},
20  acquireUi?: (isCurrent: () => boolean) => Promise<SidebarUi>,
21) {
22  let batch = await initialUi.snapshot();
23  assertState(batch.state);
24  if (!Number.isSafeInteger(batch.version) || batch.version < 0)
25    throw new Error("Invalid sidebar version.");
26  const project = batch.state.project.id;
27  let closed = false;
28  let paused = false;
29  let timer: ReturnType<typeof setTimeout> | undefined;
30  let failures = 0;
31  const deadlines = new Set<ReturnType<typeof setTimeout>>();
32  const listeners = new Set<() => void>();
33  const statusListeners = new Set<(paused: boolean) => void>();
34  type Session = {
35    generation: number;
36    ui?: SidebarUi;
37    receiver?: Receiver;
38    subscription?: Subscription;
39    pending?: Promise<unknown>;
40    stalled: boolean;
41  };
42  let session: Session = { generation: 0, ui: initialUi, stalled: false };
43  let running: { session: Session; promise: Promise<void> } | undefined;
44  let abandoned: Promise<unknown> | undefined;
45  const current = (value: Session) => !closed && value === session;
46
47  function status(value: boolean) {
48    if (closed || paused === value) return;
49    paused = value;
50    onError(value ? "Live updates paused." : "");
51    for (const listener of statusListeners) listener(value);
52  }
53  function accept(owner: Session, next: SidebarBatch, snapshot = false): void {
54    if (!current(owner)) return;
55    assertState(next.state);
56    if (
57      next.state.project.id !== project ||
58      !Number.isSafeInteger(next.version) ||
59      next.version < 0
60    )
61      throw new Error("Invalid sidebar update.");
62    if (!snapshot && next.version < batch.version) throw new Error("Sidebar version moved back.");
63    if (snapshot || next.version > batch.version) {
64      batch = next;
65      for (const listener of listeners) listener();
66    }
67    status(false);
68    failures = 0;
69  }
70  function schedule(operation: () => Promise<void>, delay: number) {
71    clearTimeout(timer);
72    if (!closed)
73      timer = setTimeout(() => {
74        timer = undefined;
75        void operation();
76      }, delay);
77  }
78  async function observe<T>(owner: Session, operation: PromiseLike<T>): Promise<T> {
79    const pending = Promise.resolve(operation);
80    owner.pending = pending;
81    const deadline = setTimeout(() => {
82      if (!current(owner)) return;
83      owner.stalled = true;
84      status(true);
85      // Give normal relay restoration time, then try a fresh capability without a call storm.
86      schedule(reconnect, 60_000);
87    }, 30_000);
88    deadlines.add(deadline);
89    try {
90      const result = await pending;
91      if (!current(owner)) {
92        if (result && typeof result === "object") disposeStub(result);
93        throw new Error("Sidebar session retired.");
94      }
95      return result;
96    } finally {
97      clearTimeout(deadline);
98      deadlines.delete(deadline);
99      if (owner.pending === pending) {
100        owner.pending = undefined;
101        owner.stalled = false;
102      }
103    }
104  }
105  async function release(owner: Session, remote = true) {
106    if (owner.receiver) owner.receiver.active = false;
107    owner.receiver = undefined;
108    const previous = owner.subscription;
109    owner.subscription = undefined;
110    let closing: Promise<void> | undefined;
111    try {
112      if (remote) closing = previous?.close();
113    } catch {
114      /* Broken remote. */
115    }
116    disposeStub(previous);
117    try {
118      if (closing) await observe(owner, closing);
119    } catch {
120      /* Disconnected or retired. */
121    }
122  }
123  function single(owner: Session, operation: () => Promise<void>): Promise<void> {
124    if (!current(owner)) return Promise.resolve();
125    if (running) return running.promise;
126    const entry = { session: owner, promise: Promise.resolve() };
127    running = entry;
128    entry.promise = Promise.resolve()
129      .then(operation)
130      .catch(() => {
131        if (!current(owner)) return; // Late failures cannot overwrite a recovered generation.
132        status(true);
133        schedule(reconnect, Math.min(30_000, 1000 * 2 ** Math.min(failures++, 5)));
134      })
135      .finally(() => {
136        if (running === entry) running = undefined;
137      });
138    return entry.promise;
139  }
140  function watch(owner: Session) {
141    if (current(owner)) schedule(() => single(owner, () => poll(owner)), 30_000);
142  }
143  async function poll(owner: Session) {
144    await release(owner);
145    while (current(owner))
146      accept(owner, await observe(owner, owner.ui!.waitForChanges(batch.version)));
147  }
148  class Receiver extends RpcTarget implements UpdateReceiver {
149    active = true;
150    readonly owner: Session;
151    constructor(owner: Session) {
152      super();
153      this.owner = owner;
154    }
155    async receive(next: SidebarBatch) {
156      if (!this.active || !current(this.owner)) return;
157      accept(this.owner, next);
158      watch(this.owner);
159    }
160  }
161  async function subscribe(owner: Session) {
162    try {
163      owner.receiver = new Receiver(owner);
164      const subscription = await observe(owner, owner.ui!.subscribe(owner.receiver));
165      if (!current(owner)) {
166        disposeStub(subscription);
167        return;
168      }
169      owner.subscription = subscription;
170      status(false);
171      watch(owner);
172    } catch {
173      if (current(owner)) schedule(() => single(owner, () => poll(owner)), 0);
174    }
175  }
176  async function recover(owner: Session) {
177    await release(owner);
178    if (!current(owner)) return;
179    if (acquireUi)
180      owner.ui = await observe(
181        owner,
182        acquireUi(() => current(owner)),
183      );
184    else owner.ui = initialUi;
185    // Host-local and current: apply theme even if the replacement UI snapshot then fails.
186    await observe(
187      owner,
188      resubscribeTheme(() => current(owner)),
189    );
190    accept(owner, await observe(owner, owner.ui.snapshot()), true);
191    if (current(owner)) await subscribe(owner);
192  }
193  async function reconnect(): Promise<void> {
194    if (closed) return;
195    clearTimeout(timer);
196    if (running) {
197      const old = running.session;
198      if (!old.stalled) return running.promise;
199      // A second hung attempt must wait while the first abandoned call is still outstanding.
200      if (abandoned || !acquireUi) {
201        schedule(reconnect, 60_000);
202        return;
203      }
204      const pending = old.pending!;
205      abandoned = pending;
206      void pending.then(
207        () => {
208          if (abandoned === pending) abandoned = undefined;
209        },
210        () => {
211          if (abandoned === pending) abandoned = undefined;
212        },
213      );
214      session = { generation: old.generation + 1, stalled: false };
215      running = undefined;
216      // Local disposal must not add a remote close call behind the already hung operation.
217      void release(old, false);
218      disposeStub(old.ui);
219    } else {
220      const old = session;
221      session = { generation: old.generation + 1, stalled: false };
222      void release(old, false);
223      if (acquireUi) disposeStub(old.ui);
224    }
225    const owner = session;
226    return single(owner, () => recover(owner));
227  }
228  await single(session, async () => {
229    const owner = session;
230    await observe(
231      owner,
232      resubscribeTheme(() => current(owner)),
233    );
234    if (current(owner)) await subscribe(owner);
235  });
236  function requireProject(id: string) {
237    if (id !== project) throw new Error("This sidebar connection is bound to another project.");
238  }
239  const transport: DashboardTransport = {
240    live: {
241      subscribe(listener) {
242        statusListeners.add(listener);
243        listener(paused);
244        return () => statusListeners.delete(listener);
245      },
246      reconnect,
247    },
248    async getState(id) {
249      requireProject(id);
250      return structuredClone(batch.state);
251    },
252    async getActivity(id, after = 0) {
253      requireProject(id);
254      const activity = (batch.state.recentActivity ?? []).filter((event) => event.sequence > after);
255      return { activity, nextAfter: activity.at(-1)?.sequence ?? after };
256    },
257    async approve() {
258      throw new Error(OWNER_UNAVAILABLE);
259    },
260    async promote() {
261      throw new Error(OWNER_UNAVAILABLE);
262    },
263    subscribe(id, invalidate) {
264      requireProject(id);
265      listeners.add(invalidate);
266      return () => listeners.delete(invalidate);
267    },
268  };
269  return {
270    project,
271    transport,
272    dispose() {
273      closed = true;
274      clearTimeout(timer);
275      for (const deadline of deadlines) clearTimeout(deadline);
276      deadlines.clear();
277      listeners.clear();
278      statusListeners.clear();
279      void release(session, false);
280      if (acquireUi) disposeStub(session.ui);
281    },
282  };
283}
284