// The machine each conversation's files and commands are on: a binding its history keeps, and the environments // bindings name. import type { Context, JsonValue } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { homedir } from "node:os"; import { Type } from "@earendil-works/pi-ai"; import { defineExtension } from "@berry/api"; import { directory, type Environment, LOCAL, localEnvironment } from "@berry/api/environment.ts"; import { EventEntry } from "./entries.ts"; import { Connection, RemoteEnvironment, sshCommand } from "./remote/connection.ts"; export const ENVIRONMENT = "environment"; /** Where a conversation works; a `cwd` of `null` is the environment's home. */ export type Binding = { environment: string; cwd: string | null }; export type BindingEvent = { type: "bind"; environment: string; cwd: string }; /** Opens the environments bindings name. */ export type Environments = { /** Throws for a name it does not know, or a machine it cannot reach. */ open(name: string, cwd: string | null, context: Context): Promise; /** Let go of every connection. */ close(): Promise; }; /** Only the machine berry runs on. */ export const localOnly: Environments = { open(name, cwd) { if (name !== LOCAL) return Promise.reject(new Error(`No environment named ${name}`)); return Promise.resolve(localEnvironment(cwd ?? homedir())); }, close: () => Promise.resolve(), }; /** A machine settings name, which berry reaches over ssh. */ export type EnvironmentSettings = { /** What `ssh` connects to: a host from its config, `user@host`, or an `ssh://` url. */ readonly ssh: string; /** How the far side runs deno, which the worker needs; default `deno`. */ readonly deno?: string; }; /** A name settings can give a machine: never `local`, and nothing a command line could mistake for a flag. */ export const ENVIRONMENT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; /** * This machine, and those `configured` names, each over one connection that a change of its settings or the loss of * it replaces. */ export function configuredEnvironments( configured: () => Promise>>, start: (name: string, settings: EnvironmentSettings) => Promise = (name, settings) => Connection.open(name, sshCommand(settings.ssh, settings.deno)), ): Environments { const connections = new Map }>(); return { async open(name, cwd, context) { if (name === LOCAL) return await localOnly.open(name, cwd, context); const settings = (await configured())[name]; if (settings === undefined) throw new Error(`No environment named ${name}`); const key = JSON.stringify([settings.ssh, settings.deno]); const replace = (stale?: { connection: Promise }) => { if (stale) void stale.connection.then((connection) => connection.close(), () => {}); const fresh = { key, connection: start(name, settings) }; connections.set(name, fresh); return fresh; }; let open = connections.get(name); if (open?.key !== key) open = replace(open); else if (!(await open.connection.then((connection) => !connection.lost, () => false))) { // a call that found it gone first has replaced it already const current = connections.get(name); open = current !== open && current?.key === key ? current : replace(open); } const connection = await open.connection; return new RemoteEnvironment(name, connection, cwd ?? connection.hello.home); }, async close() { const open = [...connections.values()]; connections.clear(); await Promise.allSettled( open.map(async ({ connection }) => await (await connection).close()), ); }, }; } const isLegacyCd = (event: JsonValue): event is { type: "cd"; cwd: string } => typeof event === "object" && event !== null && !Array.isArray(event) && event.type === "cd" && typeof event.cwd === "string"; /** The binding as harness's own extension: always on, configured like any other. */ export function environmentExtension(environments: Environments) { return defineExtension({ name: ENVIRONMENT, description: "the target machine & working directory for the conversation", alwaysOn: true, sortOrder: Number.MIN_SAFE_INTEGER, initial: () => ({ environment: LOCAL, cwd: null }), apply: (_, event) => ({ environment: event.environment, cwd: event.cwd }), // before, the code extension kept the working directory, which was always on this machine observe(state, entry) { if (!EventEntry.is(entry)) return state; const { ext, event } = entry.data; return (ext === "code" || ext === "coding") && isLegacyCd(event) ? { ...state, cwd: event.cwd } : state; }, setup(ext) { ext.configuration({ parameters: Type.Object({ environment: Type.String({ title: "environment", default: LOCAL, description: "local, or an ssh environment (see settings)", }), cwd: Type.String({ title: "working directory", default: "", description: "if empty, defaults to $HOME", }), }), values: ({ environment, cwd }) => ({ environment, cwd: cwd ?? "" }), async events({ environment, cwd }, state) { const moving = environment !== state.environment; const env = await environments.open( environment, moving ? null : state.cwd, BACKGROUND_CONTEXT, ); // a change of machine alone leaves the old machine's directory in the form const path = moving && cwd === (state.cwd ?? "") ? "" : cwd; return [{ type: "bind", environment, cwd: await directory(env, path, BACKGROUND_CONTEXT), }]; }, }); return (conv) => { conv.command({ name: "cd", description: "change conversation working directory", execute: (path, ctx) => ctx.cd(path), }); conv.prompt(({ env }) => { if (env === undefined) { return `the environment ${conv.state.environment} cannot be reached right now: ` + "calls that use its files or commands fail until it can"; } if (env.name === LOCAL) return undefined; return `files and commands are on ${env.name} (${env.platform}), not on the machine berry runs ` + "on: berry's own source and data are out of reach from there"; }); }; }, }); }