char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somperf: abort subprocesses instantly on restartba8ed3c

main
12.9 KiB343 linesraw
1// Extensions come from configured directories: loaded at startup, and again whenever their files change.
2
3import { existsSync, mkdirSync } from "node:fs";
4import { copy } from "@std/fs/copy";
5import { fromFileUrl, join, relative, resolve, toFileUrl } from "@std/path";
6import type { TObject } from "typebox";
7import type { ExtensionStartup, FrontendSource } from "@berry/api/extension-startup.ts";
8import type { Berry, ExtensionStatus } from "./berry.ts";
9import { denoExecutable } from "./deno.ts";
10import { published } from "./pubsub.ts";
11import type { ExtensionWeb, WebEntry } from "./web.ts";
12
13/** An extension as the ui sees it: how its last load went, whether a version of it runs, and its frontend. */
14export type ExtensionEntry = ExtensionStatus & {
15  readonly running: boolean;
16  /** The source directory, or null for a builtin or an unresolved name collision. */
17  readonly directory: string | null;
18  /** The harness's own: always there, nothing on disk to reload. */
19  readonly builtin: boolean;
20  readonly alwaysOn: boolean;
21  /** Whether a new conversation starts with it, until one is chosen from before. */
22  readonly defaultEnabled: boolean;
23  /** Where its configuration sorts among the others': lower first. */
24  readonly sortOrder: number | null;
25  readonly web: WebEntry | null;
26  /** Names it went by before, which old history still calls it. */
27  readonly formerly: readonly string[];
28  /** What a new conversation with it is asked, and what an existing one may change, as a json schema. */
29  readonly configuration: TObject | null;
30  /** Its global editable prose default, when it registers any. */
31  readonly promptProse: { default: string } | null;
32  readonly startup: ExtensionStartup | null;
33};
34
35const SETTLE_MS = 300;
36
37/**
38 * What `deno check` says of `files`, or `null` when they pass; a signal kills the check and rejects with its reason.
39 */
40export async function denoCheck(
41  root: string,
42  files: readonly string[],
43  signal?: AbortSignal,
44): Promise<string | null> {
45  const { success, stderr } = await new Deno.Command(denoExecutable(), {
46    args: [
47      "check",
48      "--quiet",
49      "--config",
50      fromFileUrl(new URL("../deno.json", import.meta.url)),
51      ...files,
52    ],
53    cwd: root,
54    env: { NO_COLOR: "1" },
55    stdout: "null",
56    stderr: "piped",
57    signal,
58  }).output();
59  // a killed check exits by signal and writes nothing, which is not a verdict on the code
60  signal?.throwIfAborted();
61  return success ? null : new TextDecoder().decode(stderr).trim();
62}
63
64/** The extensions as the ui lists them: by `sortOrder`, lower first, and those without one after them by name. */
65function ranked(entries: readonly ExtensionEntry[]): readonly ExtensionEntry[] {
66  const at = (entry: ExtensionEntry) => entry.sortOrder ?? Number.MAX_SAFE_INTEGER;
67  return [...entries].sort((a, b) => at(a) - at(b) || a.name.localeCompare(b.name));
68}
69
70export function extensionLoader(
71  { berry, web, root, directories = [join(root, "extensions")] }: {
72    berry: Berry;
73    web: ExtensionWeb;
74    root: string;
75    directories?: readonly string[];
76  },
77) {
78  const roots = [...new Set(directories.map((directory) => resolve(directory)))];
79  const entries = published<readonly ExtensionEntry[]>([]);
80  const builtins: readonly ExtensionEntry[] = berry.builtins().map((extension) => ({
81    name: extension.name,
82    description: extension.description ?? null,
83    status: "loaded",
84    error: null,
85    running: true,
86    directory: null,
87    builtin: true,
88    alwaysOn: extension.alwaysOn ?? false,
89    defaultEnabled: extension.defaultEnabled ?? false,
90    sortOrder: extension.sortOrder ?? null,
91    web: null,
92    formerly: extension.formerly ?? [],
93    configuration: berry.configurationParameters(extension.name),
94    promptProse: berry.promptProseDefault(extension.name) === null
95      ? null
96      : { default: berry.promptProseDefault(extension.name)! },
97    startup: null,
98  }));
99  /** What the ui lists: the extensions by their configuration order. */
100  const listed = published<readonly ExtensionEntry[]>(ranked(builtins));
101  const copies = new Map<string, string>();
102  const queues = new Map<string, Promise<unknown>>();
103  // a restart loads every extension again, so a reload still being checked is only holding the process up
104  const checks = new AbortController();
105  let watcher: Deno.FsWatcher | undefined;
106  let watching: Promise<void> | undefined;
107
108  function sources() {
109    const found = new Map<string, string[]>();
110    for (const directory of roots) {
111      if (!existsSync(directory)) continue;
112      for (const entry of Deno.readDirSync(directory)) {
113        if (!entry.isDirectory || entry.name.startsWith(".")) continue;
114        const paths = found.get(entry.name) ?? [];
115        paths.push(join(directory, entry.name));
116        found.set(entry.name, paths);
117      }
118    }
119    return found;
120  }
121
122  function record(entry: ExtensionEntry) {
123    const rest = entries.value.filter((e) => e.name !== entry.name);
124    entries.set([...rest, entry].sort((a, b) => a.name.localeCompare(b.name)));
125    listed.set(ranked([...builtins, ...entries.value]));
126    return entry;
127  }
128
129  /**
130   * Load an extension's files. A reload checks them first, and imports a fresh copy of the directory, so that its
131   * relative imports load anew too; deno would otherwise reuse what it has for their unchanged urls.
132   */
133  async function load(name: string, reload: boolean): Promise<ExtensionStatus> {
134    const builtin = builtins.find((extension) => extension.name === name);
135    if (builtin) {
136      return {
137        name,
138        description: builtin.description,
139        status: "failed",
140        error: `${name} is the harness's own; restart berry to reload it`,
141      };
142    }
143    const started = performance.now();
144    const phases: ExtensionStartup["phases"][number][] = [];
145    let frontendSource: FrontendSource | undefined;
146    const candidates = sources().get(name) ?? [];
147    const previous = entries.value.find((e) => e.name === name);
148
149    async function trace<T>(
150      phase: ExtensionStartup["phases"][number]["name"],
151      run: () => Promise<T>,
152    ) {
153      if (reload) return await run();
154      const started = performance.now();
155      try {
156        return await run();
157      } finally {
158        const durationMs = performance.now() - started;
159        phases.push({
160          name: phase,
161          durationMs,
162          ...(phase === "frontend" && frontendSource ? { source: frontendSource } : {}),
163        });
164      }
165    }
166
167    function finish(entry: Omit<ExtensionEntry, "startup">) {
168      return record({
169        ...entry,
170        startup: reload ? previous?.startup ?? null : {
171          status: entry.status,
172          totalMs: performance.now() - started,
173          phases,
174        },
175      });
176    }
177    const source = candidates.length === 1 ? candidates[0] : undefined;
178    let copied: string | undefined;
179    // its directory gone: so is the extension
180    if (candidates.length === 0) {
181      berry.uninstall(name);
182      entries.set(entries.value.filter((e) => e.name !== name));
183      listed.set(ranked([...builtins, ...entries.value]));
184      return { name, description: null, status: "failed", error: "removed" };
185    }
186    try {
187      if (source === undefined) {
188        throw new Error(`duplicate extension ${name}:\n${candidates.join("\n")}`);
189      }
190      const frontend = join(source, "web.tsx");
191      if (!existsSync(join(source, "mod.ts"))) throw new Error(`${source} has no mod.ts`);
192      const files = [join(source, "mod.ts"), ...(existsSync(frontend) ? [frontend] : [])];
193      if (reload) {
194        const failures = await denoCheck(
195          root,
196          files.map((file) => relative(root, file)),
197          checks.signal,
198        );
199        if (failures !== null) throw new Error(failures);
200        copied = await Deno.makeTempDir({ prefix: `berry-${name}-` });
201        await copy(source, copied, { overwrite: true });
202      }
203      const module = await trace(
204        "import",
205        () => import(toFileUrl(join(copied ?? source, "mod.ts")).href),
206      );
207      const extension = module.default;
208      // its frontend is served, and its state kept, under one name
209      if (extension?.name !== name) {
210        throw new Error(`${source} names itself ${extension?.name}`);
211      }
212      // the frontend first: a build that fails leaves both halves as they were
213      const built = existsSync(frontend)
214        ? await trace("frontend", () =>
215          web.build(name, frontend, (source) => {
216            frontendSource = source;
217          }))
218        : null;
219      await trace("install", () => berry.install(extension));
220      if (copied !== undefined) {
221        const old = copies.get(name);
222        copies.set(name, copied);
223        if (old) await Deno.remove(old, { recursive: true }).catch(() => {});
224      }
225      return finish({
226        name,
227        description: extension.description ?? null,
228        status: "loaded",
229        error: null,
230        running: true,
231        directory: source,
232        builtin: false,
233        alwaysOn: extension.alwaysOn ?? false,
234        defaultEnabled: extension.defaultEnabled ?? false,
235        sortOrder: extension.sortOrder ?? null,
236        web: built,
237        formerly: extension.formerly ?? [],
238        configuration: berry.configurationParameters(name),
239        promptProse: berry.promptProseDefault(name) === null
240          ? null
241          : { default: berry.promptProseDefault(name)! },
242      });
243    } catch (error) {
244      if (copied !== undefined) await Deno.remove(copied, { recursive: true }).catch(() => {});
245      // a cancelled reload says nothing about the extension, which the restart loads anew
246      if (checks.signal.aborted) {
247        return previous ?? {
248          name,
249          description: null,
250          status: "failed",
251          error: `${name} did not finish loading`,
252        };
253      }
254      const message = error instanceof Error ? error.message : String(error);
255      console.error(`[berry] ${name} did not ${reload ? "reload" : "load"}:\n${message}`);
256      return finish({
257        name,
258        description: previous?.description ?? null,
259        status: "failed",
260        error: message,
261        running: previous?.running ?? false,
262        directory: previous?.running ? previous.directory : source ?? null,
263        builtin: false,
264        alwaysOn: previous?.alwaysOn ?? false,
265        defaultEnabled: previous?.defaultEnabled ?? false,
266        sortOrder: previous?.sortOrder ?? null,
267        web: previous?.web ?? null,
268        formerly: previous?.formerly ?? [],
269        configuration: previous?.configuration ?? null,
270        promptProse: previous?.promptProse ?? null,
271      });
272    }
273  }
274
275  /** One load of an extension at a time; a change while one runs loads again after it. */
276  function queue(name: string, reload: boolean): Promise<ExtensionStatus> {
277    const next = (queues.get(name) ?? Promise.resolve()).then(() => load(name, reload));
278    queues.set(name, next.catch(() => {}));
279    return next;
280  }
281
282  return {
283    directories: roots as readonly string[],
284
285    files: () =>
286      [...sources().values()].flat().flatMap((directory) => [
287        join(directory, "mod.ts"),
288        ...(existsSync(join(directory, "web.tsx")) ? [join(directory, "web.tsx")] : []),
289      ]),
290
291    async loadAll() {
292      for (const name of sources().keys()) await queue(name, false);
293    },
294
295    reload: (name: string) => queue(name, true),
296
297    entries: (): readonly ExtensionEntry[] => entries.value,
298
299    feed: () => listed.feed(),
300
301    /** Reload each extension whose files change, once they settle. */
302    watch() {
303      if (watcher || roots.length === 0) return;
304      for (const directory of roots) mkdirSync(directory, { recursive: true, mode: 0o700 });
305      watcher = Deno.watchFs(roots, { recursive: true });
306      const timers = new Map<string, ReturnType<typeof setTimeout>>();
307      watching = (async () => {
308        for await (const event of watcher!) {
309          const affected = new Set<string>();
310          for (const path of event.paths) {
311            for (const directory of roots) {
312              const name = relative(directory, path).split(/[/\\]/)[0];
313              // dot directories, and anything outside, which relative() starts with ".."
314              if (name.startsWith(".")) continue;
315              if (name) affected.add(name);
316              else {
317                for (const name of sources().keys()) affected.add(name);
318                for (const entry of entries.value) affected.add(entry.name);
319              }
320            }
321          }
322          for (const name of affected) {
323            clearTimeout(timers.get(name));
324            timers.set(name, setTimeout(() => void queue(name, true), SETTLE_MS));
325          }
326        }
327        for (const timer of timers.values()) clearTimeout(timer);
328      })();
329    },
330
331    async close() {
332      checks.abort(new Error("berry is stopping"));
333      watcher?.close();
334      await watching;
335      await Promise.all(queues.values());
336      for (const copied of copies.values()) {
337        await Deno.remove(copied, { recursive: true }).catch(() => {});
338      }
339    },
340  };
341}
342
343export type Loader = ReturnType<typeof extensionLoader>;