Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160// The Node-only half of @radial/atproto, imported as `@radial/atproto/node`.//// The package's main entry is deliberately isomorphic: transport, resolvers, and CredentialClient// reach for nothing but `fetch` and `URL`, so a browser bundle can run the identical read and write// paths the daemon and CLI run. Everything that needs a filesystem, a DNS resolver, or SQLite lives// here instead — a bundler that follows the main entry never sees `node:fs` or `node:sqlite`.
import { chmod, lstat, mkdir, readFile, rename, writeFile } from 'node:fs/promises'import { dirname, join } from 'node:path'import { DatabaseSync } from 'node:sqlite'import type { ActorSession, IdentityResolution, TxtResolver } from './client.js'import type { StrongRef } from '@radial/core'
export interface StoredActor extends ActorSession { profile: string agentProfile?: StrongRef}
interface SessionFile { version: 1 defaultProfile?: string profiles: Record<string, StoredActor>}
const object = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
const sessionQueues = new Map<string, Promise<void>>()
export class FileSessionStore { readonly path: string
constructor(directory = defaultDataDirectory()) { this.path = join(directory, 'sessions.json') }
async #read(): Promise<SessionFile> { try { const info = await lstat(this.path) if (info.isSymbolicLink() || !info.isFile()) { throw new Error(`Session path is not a regular non-symlink file: ${this.path}`) } if ((info.mode & 0o077) !== 0) await chmod(this.path, 0o600) const value = JSON.parse(await readFile(this.path, 'utf8')) as unknown if (!object(value) || value.version !== 1 || !object(value.profiles)) { throw new TypeError('Invalid Radial session file') } return value as unknown as SessionFile } catch (error) { if (object(error) && error.code === 'ENOENT') return { version: 1, profiles: {} } throw error } }
async get(profile?: string): Promise<StoredActor | undefined> { const state = await this.#read() const selected = profile ?? state.defaultProfile return selected ? state.profiles[selected] : undefined }
/** Every stored profile, and which one `get()` returns by default — for UIs that offer a picker. */ async list(): Promise<{ profiles: StoredActor[]; defaultProfile?: string }> { const state = await this.#read() return { profiles: Object.values(state.profiles), ...(state.defaultProfile ? { defaultProfile: state.defaultProfile } : {}), } }
async save(actor: StoredActor, makeDefault = true): Promise<void> { const directory = dirname(this.path) await mkdir(directory, { recursive: true, mode: 0o700 }) await chmod(directory, 0o700) await this.#withLock(async () => { const state = await this.#read() state.profiles[actor.profile] = actor if (makeDefault || !state.defaultProfile) state.defaultProfile = actor.profile await this.#write(state) }) }
async coordinateRefresh( profile: string, current: ActorSession, refresh: (session: ActorSession) => Promise<ActorSession>, ): Promise<ActorSession> { return this.#withLock(async () => { const state = await this.#read() const stored = state.profiles[profile] if (!stored) throw new Error(`Radial profile not found during refresh: ${profile}`) if (stored.refreshJwt !== current.refreshJwt) return stored const rotated = await refresh(stored) state.profiles[profile] = { ...stored, ...rotated } await this.#write(state) return rotated }) }
async #write(state: SessionFile): Promise<void> { const temporary = `${this.path}.tmp-${Math.random().toString(36).slice(2)}` await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }) await chmod(temporary, 0o600) await rename(temporary, this.path) await chmod(this.path, 0o600) }
async #withLock<T>(operation: () => Promise<T>): Promise<T> { // A module-local queue prevents a synchronous SQLite wait from blocking an // async holder in this process. SQLite supplies the cross-process advisory // lock and releases it automatically if its owner exits or crashes. const previous = sessionQueues.get(this.path) ?? Promise.resolve() let release = (): void => {} const held = new Promise<void>((resolve) => { release = resolve }) const queued = previous.then(() => held) sessionQueues.set(this.path, queued) await previous
const database = new DatabaseSync(`${this.path}.lock.db`) try { database.exec(` PRAGMA busy_timeout = 30000; CREATE TABLE IF NOT EXISTS session_lock (id INTEGER PRIMARY KEY) STRICT; BEGIN IMMEDIATE; `) return await operation() } finally { try { database.exec('COMMIT') } finally { database.close() release() if (sessionQueues.get(this.path) === queued) sessionQueues.delete(this.path) } } }}
/** The operator's Radial state directory. `env` is a parameter rather than a read of `process.env` * so a caller resolving a data directory for one *instance* (daemon `instance.ts`) can decide the * precedence itself and still land on the same default. */export function defaultDataDirectory(env: Record<string, string | undefined> = process.env): string { if (env.RADIAL_DATA_DIR) return env.RADIAL_DATA_DIR if (env.XDG_STATE_HOME) return join(env.XDG_STATE_HOME, 'radial') if (!env.HOME) throw new Error('Set RADIAL_DATA_DIR or HOME') return join(env.HOME, '.local', 'state', 'radial')}
/** * The `_atproto` DNS TXT lookup, which only a Node process can do. Pass it to `resolveHandleDid` / * `resolveIdentityPds` to get the full two-method handle resolution; without it those fall back to * the HTTPS well-known method (and any XRPC `directory` supplied), which is what a browser uses. */export const nodeResolveTxt: TxtResolver = async (hostname: string) => (await import('node:dns/promises')).resolveTxt(hostname)
/** `nodeResolveTxt` as an `IdentityResolution`, for call sites that pass options straight through. */export const nodeIdentityResolution: IdentityResolution = { resolveTxt: nodeResolveTxt }