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

johnandersen777.bsky.social/atlibs-socialweb-computer-app

app.socialweb.computer: socialweb.computer in one page, one atlibs composition

0
0
main
358 lines · 17.3 KB Raw History
// Wiring only: the session, the transport, the views. The logic is app.js; chat is atlibs-atirc (vendor/).
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
import { secp256k1 } from '@noble/curves/secp256k1.js'
import * as age from 'age-encryption'
import { attestHost } from '@publicdomainrelay/atlibs/attest/index.js'
import { pageClientId, requestScope } from '@publicdomainrelay/atlibs/oauth/index.js'
import { createPluginContext } from '@publicdomainrelay/atlibs/plugin-context/index.js'
import { WebrtcHost } from '@publicdomainrelay/atlibs-webrtc/index.js'
import { recordSignals } from '@publicdomainrelay/atlibs-webrtc/record-signals.js'

import { QUICK, bidderDids, channelOf, groupBidders, guestOf, liveness, loadApp, must, paramDefault, runner, sessionRepo, vmSignals } from './app.js'
import { atirc } from './vendor/atlibs-atirc.js'

const $ = (s) => document.querySelector(s)
const el = (tag, props = {}, children = []) => {
	const node = Object.assign(document.createElement(tag), props)
	node.append(...children.filter((c) => c !== undefined && c !== null))
	return node
}
const status = (text = '') => ($('#status').textContent = text)
const fail = (error) => {
	$('#error').hidden = !error
	$('#error').textContent = error ? String(error.message ?? error) : ''
}
const busy = async (button, fn) => {
	button?.setAttribute('aria-busy', 'true')
	if (button) button.disabled = true
	try {
		fail()
		return await fn()
	} catch (error) {
		fail(error)
	} finally {
		button?.removeAttribute('aria-busy')
		if (button) button.disabled = false
	}
}

// --- header: theme and menu, as exec.fedfork.com's
const root = document.documentElement
const dark = matchMedia('(prefers-color-scheme: dark)')
const isDark = () => (root.dataset.theme ? root.dataset.theme === 'dark' : dark.matches)
const themed = () => document.querySelectorAll('[data-theme-toggle]').forEach((b) => b.setAttribute('aria-checked', String(isDark())))
themed()
dark.addEventListener('change', themed)
for (const b of document.querySelectorAll('[data-theme-toggle]'))
	b.onclick = () => {
		root.dataset.theme = isDark() ? 'light' : 'dark'
		try { localStorage.setItem('theme', root.dataset.theme) } catch {}
		themed()
	}
const header = $('.site-header')
const menu = $('[data-menu-toggle]')
const openMenu = (on) => (header.classList.toggle('is-open', on), menu.setAttribute('aria-expanded', String(on)))
menu.onclick = () => openMenu(menu.getAttribute('aria-expanded') !== 'true')

// --- views: one section per hash
const views = {}
function show() {
	const id = (location.hash.slice(1) || 'running').split('?')[0]
	const view = document.getElementById(id)?.hasAttribute('data-view') ? id : 'running'
	document.querySelectorAll('[data-view]').forEach((s) => s.classList.toggle('is-active', s.id === view))
	document.querySelectorAll('.site-nav a[href^="#"]').forEach((a) => (a.getAttribute('href') === `#${view}` ? a.setAttribute('aria-current', 'page') : a.removeAttribute('aria-current')))
	openMenu(false)
	views[view]?.()
}
addEventListener('hashchange', show)

// --- session: OAuth (one sign-in, every module session's scope); a test may set __ATLIBS_SESSION__ first
const ctx = createPluginContext({ id: 'app-socialweb-computer', storage: { managed_records: {} }, settingsSchema: { apiKey: { type: 'secret' }, attestKeys: { type: 'secret' } } })
const base = new URL('./', import.meta.url)
const app = await loadApp(ctx, base)
let session = globalThis.__ATLIBS_SESSION__
let client
if (!session) {
	client = await BrowserOAuthClient.load({ clientId: await pageClientId(requestScope(app.stage, Object.keys(app.stage.sessions))), handleResolver: 'https://bsky.social' }).catch((e) => (fail(e), undefined))
	session = (await client?.init().catch(fail))?.session
}
const me = session?.did
$('#sign').textContent = me ? 'Sign out' : 'Sign in'
$('#sign').onclick = async (e) => {
	if (!me) return
	e.preventDefault()
	await client?.revoke(me).catch(() => undefined)
	location.reload()
}
$('#login').onsubmit = (e) => busy(e.submitter, async () => {
	e.preventDefault()
	await client.signIn(new FormData(e.target).get('handle'))
})

// --- the transport: the requester signs with an attestation key kept here; WebRTC to the VMs' shells and the
// bidders' webrtc endpoints, signalled through requester runs (as you) and computer.socialweb.ingress records
let run = runner(ctx, app, session)
if (session) {
	const vm = { attest: attestHost(ctx, secp256k1, me) }
	run = runner(ctx, app, session, vm)
	const records = recordSignals({ repo: sessionRepo(ctx, session), subhandle: 'requester' })
	const webrtc = new WebrtcHost({ ...vmSignals(must(run)), records, makePeer: (config) => new RTCPeerConnection(config), age })
	Object.assign(vm, { calls: webrtc.calls, fetchers: webrtc.fetchers() })
}
const call = must(run)

// --- names: DIDs shown as handles (public profiles)
const handles = new Map()
async function named(dids) {
	const want = [...new Set(dids)].filter((d) => d?.startsWith('did:') && !handles.has(d))
	for (let i = 0; i < want.length; i += 25) {
		const q = want.slice(i, i + 25).map((d) => `actors=${encodeURIComponent(d)}`).join('&')
		const r = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?${q}`).then((r) => r.json(), () => ({}))
		for (const p of r.profiles ?? []) if (p.handle && p.handle !== 'handle.invalid') handles.set(p.did, p.handle)
	}
}
const who = (did) => handles.get(did) ?? (did ? `${did.slice(0, 18)}…${did.slice(-4)}` : '')
const signedOut = (node) => node.replaceChildren(el('p', { className: 'empty', innerHTML: 'Sign in to see this. <a href="#signin">Sign in</a>' }))

// --- Running
let vms = []
let agents = []
const card = (title, meta, children = []) => el('article', { className: 'card' }, [el('h3', { textContent: title }), ...meta.map((m) => el('p', { className: 'meta', textContent: m })), ...children])
const bidderCard = (r) => {
	const beat = liveness(r.beatAt)
	const ends = (r.endpoints ?? []).map((e) => e.kind).join(', ') || 'none'
	return card(who(r.did), [r.did, `operator ${r.operator ? who(r.operator) : r.claimedOperator ? `${who(r.claimedOperator)} (unconfirmed)` : 'none'}`, `endpoints ${ends}`, `bids on ${(r.appliesTo ?? []).join(', ') || 'nothing'}`], [
		el('span', { className: `pill ${r.withdrawn ? 'pill--off' : beat.live ? 'pill--on' : 'pill--bad'}`, textContent: r.withdrawn ? 'withdrawn' : `heartbeat ${beat.age}` }),
	])
}
const vmCard = (v) =>
	card(v.vm, [`${v.cpus} vCPUs, ${v.mem} GiB from ${who(v.bidder)}`, `since ${v.created}`, v.expired ? 'expired' : `until ${v.expires}`], [
		el('div', { className: 'toolbar' }, [
			el('a', { className: 'btn btn--small btn--ghost', href: `#shell?vm=${v.vm}`, textContent: 'Shell' }),
			el('button', {
				className: 'btn btn--small btn--danger',
				textContent: 'Release',
				onclick: (e) => busy(e.target, async () => {
					status(`releasing ${v.vm}…`)
					await call('vm', 'vm_release', { vm: v.vm })
					status(`${v.vm} released`)
					await loadVms()
					views.running()
				}),
			}),
		]),
	])
const fill = (node, items, empty) => node.replaceChildren(...(items.length ? items : [el('p', { className: 'empty', textContent: empty })]))
async function loadVms() {
	vms = me ? ((await call('vm', 'vm_list', {})).vms ?? []) : []
	return vms
}
async function loadAgents() {
	agents = me ? ((await call('agent', 'list', {})).agents ?? []) : []
	return agents
}
let vouched = new Map()
async function loadVouches() {
	const list = me ? ((await call('vouches', 'list', {})).vouches ?? []) : []
	vouched = new Map(list.filter((v) => v.kind !== 'denounce').map((v) => [v.subject, v]))
	return list
}
views.running = () => busy($('#refresh-running'), async () => {
	if (!me) return [$('#my-bidders'), $('#my-vms'), $('#vouched-bidders')].forEach(signedOut)
	status('reading the market…')
	await Promise.all([loadVouches(), loadVms()])
	// the bidders you follow, and those each account you vouch for follows (its computer.socialweb.bidders/self)
	const known = await Promise.all([me, ...vouched.keys()].slice(0, 50).map((of) => call('status', 'known', { of }).then((r) => r.bidders ?? [], () => [])))
	const dids = bidderDids(paramDefault(app.manifest, 'bidders'), known.flat(), me, [...vouched.keys()])
	const rows = (await call('status', 'bidders', { dids })).bidders ?? []
	await named([...rows.flatMap((r) => [r.did, r.operator, r.claimedOperator]), ...vms.map((v) => v.bidder)])
	const groups = groupBidders(rows, me, new Set(vouched.keys()))
	fill($('#my-bidders'), groups.mine.map(bidderCard), 'No bidders of yours. Follow one you run by its DID.')
	fill($('#my-vms'), vms.map(vmCard), 'No VMs kept: creating an agent rents one.')
	fill($('#vouched-bidders'), groups.vouched.map(bidderCard), 'No bidders run by accounts you vouch for.')
	status('')
})
$('#refresh-running').onclick = () => views.running()
$('#track').onsubmit = (e) => busy(e.submitter, async () => {
	e.preventDefault()
	await call('status', 'track', { bidder: new FormData(e.target).get('bidder') })
	e.target.reset()
	views.running()
})

// --- Vouches
views.vouches = () => busy(null, async () => {
	if (!me) return signedOut($('#vouch-list'))
	const list = await loadVouches()
	await named(list.map((v) => v.subject))
	fill(
		$('#vouch-list'),
		list.map((v) =>
			card(who(v.subject), [v.kind === 'denounce' ? 'denounced' : 'vouched', v.reason ?? '', v.createdAt ?? ''].filter(Boolean), [
				el('div', { className: 'toolbar' }, [
					el('button', {
						className: 'btn btn--small btn--danger',
						textContent: 'Unvouch',
						onclick: (e) => busy(e.target, async () => (await call('vouches', 'unvouch', { subject: v.subject }), views.vouches())),
					}),
				]),
			]),
		),
		'You have not vouched for anyone yet.',
	)
})
$('#vouch').onsubmit = (e) => busy(e.submitter, async () => {
	e.preventDefault()
	const f = new FormData(e.target)
	const r = await call('vouches', 'vouch', { subject: f.get('subject'), reason: f.get('reason') || undefined })
	status(`vouched for ${r.subject}`)
	e.target.reset()
	views.vouches()
})

// --- Agents
const steps = (text) => $('#launch-steps').append(el('li', { textContent: text }))
const agentCard = (a) =>
	card(a.nick, [a.subhandle, `on ${a.vm ?? 'a VM'}`, a.channels.join(' ')], [
		el('div', { className: 'toolbar' }, [
			el('a', { className: 'btn btn--small', href: `#chat?c=${encodeURIComponent(a.channels[1])}`, textContent: 'Chat 1:1' }),
			el('a', { className: 'btn btn--small btn--ghost', href: `#chat?c=${encodeURIComponent(a.channels[0])}`, textContent: 'Your channel' }),
			el('a', { className: 'btn btn--small btn--ghost', href: `#shell?vm=${a.vm}`, textContent: 'Shell' }),
			el('button', {
				className: 'btn btn--small btn--danger',
				textContent: 'Stop',
				onclick: (e) => busy(e.target, async () => (await call('agent', 'stop', { nick: a.nick }), views.agents())),
			}),
		]),
	])
views.agents = () => busy(null, async () => {
	if (!me) return signedOut($('#agent-list'))
	await Promise.all([loadAgents(), loadVms()])
	$('#vm').replaceChildren(el('option', { value: '', textContent: 'a new VM' }), ...vms.filter((v) => !v.expired && !agents.some((a) => a.vm === v.vm)).map((v) => el('option', { value: v.vm, textContent: `${v.vm} (${v.cpus} vCPUs)` })))
	fill($('#agent-list'), agents.map(agentCard), 'No agents yet.')
	const form = $('#launch')
	for (const k of ['base_url', 'model']) form[k].value ||= (await ctx.settings.get(k)) ?? ''
	form.api_key.placeholder = (await ctx.settings.get('apiKey')) ? 'a key is saved in this browser' : 'your API key'
})
$('#launch').onsubmit = (e) => busy(e.submitter, async () => {
	e.preventDefault()
	const f = Object.fromEntries(new FormData(e.target))
	const api_key = f.api_key || (await ctx.settings.get('apiKey'))
	if (!api_key) throw new Error('an API key, please')
	await Promise.all([ctx.settings.set('apiKey', api_key), ctx.settings.set('base_url', f.base_url), ctx.settings.set('model', f.model)])
	$('#launch-steps').replaceChildren()
	let v = vms.find((x) => x.vm === f.vm)
	if (!v) {
		steps('renting a VM (a bid, the receipt, the guest: a few minutes)…')
		v = await call('vm', 'vm_create', {})
		steps(`${v.vm} is up: ${v.cpus} vCPUs from ${who(v.bidder)}`)
	}
	steps(`installing atirc and starting ${f.nick} on ${v.vm}…`)
	const a = await call('agent', 'launch', { vm: v.vm, shell: v.shell, expires: v.expires, nick: f.nick, model: f.model, base_url: f.base_url, api_key, harness: f.harness || undefined })
	steps(`${a.subhandle} is on the mesh (units: ${a.units}); it says hello in ${a.channels[1]} once its harness is up`)
	e.target.nick.value = ''
	await views.agents()
	location.hash = `#chat?c=${encodeURIComponent(a.channels[1])}`
})

// --- Chat: atlibs-atirc to your node on an agent's VM (its contract's "atirc" WebRTC tunnel); your channel through the newest agent's
const clients = new Map() // vm -> client
let unsubscribe
const clientFor = (a) => {
	if (!clients.has(a.vm)) clients.set(a.vm, atirc({ guests: [guestOf(a.shell, vmSignals(call))], age }))
	return clients.get(a.vm)
}
const via = (channel) => [...agents].reverse().find((a) => a.channels.includes(channel))
views.chat = () => busy(null, async () => {
	if (!me) return signedOut($('#messages'))
	if (!agents.length) await loadAgents()
	const channels = [...new Set(agents.flatMap((a) => a.channels))]
	const want = new URLSearchParams(location.hash.split('?')[1] ?? '').get('c') ?? channels[0]
	$('#channel').replaceChildren(...channels.map((c) => el('option', { value: c, textContent: c, selected: c === want })))
	if (!channels.length) return fill($('#messages'), [], 'No agents yet: create one, then chat here.')
	await openChannel($('#channel').value)
})
async function openChannel(channel) {
	const a = via(channel)
	const client = clientFor(a)
	unsubscribe?.()
	$('#messages').replaceChildren()
	const seen = new Set()
	unsubscribe = client.onMessage((m) => {
		if (m.error) return status(`chat: ${m.error}`)
		if (m.channel !== channel || seen.has(`${m.identity} ${m.seq} ${m.ts}`)) return
		seen.add(`${m.identity} ${m.seq} ${m.ts}`)
		const mine = m.self || m.identity?.startsWith('you-')
		const li = el('li', { className: mine ? 'mine' : '' }, [el('span', { className: 'nick', textContent: mine ? 'you' : m.nick ?? m.identity }), m.text, el('time', { textContent: new Date(m.ts).toLocaleTimeString() })])
		$('#messages').append(li)
		li.scrollIntoView({ block: 'end' })
	})
	status(`joining ${channel} through ${a.vm}…`)
	await client.join(channel)
	const peers = await client.peers(channel).catch(() => null)
	$('#peers').textContent = peers ? `${(peers.peers ?? []).length} peers` : ''
	status('')
}
$('#channel').onchange = (e) => busy(null, () => openChannel(e.target.value))
$('#post').onsubmit = (e) => busy(e.submitter, async () => {
	e.preventDefault()
	const channel = $('#channel').value
	await clientFor(via(channel)).post(channel, e.target.text.value)
	e.target.reset()
})

// --- Shell: commands on a VM you keep (vm-mcp vm_exec, over its MCP shell)
const term = (cls, text) => {
	$('#terminal').append(el('span', { className: cls, textContent: text }))
	$('#terminal').scrollTop = $('#terminal').scrollHeight
}
$('#quick').replaceChildren(...QUICK.map(([label, command]) => el('button', { className: 'btn btn--small btn--ghost', textContent: label, onclick: (e) => exec(e.target, command) })))
async function exec(button, command) {
	const vm = $('#shell-vm').value
	if (!vm) return fail('no VM: create an agent (it rents one)')
	term('cmd', `${vm}$ ${command}\n`)
	await busy(button, async () => {
		const r = await call('vm', 'vm_exec', { vm, command })
		if (r.stdout) term('', r.stdout)
		if (r.stderr) term('err', r.stderr)
		if (r.exit) term('err', `exit ${r.exit}\n`)
	})
}
$('#exec').onsubmit = (e) => (e.preventDefault(), exec(e.submitter, e.target.command.value), e.target.reset())
views.shell = () => busy(null, async () => {
	if (!me) return signedOut($('#terminal'))
	await loadVms()
	const want = new URLSearchParams(location.hash.split('?')[1] ?? '').get('vm')
	$('#shell-vm').replaceChildren(...vms.filter((v) => !v.expired).map((v) => el('option', { value: v.vm, textContent: v.vm, selected: v.vm === want })))
})

// --- Tiles: this page as the mothership (web tiles), the running summary as a tile
let tiles
views.tiles = () => busy(null, async () => {
	tiles ??= await import('./tiles.js').then((m) => m.mothership())
	const open = (url) => busy(null, async () => {
		const state = { session: me ? 'signed-in' : 'signed-out', handle: who(me), vms, agents, vouches: [...vouched.values()] }
		$('#tile').replaceChildren(await tiles.show(url, state))
	})
	fill($('#tile-list'), tiles.list.map((t) => card(t.name, [t.description], [el('div', { className: 'toolbar' }, [el('button', { className: 'btn btn--small', textContent: 'Open', onclick: () => open(t.url) })])])), '')
})

// --- atlibs: the composition this page runs
views.atlibs = async () => {
	const lock = await fetch(new URL('atlibs.lock.json', base)).then((r) => r.json(), () => ({ modules: {} }))
	const edges = app.manifest.edges ?? []
	fill(
		$('#instances'),
		app.manifest.instances.map((i) => {
			const m = lock.modules?.[i.module]
			const session = app.stage.modules[i.module]?.session
			return card(i.name, [`module ${m?.ref ?? i.module}`, m?.pin ? `pin ${m.pin.slice(0, 16)}…` : 'unpublished (staged from its repo)', session ? `scope ${app.stage.sessions[session].scope}` : 'no session: public reads', ...edges.filter((e) => e.from === i.name).map((e) => `${e.nsid} → ${e.to}`)])
		}),
		'',
	)
}

if (session) status(`signed in as ${me}`)
$('#who').textContent = me ? `Signed in as ${me}` : ''
document.body.dataset.ready = 'true'
show()