~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs-agents

agents and atirc modules for atlibs: launch agents from a composition, chat on atirc; pages and the agents service

0
0
main
185 lines · 7.1 KB Raw History
import { Cubit } from 'atlibs/cubit/index.js'
import { serviceAud, sessionXrpc } from 'atlibs/run-module/host-call.js'

export const LAUNCH = "com.fedfork.agents.launch";
export const NSID = { launch: "com.fedfork.agents.launchAgent", get: "com.fedfork.agents.getAgent", shutdown: "com.fedfork.agents.shutdownAgent" };
// The one OAuth session this page needs: the user's PDS minting service auth for the three methods.
// aud=* because the service's host name is the page's setting (a did:key ingress host), not a constant.
export const SCOPE = ["atproto", ...Object.values(NSID).map((nsid) => `rpc:${nsid}?aud=*`)].join(" ");
// The agents service this page talks to by default (service/ on xrpc.fedproxy.com).
export const DEFAULT_SERVICE = "https://did-key-zdnaewfbq92fbgek476eg2kmnykfbvzphxfwwexy7x3cp6dyc.xrpc.fedproxy.com";

// Page state.
//   session: "loading" | "signed-out" | "signed-in" | "error"
//   service: the agents service's https origin
//   agents: [{rkey, uri, record, guest?}] this account launched from this browser, newest first
//   busy / progress / results: per launch rkey ("launch" while a launch has no rkey yet)
const INITIAL = {
	session: "loading",
	did: null,
	handle: null,
	error: null,
	login: { busy: false, error: null },
	service: DEFAULT_SERVICE,
	agents: [],
	busy: {},
	progress: {},
	results: {},
};

// The agents service over XRPC as the signed-in account: each call carries a
// service-auth token the user's PDS mints (com.atproto.server.getServiceAuth, aud = the service
// host's did:web, lxm = the method). The service records the caller and answers only its launches,
// so the page keeps the rkeys it launched (localStorage, per account) and reads them back.
//   client     Promise of a BrowserOAuthClient
//   makeAgent  session => @atproto/api Agent
//   fetch      for the service (tests stub it)
//   storage    localStorage-like, for the service origin and the launched rkeys
export class AgentsCubit extends Cubit {
	#client;
	#makeAgent;
	#fetch;
	#storage;
	#agent;

	constructor({ client, makeAgent, fetch = globalThis.fetch.bind(globalThis), storage = globalThis.localStorage, service }) {
		super({ ...INITIAL, service: service ?? storage?.getItem("agents.service") ?? DEFAULT_SERVICE });
		this.#client = client;
		this.#makeAgent = makeAgent;
		this.#fetch = fetch;
		this.#storage = storage;
	}

	async init() {
		try {
			const result = await (await this.#client).init();
			if (!result) return this.emit({ session: "signed-out" });
			this.#agent = this.#makeAgent(result.session);
			const res = await this.#agent.com.atproto.server.getSession();
			if (!res.success) throw new Error(JSON.stringify(res));
			this.emit({ session: "signed-in", did: this.#agent.did, handle: res.data.handle });
		} catch (error) {
			return this.emit({ session: "error", error: `An error occurred: ${error.message}` });
		}
		await this.refresh();
	}

	async login(identifier) {
		this.emit({ login: { busy: true, error: null } });
		try {
			await (await this.#client).signIn(identifier, { signal: new AbortController().signal });
		} catch (error) {
			this.emit({ login: { busy: false, error: `Login error: ${error.message}` } });
		}
	}

	async logout() {
		await (await this.#client).revoke(this.#agent.did).catch(() => undefined);
		this.#agent = undefined;
		this.emit({ ...INITIAL, service: this.state.service, session: "signed-out" });
	}

	async setService(service) {
		const origin = new URL(service).origin;
		if (!origin.startsWith("https://")) throw new Error("the service must be an https origin");
		this.#storage?.setItem("agents.service", origin);
		this.emit({ service: origin, agents: [] });
		await this.refresh();
	}

	// ---- launched rkeys, per account and service -----------------------------------------------
	get #key() {
		return `agents.launches:${this.state.did}:${this.state.service}`;
	}
	#known() {
		try {
			return JSON.parse(this.#storage?.getItem(this.#key) ?? "[]");
		} catch {
			return [];
		}
	}
	#remember(rkeys) {
		this.#storage?.setItem(this.#key, JSON.stringify(rkeys));
	}

	// ---- XRPC -----------------------------------------------------------------------------------
	async #token(nsid) {
		const params = { aud: serviceAud(this.state.service), lxm: nsid };
		return (await sessionXrpc(this.#agent)("com.atproto.server.getServiceAuth", { params })).token;
	}

	// Errors keep the service's status and name ("403 Forbidden: ...") for the page and tests.
	async #xrpc(nsid, { params, body } = {}) {
		if (!this.#agent) throw new Error(`${nsid} needs a signed-in session`);
		const token = await this.#token(nsid);
		const res = await this.#fetch(`${this.state.service}/xrpc/${nsid}${params ? `?${new URLSearchParams(params)}` : ""}`, {
			method: body ? "POST" : "GET",
			headers: { authorization: `Bearer ${token}`, ...(body && { "content-type": "application/json" }) },
			body: body && JSON.stringify(body),
		});
		const out = await res.json().catch(() => ({}));
		if (!res.ok) throw Object.assign(new Error(`${res.status} ${out.error ?? "Error"}: ${out.message ?? ""}`), { status: res.status, name: out.error });
		return out;
	}

	#set(key, field, value) {
		this.emit({ [field]: { ...this.state[field], [key]: value } });
	}

	async #run(key, label, work) {
		this.#set(key, "busy", label);
		try {
			const result = await work();
			this.#set(result?.rkey ?? key, "results", result);
			return result;
		} catch (error) {
			this.#set(key, "results", { error: error.message });
			return { error: error.message };
		} finally {
			this.#set(key, "busy", null);
		}
	}

	// Every launch this browser remembers for the account, read back from the service; ones the
	// service no longer has (deleted, another service) are forgotten.
	async refresh() {
		if (!this.#agent) return this.emit({ agents: [] });
		const agents = [];
		const keep = [];
		for (const rkey of this.#known()) {
			try {
				agents.push(await this.#xrpc(NSID.get, { params: { rkey } }));
				keep.push(rkey);
			} catch (error) {
				if (!/RecordNotFound|Forbidden/.test(error.message)) {
					keep.push(rkey);
					agents.push({ rkey, record: { status: "unknown", error: error.message } });
				}
			}
		}
		this.#remember(keep);
		this.emit({ agents });
	}

	// bidders (DIDs, a list or space separated), name, exp_s: the service decides the rest (user_data,
	// the guest command `atirc agent run`).
	async launch({ bidders, name, exp_s }) {
		const body = { ...(bidders && { bidders }), ...(name && { name }), ...(exp_s && { exp_s: Number(exp_s) }) };
		const result = await this.#run("launch", "launch", () => this.#xrpc(NSID.launch, { body }));
		if (result.rkey) this.#remember([result.rkey, ...this.#known().filter((r) => r !== result.rkey)]);
		await this.refresh();
		return result;
	}

	async status(rkey) {
		const result = await this.#run(rkey, "status", () => this.#xrpc(NSID.get, { params: { rkey } }));
		if (result.record) this.emit({ agents: this.state.agents.map((a) => (a.rkey === rkey ? result : a)) });
		return result;
	}

	async shutdown(rkey, remove = false) {
		const result = await this.#run(rkey, "shutdown", () => this.#xrpc(NSID.shutdown, { body: { rkey, delete: remove } }));
		await this.refresh();
		return result;
	}
}