johnandersen777.bsky.social/atlibs-socialweb-computer-requester
socialweb.computer market requester and policy modules for atlibs, with its web pages and lexicons
0
0
import { Cubit } from 'atlibs/cubit/index.js'
import { enabledFeatures, grantedSessions, requestScope } from 'atlibs/oauth/index.js'
// Lines atirc makes permanent vibescrobble records of: a leading emoji, as atirc's
// cli/src/server/scrobble.rs leading_emoji decides it (U+1F000-1FAFF, BMP Emoji_Presentation,
// VS16, keycaps).
const SCROBBLE = /^(?:[\u{1F000}-\u{1FAFF}]|\p{Emoji_Presentation}|\S️|[0-9#*]️?⃣)/u;
const QUESTION = /^\[(ask|answer|decided) ([0-9a-f-]{36})\] ?([\s\S]*)$/;
// An agent asks the page to request a VM: "vm-request {json requester request input}".
// The page answers "vm-request <seq>: ...".
const VM_REQUEST = /^vm-request (\{[\s\S]*\})\s*$/;
const VM_REPLY = /^vm-request (\d+):/;
const REQUEST_KEYS = ["role", "user_data", "bidders", "policies"];
// What a channel line is: scrobble | ask | answer | decided | vm-request | vm-reply | chat.
export function classify({ text }) {
if (SCROBBLE.test(text)) return { kind: "scrobble" };
const q = text.match(QUESTION);
if (q) return { kind: q[1], id: q[2], body: q[3] };
const reply = text.match(VM_REPLY);
if (reply) return { kind: "vm-reply", seq: Number(reply[1]) };
const request = text.match(VM_REQUEST);
if (request) {
try {
const input = JSON.parse(request[1]);
return { kind: "vm-request", input: Object.fromEntries(REQUEST_KEYS.filter((k) => k in input).map((k) => [k, input[k]])) };
} catch {}
}
return { kind: "chat" };
}
// atirc's handle channel: "#" + handle, dots to underscores, lowercased.
export const handleChannel = (handle) => `#${String(handle).replaceAll(".", "_").toLowerCase()}`;
// Whether a question line could have come from atirc for the channel's account. Anyone may
// post "[answer <id>] ..." text, so:
// ask from the channel owner's account (its DID: the owner or one of its agents)
// answer from the channel owner itself (the identity whose handle channel this is)
// decided from the agent that asked, or the owner
// Anything else is shown as a plain line flagged unverified. chat.atirc.questions stays the
// authoritative record.
function verified(line, id, { owner, ownerDid, questions }) {
if (line.kind === "ask") return Boolean(ownerDid) && line.did === ownerDid && !questions.has(line.id);
if (line.kind === "answer") return id === owner && questions.has(line.id);
return questions.has(line.id) && (id === questions.get(line.id).askedBy || id === owner);
}
// Agents (one per identity, i.e. subhandle), owner questions as the channel tells them, and
// VM requests from agents of `did` (the signed-in account) with whether one was answered.
// did the signed-in account; handle its handle; channel the channel read
export function channelView(list, { did, handle, channel }) {
const agents = new Map();
const questions = new Map();
const requests = [];
const replied = new Set();
const owner = list.find((m) => m.identity && handleChannel(m.identity) === channel?.toLowerCase())?.identity
?? (handle && handleChannel(handle) === channel?.toLowerCase() ? handle : null);
const ownerDid = list.find((m) => m.identity === owner)?.did ?? (owner === handle ? did : null);
const lines = list.map((m) => {
const line = { ...m, ...classify(m) };
const id = line.identity ?? line.nick;
const agent = agents.get(id) ?? { identity: id, nick: line.nick, did: line.did, lines: 0, scrobbles: 0, last: null };
agents.set(id, { ...agent, lines: agent.lines + 1, scrobbles: agent.scrobbles + (line.kind === "scrobble"), last: line.ts });
if (["ask", "answer", "decided"].includes(line.kind)) {
if (!verified(line, id, { owner, ownerDid, questions })) return { ...line, kind: "chat", unverified: true };
if (line.kind === "ask") questions.set(line.id, { id: line.id, question: line.body, askedBy: id, status: "pending" });
else questions.set(line.id, { ...questions.get(line.id), status: line.kind === "answer" ? "answered" : "decided", outcome: line.body, by: id });
}
if (line.kind === "vm-reply" && line.did === did) replied.add(line.seq);
if (line.kind === "vm-request" && did && line.did === did) requests.push({ seq: line.seq, identity: id, input: line.input });
return line;
});
return {
owner,
lines,
agents: [...agents.values()].sort((a, b) => String(b.last).localeCompare(String(a.last))),
questions: [...questions.values()].reverse(),
requests: requests.map((r) => ({ ...r, replied: replied.has(r.seq) })).reverse(),
};
}
// One BrowserOAuthClient session shared by the module cubits. client(name) is the
// { init, signIn, revoke } a module cubit expects of a BrowserOAuthClient: init() hands it
// the session only when the session's granted scope covers that module's scope.
export function sharedSessions(client, policy) {
let ready;
const init = () => (ready ??= (async () => {
const result = await (await client).init();
if (!result) return { session: null, granted: [] };
const { scope } = await result.session.getTokenInfo(false);
return { session: result.session, granted: grantedSessions(policy, scope) };
})());
const signIn = async (identifier, names) => (await client).signIn(identifier, { scope: requestScope(policy, names), signal: new AbortController().signal });
return {
init,
signIn,
async revoke(did) {
await (await client).revoke(did).catch(() => undefined);
ready = Promise.resolve({ session: null, granted: [] });
},
client: (name) => ({
init: async () => {
const { session, granted } = await init();
return granted.includes(name) ? { session } : undefined;
},
signIn: async (identifier) => signIn(identifier, [...(await init()).granted, name]),
// The page revokes the one session (SocialwebCubit.logout); a module alone cannot.
revoke: async () => undefined,
}),
};
}
// session: "loading" | "signed-out" | "signed-in" | "error"; granted: module session names;
// features: what the granted sessions unlock (agents = requester + atirc).
// approvals: {seq: {status: "running" | "done" | "error", result?, error?}} of agent VM requests.
const INITIAL = {
session: "loading",
did: null,
error: null,
login: { busy: false, error: null },
granted: [],
features: [],
allowAgentRequests: false,
approvals: {},
announce: { status: "idle", error: null },
};
// The page: the shared session, which module sessions it holds, and the agent features that
// need both. The module cubits (AtircCubit, WebrtcCubit) keep their own state and are driven
// from here for the agent features.
// sessions sharedSessions(client, policy)
// policy { sessions, features }
// atirc AtircCubit built with sessions.client("atirc")
// compute WebrtcCubit built with sessions.client("requester")
export class SocialwebCubit extends Cubit {
#sessions;
#policy;
#atirc;
#compute;
constructor({ sessions, policy, atirc, compute }) {
super(INITIAL);
this.#sessions = sessions;
this.#policy = policy;
this.#atirc = atirc;
this.#compute = compute;
}
async init() {
try {
const { session, granted } = await this.#sessions.init();
this.emit(session ? { session: "signed-in", did: session.did, granted, features: enabledFeatures(this.#policy, granted) } : { session: "signed-out" });
} catch (error) {
return this.emit({ session: "error", error: `An error occurred: ${error.message}` });
}
// Each module cubit gets the session only if it was granted; signed out, they say so.
await Promise.all([this.#atirc.init(), this.#compute.init()]);
}
// One consent screen for the identity plus the chosen module sessions.
async login(identifier, names) {
this.emit({ login: { busy: true, error: null } });
try {
await this.#sessions.signIn(identifier, names);
} catch (error) {
this.emit({ login: { busy: false, error: `Login error: ${error.message}` } });
}
}
// Grow or shrink the session to these module sessions (a new consent, then a redirect back).
setSessions(names) {
return this.#sessions.signIn(this.state.did, names);
}
async logout() {
await this.#sessions.revoke(this.state.did);
await Promise.all([this.#atirc, this.#compute].filter((c) => c.state.session === "signed-in").map((c) => c.logout()));
this.emit({ ...INITIAL, session: "signed-out" });
}
#require(feature) {
if (!this.state.features.includes(feature)) throw new Error(`${feature} needs the ${this.#policy.features[feature].requires.join(" and ")} sessions`);
}
// Agent feature: post the VM this page is connected to on the atirc channel.
async announce() {
this.emit({ announce: { status: "busy", error: null } });
try {
this.#require("agents");
const { receipt, tag, phase } = this.#compute.state;
if (!receipt) throw new Error("find a guest first: no receipt");
await this.#atirc.post(`vm: ${receipt.uri} tag ${tag} ${phase} (socialweb-computer-ui)`);
const { post } = this.#atirc.state;
this.emit({ announce: post.status === "error" ? { status: "error", error: post.error } : { status: "done", error: null } });
} catch (error) {
this.emit({ announce: { status: "error", error: error.message } });
}
}
allowAgentRequests(allow) {
this.emit({ allowAgentRequests: Boolean(allow) });
}
// Agent feature: run an agent's "vm-request {...}" line as the requester `request` entry,
// once the owner approves it here, and answer it on the channel with the RFP.
async approve(seq) {
const set = (approval) => this.emit({ approvals: { ...this.state.approvals, [seq]: approval } });
set({ status: "running" });
try {
this.#require("agents");
if (!this.state.allowAgentRequests) throw new Error("agent VM requests are not allowed on this page");
const { messages, handle, channel } = this.#atirc.state;
const request = channelView(messages.list, { did: this.state.did, handle, channel }).requests.find((r) => r.seq === seq);
if (!request) throw new Error(`no vm-request ${seq} from your agents on this channel`);
if (request.replied) throw new Error(`vm-request ${seq} was already answered`);
const result = await this.#compute.run("request", request.input);
set({ status: "done", result });
await this.#atirc.post(`vm-request ${seq}: rfp ${result.rfp?.uri} vm ${result.vm?.uri} submitted to ${result.submitted?.length ?? 0} bidder(s)`);
} catch (error) {
set({ status: "error", error: error.message });
}
}
}