char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Sominitial commite6df845

main
3.5 KiB99 linesraw
1// Provider logins run here, on the server; their prompts and events cross to the browser as a live state.
2
3import type { AuthEvent, AuthPrompt, AuthType, Models } from "@earendil-works/pi-ai";
4import { BerryError } from "./berry.ts";
5import { type Feed, published } from "./pubsub.ts";
6
7type Prompt = Omit<AuthPrompt, "signal"> & { id: number };
8
9export type LoginState = {
10  provider: string;
11  type: AuthType;
12  status: "running" | "done" | "failed" | "cancelled";
13  events: AuthEvent[];
14  /** What the flow is waiting for the user to answer, if anything. */
15  prompt: Prompt | null;
16  error: string | null;
17};
18
19const SETTLED_LINGER_MS = 10 * 60_000;
20
21export function createLogins(models: Models) {
22  const logins = new Map<string, {
23    state: ReturnType<typeof published<LoginState>>;
24    answers: Map<number, PromiseWithResolvers<string>>;
25    controller: AbortController;
26  }>();
27  let nextPrompt = 0;
28
29  return {
30    start(provider: string, type: AuthType): string {
31      const methods = models.getProvider(provider)?.auth;
32      if (!(type === "oauth" ? methods?.oauth : methods?.apiKey?.login)) {
33        throw new BerryError(`${provider} has no ${type === "oauth" ? "oauth" : "api key"} login`);
34      }
35      const id = crypto.randomUUID();
36      const state = published<LoginState>({
37        provider,
38        type,
39        status: "running",
40        events: [],
41        prompt: null,
42        error: null,
43      });
44      const update = (change: Partial<LoginState>) => state.set({ ...state.value, ...change });
45      const answers = new Map<number, PromiseWithResolvers<string>>();
46      const controller = new AbortController();
47      logins.set(id, { state, answers, controller });
48
49      void models.login(provider, type, {
50        signal: controller.signal,
51        notify: (event) => update({ events: [...state.value.events, event] }),
52        prompt: ({ signal, ...prompt }) => {
53          const answer = Promise.withResolvers<string>();
54          const promptId = nextPrompt++;
55          answers.set(promptId, answer);
56          update({ prompt: { ...prompt, id: promptId } as Prompt });
57          // the flow may withdraw its question, as when its callback server answers it first
58          const withdraw = () => answer.reject(signal?.reason ?? new Error("withdrawn"));
59          signal?.addEventListener("abort", withdraw, { once: true });
60          return answer.promise.finally(() => {
61            signal?.removeEventListener("abort", withdraw);
62            answers.delete(promptId);
63            if (state.value.prompt?.id === promptId) update({ prompt: null });
64          });
65        },
66      })
67        .then(() => update({ status: "done", prompt: null }))
68        .catch((error) =>
69          update({
70            status: controller.signal.aborted ? "cancelled" : "failed",
71            prompt: null,
72            error: controller.signal.aborted
73              ? null
74              : error instanceof Error
75              ? error.message
76              : String(error),
77          })
78        )
79        .finally(() => Deno.unrefTimer(setTimeout(() => logins.delete(id), SETTLED_LINGER_MS)));
80      return id;
81    },
82
83    answer(id: string, prompt: number, value: string) {
84      const answer = logins.get(id)?.answers.get(prompt);
85      if (answer === undefined) throw new BerryError("that question is no longer asked");
86      answer.resolve(value);
87    },
88
89    cancel(id: string) {
90      logins.get(id)?.controller.abort();
91    },
92
93    feed(id: string): Feed | undefined {
94      return logins.get(id)?.state.feed();
95    },
96  };
97}
98
99export type Logins = ReturnType<typeof createLogins>;