diff --git a/app/api/skills/route.ts b/app/api/skills/route.ts index 57882e490..852d44300 100644 --- a/app/api/skills/route.ts +++ b/app/api/skills/route.ts @@ -2,6 +2,7 @@ import { chatThreads, draftSkill, saveSkillDraft } from "@/lib/server/procedures import { notReady } from "@/lib/server/ready"; import { bot, chat, getState } from "@/lib/server/store"; import { sharedOpen } from "@/lib/server/shared"; +import { sharedSkillAct, sharedSkillsList } from "@/lib/server/shared-skills"; import { acceptProposal, proposalDiff, rejectProposal } from "@/lib/server/skill-proposals"; import { fromBopsWindow } from "@/lib/server/ui-token"; import { @@ -40,7 +41,9 @@ export const dynamic = "force-dynamic"; * (ui-token.ts): a page a bot's Chrome reached this server with mustn't teach a bot steps or approve a script. * Which labels each bot has (Who has what), a label's name and about line, Suggest labels with AI, and a bot's proposed change * (skill-proposals.ts: accept, reject, or its diff on what the skill is now) are the owner's too: never while a - * workspace shared with the user is open. + * workspace shared with the user is open. While one is open, every call here is about that workspace's skills alone, + * in orgo-web with the user's own Orgo key, for an Admin there (lib/server/shared-skills.ts), never this Mac's folders + * or the user's own workspace of the id a request names. */ const notFromWindow = (request: Request) => (fromBopsWindow(request) ? null : Response.json({ error: "Skills can only be changed in the Bops app." }, { status: 403 })); @@ -62,6 +65,7 @@ function fromOf(v: unknown): SkillRef["from"] { export async function GET(request: Request) { const unready = notReady() ?? notFromWindow(request); if (unready) return unready; + if (sharedOpen()) return sharedSkillsList().then((r) => Response.json(r), failed); const url = new URL(request.url); const wsId = url.searchParams.get("ws") ?? getState().workspace ?? MAIN_WORKSPACE; if (!workspaceById(wsId)) return Response.json({ error: "That workspace is gone." }, { status: 404 }); @@ -75,11 +79,10 @@ export async function POST(request: Request) { const unready = notReady() ?? notFromWindow(request); if (unready) return unready; const b = (await request.json().catch(() => ({}))) as Record; + if (sharedOpen()) return sharedSkillAct(b).then((r) => Response.json(r), failed); const wsId = str(b.ws) ?? getState().workspace ?? MAIN_WORKSPACE; const kind = kindOf(b.kind); const slug = str(b.slug) ?? ""; - const ownerOnly = new Set(["bot-label", "bot-labels", "label-rename", "label-about", "label-suggest", "proposal"]); - if (ownerOnly.has(String(b.action)) && sharedOpen()) return Response.json({ error: "In a shared workspace, only its owner changes this." }, { status: 403 }); try { switch (b.action) { case "detail": diff --git a/cloud/test/core-members.test.ts b/cloud/test/core-members.test.ts index d0fb8a05a..30d9280c1 100644 --- a/cloud/test/core-members.test.ts +++ b/cloud/test/core-members.test.ts @@ -249,6 +249,19 @@ test("a reply a bot drafted for the owner is shown read only: who, where, the wo }); }); +test("a skill a bot saved as a draft comes with its message: which one (kind, slug, name), so an Admin's card opens it in Skills; nothing else of it", () => { + const saved = { kind: "skill", slug: "weekly-recap", name: "Weekly recap", body: "# Weekly recap", botIds: ["max"] }; + assert.deepEqual(sharedMessage({ id: "m1", chatId: "bot:max", role: "bot", botId: "max", text: "Saved it.", at: 3, skillDrafts: [saved] }, new Set(["max"])), { + id: "m1", + chatId: "bot:max", + role: "bot", + botId: "max", + text: "Saved it.", + at: 3, + skillDrafts: [{ kind: "skill", slug: "weekly-recap", name: "Weekly recap" }], + }); +}); + test("a member given a bot sees all of it: the owner's own messages to it, one kept between us, the answers, and the threads that came of it, live too", () => { const blob = { workspaces: [{ id: "ws_acme", name: "Acme", memberAccess: { "sam@example.com": { others: "none", bots: { boppy: "read" } } } }], diff --git a/cloud/ws-slice.ts b/cloud/ws-slice.ts index fa2e8d0ae..d1e16204f 100644 --- a/cloud/ws-slice.ts +++ b/cloud/ws-slice.ts @@ -205,6 +205,9 @@ const MESSAGE: Spec = { // Replies a bot wrote for the owner to send (lib/types.ts ReplyDraft), read only: who and where, the words, and // whether they went. Never the account, thread or channel ids they go by, nor a Send in progress or why one failed. drafts: [{ id: true, app: true, toName: true, to: true, subject: true, where: true, text: true, sent: { at: true, text: true } }], + // Skills a bot saved as drafts (Message.skillDrafts): which one, so an Admin's card opens it in Skills to read and + // publish there with their own sign in (components/app/skill-drafts.tsx). Its name only: the skill is Orgo's. + skillDrafts: [{ kind: true, slug: true, name: true }], }; /** `asking` is a map (asker → teammate): its entries are each a bot id, and with `bots` (someone else's view) both of them a bot they see. */ diff --git a/components/app/bops-app.tsx b/components/app/bops-app.tsx index ad3ae5b4d..fc798777b 100644 --- a/components/app/bops-app.tsx +++ b/components/app/bops-app.tsx @@ -22,6 +22,7 @@ import { crmOpen } from "@/lib/crm-access"; import { categoriesOpen } from "@/lib/memory-categories"; import { tagsOf } from "@/lib/routine-catalog"; import { CrmView, OpenCrm } from "./crm"; +import { OpenSkill } from "./skill-drafts"; import { BoppyLoading, BotMoodsFrom, CallTalking, Mascot, talkingStore } from "./mascot"; import { MemoryView, type MemoryOpen } from "./memory-categories"; import { BROWSER_TAB, BrowserGoTo, browserTabOf, browsesHere, CHAT_TAB, electron, forgetTab, hostOf, isReading, LocalOnly, memoryAgain, NewTab, OpenBotLocal, OpenInBrowser, OpenLink, OpenPreview, TabBar, WebTab, type BrowserGo, type PanelTab, type PreviewOpened } from "./panel-tabs"; @@ -1034,6 +1035,7 @@ function Bops({ state }: { state: AppState | null }) { openSettings(at)}> + @@ -1472,6 +1474,7 @@ function Bops({ state }: { state: AppState | null }) { + diff --git a/components/app/chat-view.tsx b/components/app/chat-view.tsx index 0dbbfd51d..ef22baca7 100644 --- a/components/app/chat-view.tsx +++ b/components/app/chat-view.tsx @@ -40,6 +40,7 @@ import { AskInPlace, ChatAvatar, chatName, clockTime, memberAnswers, needsYou, p import { ReplyDrafts } from "./reply-drafts"; import { dropCitations, isLocalLink, isTableRow as isRow, isTableRule as isRule, localOnlyLine, plainStep, plainTitle, tableCells as cells } from "@/lib/plain-words"; import { SkillProposals } from "./skill-proposals"; +import { SkillDrafts } from "./skill-drafts"; /** Light markdown for bot text: **bold** and [links](url). */ /** How many messages a chat draws at first, and how many more each "Show earlier messages" adds. */ @@ -2093,6 +2094,8 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO {/* Changes it proposed to a skill, each a card with Accept and Reject (read only in a shared workspace). */} + {/* Skills it saved as drafts, each a card that opens it in Skills to read and publish. */} + {m.signIn && m.botId && } {replies} {m.needed && m.resultOf && } diff --git a/components/app/memory-categories.tsx b/components/app/memory-categories.tsx index 0fe37d758..2affdbff2 100644 --- a/components/app/memory-categories.tsx +++ b/components/app/memory-categories.tsx @@ -7,6 +7,7 @@ import { Mascot, Spinner } from "./mascot"; import { IssuesIcon, IssuesPage, isOpen, issuesSoon, useIssues } from "./orbit-issues"; import { SkillsPage, skillsIn } from "./skills"; import type { AppState } from "@/lib/types"; +import { sharedSkillsWho } from "@/lib/shared"; import { isFavoriteKind, type FavoriteKind } from "@/lib/favorites"; import { menuPoint, PinIcon, SideMenu, useFavorites, type Favorites } from "./favorites"; import { Fold } from "./fold"; @@ -747,6 +748,8 @@ function KnowledgeView({ }) { // A workspace shared with the user: its Knowledge is kept on the owner's Bops, so none is read here (view-only.ts would refuse it). const owners = state?.shared ? `Knowledge in ${state.shared.name} is kept on the owner's Bops, so it doesn't show here. To find something out, ask a bot in its chat.` : undefined; + // Its skills, for an Admin there: kept in Orgo, so they show and change here with the owner's Mac off (skills.tsx, shared-skills.ts). + const sharedSkills = !!state?.shared && sharedSkillsWho(state.shared); const { snap, act, failed, retry } = useMemoryCategories(ws, user, !owners); const { list } = useIssues(ws, user, !owners); const forgetting = useForget(ws, user, !owners); @@ -777,7 +780,8 @@ function KnowledgeView({ .catch(() => ({ error: "Couldn't reach Bops." }) as Answer & { error?: string }); return r.error ?? r; }, [ws]); - if (owners) return ; + if (owners && sharedSkills && category === "skills" && state) return ; + if (owners) return onCategory("skills") : undefined} act={act} forgetting={forgetting} category="all" onCategory={onCategory} onClose={onClose} />; if (category === "issues") return ( void; /** A workspace shared with the user: what the page says in place of its Knowledge, which is the owner's (no kinds, nothing to add or ask). */ owners?: string; + /** With `owners`: its skills show here for this viewer (an Admin there), opened from under the line. */ + onSkills?: () => void; act: MemoryAct; ask?: (question: string) => Promise; /** Forget, with Undo (useForget). */ @@ -1128,7 +1135,14 @@ export function MemoryPage({ const listView = (
{owners ? ( -

{owners}

+
+

{owners}

+ {onSkills && ( + + )} +
) : !snap && failed ? (
{failed} diff --git a/components/app/skill-drafts.tsx b/components/app/skill-drafts.tsx new file mode 100644 index 000000000..72c988203 --- /dev/null +++ b/components/app/skill-drafts.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { sharedSkillsWho } from "@/lib/shared"; +import type { AppState, Message } from "@/lib/types"; + +/* + * Skills a bot saved as drafts in a chat (Message.skillDrafts, chat.ts create_skill), as cards under its message: which + * skill, and Review and publish, which opens it in Skills, in Knowledge (skills.tsx), where the person reads it and + * publishes it with their own sign in. The card itself never publishes anything. Shown to the owner, and in a workspace + * shared with the user to an Admin there (lib/shared.ts sharedSkillsWho); nobody else sees it. + */ + +/** Opens Knowledge, Skills on one skill ("skill:"): bops-app.tsx gives it, as it gives OpenCrm. */ +export const OpenSkill = createContext<((item?: string) => void) | null>(null); + +const btn = "shrink-0 rounded-full bg-ink px-3 py-1.5 text-[12.5px] font-medium leading-4 text-white disabled:opacity-50"; + +export function SkillDrafts({ message: m, state, className = "" }: { message: Message; state: AppState; className?: string }) { + const open = useContext(OpenSkill); + if (!m.skillDrafts?.length) return null; + if (state.shared && !sharedSkillsWho(state.shared)) return null; + // On the owner's own Bops its state says whether it was published since (a shared workspace's skills are Orgo's, read in Skills). + const refs = state.shared ? [] : (state.workspaces ?? []).flatMap((w) => [...(w.skills ?? []), ...(w.apps ?? [])]); + return ( +
+ {m.skillDrafts.map((d) => { + const published = refs.find((r) => r.kind === d.kind && r.slug === d.slug)?.status === "published"; + return ( +
+ + {published ? `Published: ${d.name}` : `Saved as a draft: ${d.name}`} + + {open && ( + + )} +
+ ); + })} +
+ ); +} diff --git a/components/app/skills.tsx b/components/app/skills.tsx index 93c21e226..696d04d5b 100644 --- a/components/app/skills.tsx +++ b/components/app/skills.tsx @@ -24,11 +24,20 @@ import { CloseButton, currentWorkspace, post, teamOf } from "./ui"; * it's for (the line its bots' prompts show). Suggest labels with AI (the owner's) turns off a small model's pick * of a label for a new skill. The lists are the app's state (Workspace.skills, Workspace.apps, * Workspace.skillLabelInfo, the bots), so they're as current as everything else. + * + * In a workspace shared with the user, for an Admin there (lib/shared.ts sharedSkillsWho), the page is that workspace's + * skills as Orgo keeps them (GET /api/skills: lib/server/shared-skills.ts, with the user's own Orgo key), read again after + * each change: Publish on a draft, Import a skill folder (a new draft), Update from folder (its next version, published + * once they say so), Roll back, Delete, a skill's own bots, and its scripts to approve. Its labels, Who has what and + * Suggest labels stay the owner's, and nothing of this Mac's folders shows (no Open in Finder). */ type Problem = { path: string; reason: string; line?: number }; type FileInfo = { path: string; size: number; sha256: string; executable: boolean; script: boolean; approved: boolean; binary?: boolean }; -type Detail = { ref: SkillRef; body: string; frontmatter: Record; files: FileInfo[]; dir: string | null; missing: boolean; changed: boolean }; +type Detail = { ref: SkillRef; body: string; frontmatter: Record; files: FileInfo[]; dir: string | null; missing: boolean; changed: boolean; shared?: true }; +/** What an import or an Update from folder did, said over the page. `published`: in a shared workspace, it's in use at once. */ +type ImportResult = { name?: string; skipped: Problem[]; warnings: Problem[]; error?: string; updated?: boolean; published?: boolean }; +type Lists = { skills: SkillRef[]; apps: SkillRef[] }; /** The Mac app's bridge for skills (desktop/preload.cjs): a folder picked in Finder, and a folder shown there. */ type Bridge = { pickFolder?: () => Promise; revealFolder?: (dir: string) => Promise }; @@ -167,7 +176,28 @@ async function call(body: Record): Promise<{ ok: true; json: export function SkillsPage({ state, only, focus, focusAt, nav, onClose }: { state: AppState; only?: SkillKind; focus?: string; focusAt?: number; nav?: ReactNode; onClose: () => void }) { const ws = currentWorkspace(state); const team = teamOf(state); - const { skills, apps } = skillsIn(state, ws); + // A workspace shared with the user: its skills as Orgo keeps them, read here (never the state's: the owner's are on their Mac). + const shared = state.shared; + const [remote, setRemote] = useState<{ ws: string; lists: Lists | null; error?: string } | null>(null); + const [reads, setReads] = useState(0); + const reload = useCallback(() => setReads((n) => n + 1), []); + useEffect(() => { + if (!shared) return; + let gone = false; + void fetch("/api/skills", { cache: "no-store" }) + .then(async (r) => ({ ok: r.ok, json: (await r.json().catch(() => ({}))) as Partial & { error?: string } })) + .then(({ ok, json }) => { + if (gone) return; + if (ok) setRemote({ ws, lists: { skills: json.skills ?? [], apps: json.apps ?? [] } }); + else setRemote((was) => ({ ws, lists: was?.ws === ws ? was.lists : null, error: json.error ?? "Couldn't read its skills. Try again." })); + }) + .catch(() => !gone && setRemote((was) => ({ ws, lists: was?.ws === ws ? was.lists : null, error: "Couldn't reach Bops. Try again." }))); + return () => { + gone = true; + }; + }, [shared, ws, reads]); + const here = shared ? (remote?.ws === ws ? remote.lists : null) : null; + const { skills, apps } = shared ? (here ?? { skills: [], apps: [] }) : skillsIn(state, ws); const [open, setOpen] = useState(focus ?? null); const [asked, setAsked] = useState({ focus, focusAt }); if (asked.focus !== focus || asked.focusAt !== focusAt) { @@ -175,10 +205,11 @@ export function SkillsPage({ state, only, focus, focusAt, nav, onClose }: { stat if (focus) setOpen(focus); } const [importing, setImporting] = useState(null); - const [imported, setImported] = useState<{ name?: string; skipped: Problem[]; warnings: Problem[]; error?: string; updated?: boolean } | null>(null); + const [imported, setImported] = useState(null); const [orgo, setOrgo] = useState(false); // Opened: kept in step with Orgo, where the workspace has its own and Orgo has the routes. useEffect(() => { + if (shared) return; let gone = false; void fetch(`/api/skills?sync=1&ws=${encodeURIComponent(ws)}`, { cache: "no-store" }) .then((r) => r.json()) @@ -187,7 +218,7 @@ export function SkillsPage({ state, only, focus, focusAt, nav, onClose }: { stat return () => { gone = true; }; - }, [ws]); + }, [ws, shared]); const picker = bridge()?.pickFolder; const importFolder = async (kind: SkillKind) => { @@ -200,10 +231,13 @@ export function SkillsPage({ state, only, focus, focusAt, nav, onClose }: { stat if (r.ok) { setImported({ name: r.json.ref.name, skipped: r.json.skipped, warnings: r.json.warnings }); setOpen(`${kind}:${r.json.ref.slug}`); + reload(); } else setImported({ error: r.error, skipped: r.problems ?? [], warnings: [] }); }; const ref = open ? [...skills, ...apps].find((r) => `${r.kind}:${r.slug}` === open) : undefined; + // Apps aren't offered in a shared workspace (Bops' apps are the owner's there). + const importKinds = (only ? [only] : (["skill", "app"] as SkillKind[])).filter((k) => !shared || k === "skill"); const info = state.workspaces?.find((w) => w.id === ws)?.skillLabelInfo ?? []; // Bops' own Apps and source control skills only where apps are shown (lib/apps-access.ts): never in a shared workspace. const appsHere = appsOwnShown(state); @@ -236,7 +270,7 @@ export function SkillsPage({ state, only, focus, focusAt, nav, onClose }: { stat
{only === "app" ? "Apps" : "Skills"} - {about} {orgo ? "Kept in step with Orgo." : "Kept on this Mac."} + {about} {shared ? `Kept in Orgo for everyone in ${shared.name}.` : orgo ? "Kept in step with Orgo." : "Kept on this Mac."}
@@ -244,16 +278,30 @@ export function SkillsPage({ state, only, focus, focusAt, nav, onClose }: { stat {!ref && nav} {imported && setImported(null)} />} + {shared && remote?.error && ( + + {remote.error} + + + )} + {shared && !here && !remote?.error && ( + + + Reading its skills + + )} {ref ? ( - setOpen(null)} onUpdated={setImported} /> + setOpen(null)} onUpdated={setImported} onChanged={reload} /> ) : ours ? ( setOpen(null)} /> ) : ( <> - {picker && ( + {picker && importKinds.length > 0 && (
- {kinds.map((k) => ( + {importKinds.map((k) => ( @@ -561,6 +611,7 @@ function SkillView({ labels, onBack, onUpdated, + onChanged, }: { state: AppState; ws: string; @@ -569,14 +620,19 @@ function SkillView({ labels: string[]; onBack: () => void; /** What Update from folder left out, said over the page as an import's is. */ - onUpdated: (result: { name?: string; skipped: Problem[]; warnings: Problem[]; error?: string; updated?: boolean }) => void; + onUpdated: (result: ImportResult) => void; + /** Something changed it: the page's list is read again (a shared workspace's, which isn't in the state). */ + onChanged?: () => void; }) { + const shared = !!state.shared; const [detail, setDetail] = useState(null); const [version, setVersion] = useState(undefined); const [error, setError] = useState(null); const [problems, setProblems] = useState([]); const [busy, setBusy] = useState(null); const [sure, setSure] = useState(false); + // In a shared workspace, a folder picked for Update from folder, waiting for the user to say publish it. + const [folder, setFolder] = useState(null); // Read again after a change here, and whenever the state says it changed (a publish, a roll back, a sync from Orgo, an approval). const [tick, setTick] = useState(0); const read = useCallback(async () => setTick((n) => n + 1), []); @@ -613,14 +669,18 @@ function SkillView({ return false; } await read(); + onChanged?.(); return true; }; const reveal = bridge()?.revealFolder; const picker = bridge()?.pickFolder; // Update from folder: a folder picked in Finder is its next version (a draft to publish), checked as an import is. - const fromFolder = async () => { - const path = await picker?.().catch(() => null); + const fromFolder = async (picked?: string) => { + const path = picked ?? (await picker?.().catch(() => null)); if (!path) return; + // In a shared workspace the folder is published at once: the user says so first. + if (shared && !picked) return setFolder(path); + setFolder(null); setBusy("folder"); setError(null); setProblems([]); @@ -631,11 +691,14 @@ function SkillView({ setProblems(res.problems ?? []); return; } - onUpdated({ name: res.json.ref.name, skipped: res.json.skipped, warnings: res.json.warnings, updated: true }); + onUpdated({ name: res.json.ref.name, skipped: res.json.skipped, warnings: res.json.warnings, updated: true, published: shared }); await read(); + onChanged?.(); }; - const latest = r.versions?.at(-1)?.version; - const canPublish = !version && (r.status === "draft" || !!detail?.changed); + // A shared workspace's versions are Orgo's (its detail says them); Publish there is a draft's alone. + const versions = shared ? (detail?.ref.versions ?? []) : (r.versions ?? []); + const latest = versions.at(-1)?.version; + const canPublish = !version && (shared ? r.status === "draft" : r.status === "draft" || !!detail?.changed); const word = r.kind === "app" ? "app" : "skill"; return ( @@ -647,7 +710,12 @@ function SkillView({ {r.name} {r.status === "draft" ? Draft : v{r.version}} - {r.kind === "skill" && sameLabel(l, cleanLabel(r.label))) ?? cleanLabel(r.label)} labels={labels} busy={!!busy} auto={suggestedLabel(r)} onPick={(l) => void act("label", labelEdit(l))} />} + {r.kind === "skill" && + (shared ? ( + {cleanLabel(r.label)} + ) : ( + sameLabel(l, cleanLabel(r.label))) ?? cleanLabel(r.label)} labels={labels} busy={!!busy} auto={suggestedLabel(r)} onPick={(l) => void act("label", labelEdit(l))} /> + ))} {r.risk && {RISK[r.risk] ?? r.risk}} {r.runsOn && Runs {r.runsOn === "local" ? "on its computer" : `on ${r.runsOn}`}} @@ -659,16 +727,16 @@ function SkillView({
{canPublish && ( )} - {reveal && detail?.dir && !detail.missing && ( + {!shared && reveal && detail?.dir && !detail.missing && ( )} {picker && !version && ( - @@ -677,9 +745,24 @@ function SkillView({ Delete
+ {folder && ( +
+ + Publish the folder {folder.split("/").filter(Boolean).pop()} as its next version? Its bots use it at once. + + + +
+ )} {sure && (
- Delete this {word}, its folder and every version? Its bots stop using it. + + {shared ? `Delete this ${word} and every version, for everyone here? Its bots stop using it.` : `Delete this ${word}, its folder and every version? Its bots stop using it.`} + @@ -699,11 +782,16 @@ function SkillView({
)} - {team.length > 0 && ( + {team.length > 0 && shared && !r.botIds.length && ( + + Every bot with the label {cleanLabel(r.label)} has it. The owner picks which bots have a label. + + )} + {team.length > 0 && (!shared || r.botIds.length > 0) && (
{team.map((b) => { - const on = botHasSkill(r, b); + const on = shared ? r.botIds.includes(b.id) : botHasSkill(r, b); return ( - {!current && ( + {!current && (!shared || r.status === "published") && ( @@ -756,7 +844,7 @@ function SkillView({ ) ) : detail.missing ? ( - Its folder isn't on this Mac. + {shared ? "Orgo has no version of it yet." : "Its folder isn't on this Mac."} ) : ( <> {detail.files.length > 0 && ( @@ -791,22 +879,24 @@ function FileRow({ ws, skill: r, file: f, version, last, onApproved }: { ws: str }; const approve = async (on: boolean) => { setBusy(true); - const res = await call({ action: "approve", ws, sha256: f.sha256, on }); + const res = await call({ action: "approve", ws, sha256: f.sha256, path: f.path, on }); setBusy(false); if (!res.ok) return setError(res.error); setText(null); await onApproved(); }; const size = f.size < 1024 ? `${f.size} B` : f.size < 1024 * 1024 ? `${Math.round(f.size / 1024)} KB` : `${(f.size / 1024 / 1024).toFixed(1)} MB`; + // Read, its first line can make it a script (skill-bundle.ts isScript), which a shared workspace's list can't say. + const script = f.script || !!text?.startsWith("#!"); return (
{f.path} {f.binary ? `Binary, ${size}` : size} - {f.script && (f.approved ? Approved : Needs approval)} - {(!f.binary || f.script) && ( + {script && (f.approved ? Approved : Needs approval)} + {(!f.binary || script) && ( )}
@@ -817,7 +907,7 @@ function FileRow({ ws, skill: r, file: f, version, last, onApproved }: { ws: str ) : (
{text}
)} - {f.script && ( + {script && ( sha256 {f.sha256.slice(0, 16)} diff --git a/components/app/view-only.ts b/components/app/view-only.ts index 0f932e4f1..98f85789a 100644 --- a/components/app/view-only.ts +++ b/components/app/view-only.ts @@ -12,7 +12,7 @@ import { changeRouteOf } from "@/cloud/change-routes"; * or switches the workspace, signing in and out, notices, usage events, what's on screen and what's the * user's own (their vault, account and name) go through. Nor do the reads the server answers from the * user's own bots or workspaces by id (a shared bot's id is often one of theirs, "boppy"): their memory, - * Knowledge's issues, skills, files, screens. The buttons that would change something are hidden or say so too; this is what + * Knowledge's issues, files, screens. The buttons that would change something are hidden or say so too; this is what * holds whatever's missed. A screen's stream and its pictures (an EventSource, an image) never pass through fetch: what * opens them asks readAllowed first. */ @@ -43,13 +43,16 @@ const ALLOWED = [ // on the owner's computer Bops Cloud names for that bot (lib/server/shared.ts sharedFileTarget), for an Admin, with the // user's own Orgo key; never on the user's own bot of that id. (attachments.tsx sendFile asks sendRefused: XMLHttpRequest.) /^\/api\/bots\/files(\?|$)/, + // Skills (skills.tsx, in Knowledge): while a shared workspace is open, every skills route acts on that workspace's skills + // alone, in orgo-web with the user's own Orgo key, for an Admin there (lib/server/shared-skills.ts, which refuses anyone + // else before Orgo is asked); never this Mac's folders or the user's own workspace of that id. + /^\/api\/skills(\/|\?|$)/, ]; /** The reads that don't go while it's open: answered from the user's own bot or workspace of that id, never the shared one's. */ const OWN_READS = [ /^\/api\/memory(\/|\?|$)/, /^\/api\/orbit(\/|\?|$)/, - /^\/api\/skills(\/|\?|$)/, /^\/api\/routines\/suggestions(\/|\?|$)/, // A routine's runs, by id: the user's own routine of that id, never the owner's (its reports read the shared copy only). /^\/api\/routines\/[^/]+\/runs(\/|\?|$)/, diff --git a/docs/releases/v0.0.53.md b/docs/releases/v0.0.53.md new file mode 100644 index 000000000..907bc1a8d --- /dev/null +++ b/docs/releases/v0.0.53.md @@ -0,0 +1,3 @@ +- Your Apps shows Preview and Live up front, with a password button. +- Review changes before you publish them. +- Admins can publish skills, even with the owner's Mac off. diff --git a/lib/server/chat.ts b/lib/server/chat.ts index 1035931b6..95712e587 100644 --- a/lib/server/chat.ts +++ b/lib/server/chat.ts @@ -21,7 +21,7 @@ import { contactLine, createBot, namesTheUser } from "./bots"; import { creditsOut, noteOutOfCredit, outOfCreditWords } from "./cloud"; import { noOwnComputer } from "./plan"; import { agentLabel, agentName, agentNamed, aiOf, modelShort, paysLabel, readyAgents } from "@/lib/agents"; -import { botChatId, live, MAX_PERSONA_CHARS, MIN_EVERY_MINUTES, pairChatId, personaOf, routineSent, settingsOf, TAPBACK_EMOJI, TAPBACKS, saidOf, workBot, workspaceOf, type AppApproval, type Bot, type ChannelKind, type Delivery, type MemberTurn, type Message, type ReplyDraft, type Routine, type RoutineRun, type Schedule, type Session, type SkillProposal, type Tapback } from "@/lib/types"; +import { botChatId, live, MAX_PERSONA_CHARS, MIN_EVERY_MINUTES, pairChatId, personaOf, routineSent, settingsOf, TAPBACK_EMOJI, TAPBACKS, saidOf, workBot, workspaceOf, type AppApproval, type Bot, type ChannelKind, type Delivery, type MemberTurn, type Message, type ReplyDraft, type Routine, type RoutineRun, type Schedule, type Session, type SkillDraftNote, type SkillProposal, type Tapback } from "@/lib/types"; import { computerBriefing, teamBriefing } from "./briefing"; import { belowOf, directOf, entryOf, inLine, mayAskIn, ownMemory, reachOf, readsTeam, worksForNow } from "@/lib/chain"; import { asksNow, chartedFor, handOffRefused, namesList, teamNow } from "./chart"; @@ -1240,6 +1240,8 @@ type ChatPause = Omit & { drafts?: ReplyDraft[]; /** The changes to skills it proposed so far (propose_skill_change), and whose words it read from outside Bops. */ proposals?: SkillProposal[]; + /** The skills it saved as drafts so far (create_skill): a card under its answer opens each in Skills. */ + skillDrafts?: SkillDraftNote[]; readFrom?: string[]; }; @@ -1978,7 +1980,7 @@ export async function resumeChat(a: AppApproval): Promise { } /** What a turn came to: its words, why it failed, the thread it started, or that it paused for the user's OK (resumeChat goes on from there). */ -export type TurnOut = { text: string | null; error?: string; sessionId?: string; paused?: boolean; drafts?: ReplyDraft[]; skillProposals?: SkillProposal[] }; +export type TurnOut = { text: string | null; error?: string; sessionId?: string; paused?: boolean; drafts?: ReplyDraft[]; skillProposals?: SkillProposal[]; skillDrafts?: SkillDraftNote[] }; async function turn(botId: string, chatId: string, opts: TurnOptions = {}): Promise { const b = bot(botId); @@ -2242,7 +2244,7 @@ async function turn(botId: string, chatId: string, opts: TurnOptions = {}): Prom fn( tool, tool === "create_skill" - ? `Write down how you do one kind of job as a new skill of your workspace, so it's done the same way next time. Only when ${owner} asks you to remember how to do something, or says yes to saving one. Put it under one of your labels when one fits, else a new short one. It's saved as a draft: ${owner} publishes it in Skills, in Knowledge, and then you use it.` + ? `Write down how you do one kind of job as a new skill of your workspace, so it's done the same way next time. Only when ${owner} asks you to remember how to do something, or says yes to saving one. Put it under one of your labels when one fits, else a new short one. It's saved as a draft: ${owner} or an Admin reads it and publishes it in Skills, in Knowledge (a card under your answer opens it there), and then you use it.` : `Change one of your skills (by its name), when ${owner} asks you to, or says yes to it. The change is a draft of its next version until ${owner} publishes it in Skills, in Knowledge.`, { name: { type: "string", description: tool === "create_skill" ? "A short name for the kind of job, e.g. \"Weekly invoice summary\"." : "The skill's name, as listed." }, @@ -2566,6 +2568,8 @@ async function turn(botId: string, chatId: string, opts: TurnOptions = {}): Prom const drafts: ReplyDraft[] = [...(kept?.drafts ?? [])]; // The changes to skills it proposed (skill-proposals.ts), and whose words from outside Bops it read: each proposal's card says so. const proposals: SkillProposal[] = [...(kept?.proposals ?? [])]; + // The skills it saved as drafts (create_skill): a card under the answer opens each in Skills, to read and publish. + const skillDrafts: SkillDraftNote[] = [...(kept?.skillDrafts ?? [])]; for (const x of kept?.readFrom ?? []) readFrom.add(x); if (opts.outside) readFrom.add(`This turn came from ${outsideFrom}`); /** Whose words this turn read so far: its own list, and each app account it read with the senders and recipients it saw there. */ @@ -2724,7 +2728,8 @@ async function turn(botId: string, chatId: string, opts: TurnOptions = {}): Prom const saved = await saveSkillFromComputer(workspaceOf(b), { name: title, description: undash(String(p.description ?? "")), body, label, botId }); if (!saved.ok) return `Not saved: ${saved.why}. Nothing was saved. ${offer}`; notes.push(`Saved the skill "${saved.name}" as a draft`); - return `Saved "${saved.name}" as a draft in your workspace's skills. ${owner} can publish it in Skills, in Knowledge; then you'll use it.`; + if (saved.slug) skillDrafts.push({ kind: "skill", slug: saved.slug, name: saved.name }); + return `Saved "${saved.name}" as a draft in your workspace's skills. ${owner} or an Admin can read it and publish it in Skills, in Knowledge (the card under your answer opens it there); then you'll use it.`; } try { const ws = workspaceOf(b); @@ -2732,7 +2737,10 @@ async function turn(botId: string, chatId: string, opts: TurnOptions = {}): Prom ? updateSkill(ws, had.kind, had.slug, { name: title, description: undash(String(p.description ?? had.description)), body, label }, { draft: true }) : createSkill(ws, { name: title, description: undash(String(p.description ?? "")), body, label, botIds: [botId], source: "bops", from: { chatId } }).ref; notes.push(`${had ? "Updated" : "Saved"} the skill "${r.name}" as a draft`); - return `${had ? "Updated" : "Saved"} "${r.name}" as a draft. ${owner} can publish it in Skills, in Knowledge; then you'll use it.`; + // A change to one is the owner's to publish (its folder is on their Mac); a new one Orgo keeps too, for an Admin. + if (had) return `Updated "${r.name}" as a draft. ${owner} can publish it in Skills, in Knowledge; then you'll use it.`; + skillDrafts.push({ kind: r.kind, slug: r.slug, name: r.name }); + return `Saved "${r.name}" as a draft. ${owner} or an Admin can read it and publish it in Skills, in Knowledge (the card under your answer opens it there); then you'll use it.`; } catch (e) { if (e instanceof SkillError) return `Not saved: ${e.message}${e.problems?.length ? ` (${e.problems.map((x) => `${x.path}${x.line ? ` line ${x.line}` : ""}: ${x.reason}`).join("; ")})` : ""}`; throw e; @@ -2939,6 +2947,7 @@ async function turn(botId: string, chatId: string, opts: TurnOptions = {}): Prom read: read.keep(), drafts, proposals, + skillDrafts, readFrom: [...readFrom], }; askFor( @@ -3237,6 +3246,7 @@ async function turn(botId: string, chatId: string, opts: TurnOptions = {}): Prom called: called.length ? called : undefined, drafts: drafts.length ? drafts : undefined, skillProposals: skillProposals.length ? skillProposals : undefined, + skillDrafts: skillDrafts.length ? skillDrafts : undefined, }); // Not the reply to something the user wants kept from someone: it would carry the secret into the shared memory. if (reply && !isSecret(opts.answering)) saveToMemory(scopeOf(botId), "chat", chatId, [{ who: botId, text: reply }], { chat: chatName(chatId) }); diff --git a/lib/server/runner-chat.ts b/lib/server/runner-chat.ts index 4e8a5b0cc..a3ec17917 100644 --- a/lib/server/runner-chat.ts +++ b/lib/server/runner-chat.ts @@ -2,7 +2,7 @@ import "server-only"; import { mkdir, stat, unlink, utimes, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { RUNNER_HOLD, RUNNER_TURNS_DIR, TURN_BEAT_MS, type RunnerClaim, type RunnerResume, type RunnerTurnEvent, type TurnJob, type TurnMember } from "@/cloud/protocol"; -import type { AppApproval, MemberTurn } from "@/lib/types"; +import { workspaceOf, type AppApproval, type MemberTurn } from "@/lib/types"; import { cloudFetch } from "./cloud"; import { loopHere, onRunner } from "./persist"; import { addMessage, getState, pullState, stateReady } from "./store"; @@ -17,7 +17,8 @@ import { runInTurn, type TurnContext } from "./turn-context"; * 1. makes sure it's on its own computer (runner.ts checkIdentity: a copy never answers), then claims it (Bops Cloud gives * it only to this computer's device, one at a time per chat, and checks again that a member still may); * 2. reads the state until it has the message (Bops Cloud wrote it, never this runner); - * 3. runs the same turn the Mac would (chat.ts answerMessage) in the turn's context (turn-context.ts): its rows carry the + * 3. reads the skills published in Orgo (runner-skills.ts, at most once a minute), then runs the same turn the Mac would + * (chat.ts answerMessage) in the turn's context (turn-context.ts): its rows carry the * turn and Bops Cloud's clock, and the first thing it does that can't be done twice is said at once, so a runner that * goes quiet after that never has it run again; * 4. beats every TURN_BEAT_MS while it runs (a 409 means Bops Cloud took it back: it stops and writes nothing more), @@ -301,7 +302,22 @@ async function claimAndAnswer(ev: RunnerTurnEvent) { } const ctx: TurnContext = { id: t.id, chatId: t.chatId, botId: t.botId, offsetMs, humanAt: t.humanAt, acting: false, cancelled: false }; const answer = await answerOf(); - await asTurn(ctx, () => answer(t.chatId, t.messageId, saidBy(t.member)), { messageId: t.messageId }); + await asTurn( + ctx, + async () => { + // The skills the owner or an Admin published in Orgo, read first (runner-skills.ts, a few seconds at most): the bot + // follows them with the owner's Mac off. + // Never in the way: a read that fails leaves the turn as it would have been. + try { + const b = (getState().bots ?? []).find((x) => x.id === t.botId); + if (b) await (await import("./runner-skills")).refreshRunnerSkills(workspaceOf(b)); + } catch (e) { + console.warn(`[runner-chat] ${t.id}: skills from Orgo: ${(e as Error).message}`); + } + return answer(t.chatId, t.messageId, saidBy(t.member)); + }, + { messageId: t.messageId }, + ); } /** How a job's result reads in the bot's chat (Bops' own words, as the bot). */ diff --git a/lib/server/runner-skills.ts b/lib/server/runner-skills.ts new file mode 100644 index 000000000..03646550e --- /dev/null +++ b/lib/server/runner-skills.ts @@ -0,0 +1,269 @@ +import "server-only"; +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve, sep } from "node:path"; +import type { SkillKind } from "@/lib/types"; +import { keptLabel } from "@/lib/skill-labels"; +import { onRunner } from "./persist"; +import { isVersion, labelOf, normalizePath, parseSkillMd, RISKS, sha256Hex, textOf, type BundleFile } from "./skill-bundle"; +import { getState, userDir } from "./store"; +import type { Bundle } from "./workspace-skills"; + +/* + * A workspace's published skills as Orgo keeps them, read by a computer's runner (BOPS_RUNNER) with its own key, so a + * chat or a task its bots run there follows the skills the owner or an Admin published, with the owner's Mac off. + * orgo-web lets a computer's own key read its computer's workspace's published skills, each at its current version, and + * the approvals with the approver's role (lib/api-keys/runner-scope.ts computerSkillReadScope there); every published + * one of the workspace, not only its bots': which bot has which is Bops' own rule (lib/skill-labels.ts botHasSkill), + * kept by workspace-skills.ts as on the Mac (refsOf lays this list over the state's, skillsFor filters). + * + * Read at most once a minute a workspace (refreshRunnerSkills: at a chat turn's start, runner-chat.ts, and before a + * copy to the computer, copySkillsToComputer), each call held to a few seconds, and kept on the computer's disk under + * userDir()/runner-skills// (a version never changes, so one read is read once): the list (index.json) and + * each version's files. A version no longer listed goes from there. Orgo not answering, an orgo-web from before + * (403 computer_key) or no Orgo project: the last copy kept stays, and with none, everything is as before (the state's + * list, and what the owner's Mac last copied to the computer). Never written to the state: a computer may not write a + * workspace's skills (cloud/computer-scope.ts). Nothing of a skill is logged. + */ + +/** A published skill as the runner keeps it: what its ref says, and its files (the version's folder holds them). */ +export type RunnerSkill = { + id: string; + slug: string; + kind: SkillKind; + name: string; + description: string; + version: string; + botIds: string[]; + label?: string; + risk?: string; + updatedAt: string; + files: { path: string; sha256: string; size: number; executable: boolean; binary: boolean }[]; +}; +/** What a runner keeps of a workspace's: its published skills, and the contents the owner or an Admin approved. */ +type Kept = { pid: string; at: number; skills: RunnerSkill[]; approvals: string[] }; + +const THROTTLE_MS = 60_000; +/** How long one call to Orgo may take, and how long a turn waits for a read (it goes on in the background after). */ +const CALL_MS = 5_000; +const WAIT_MS = 8_000; + +const g = globalThis as unknown as { bopsRunnerSkills?: Map }> }; +const memo = () => (g.bopsRunnerSkills ??= new Map()); +/** A project's entry, made when missing: changed in place, so a read under way and its end see the same one. */ +const entry = (pid: string) => { + let e = memo().get(pid); + if (!e) memo().set(pid, (e = { tried: 0 })); + return e; +}; + +const root = () => { + const dir = userDir(); + return dir === null ? null : join(/*turbopackIgnore: true*/ dir, "runner-skills"); +}; +const safe = (s: string) => s.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120) || "_"; +const pidDir = (pid: string) => { + const r = root(); + return r ? join(/*turbopackIgnore: true*/ r, safe(pid)) : null; +}; +const versionDir = (pid: string, kind: SkillKind, slug: string, version: string) => { + const d = pidDir(pid); + return d && isVersion(version) ? join(/*turbopackIgnore: true*/ d, kind === "app" ? "apps" : "skills", safe(slug), version) : null; +}; + +/** The copy kept for a project: in memory, else from its index on disk; null when there's none. */ +function keptOf(pid: string): Kept | null { + const m = memo().get(pid); + if (m && m.kept !== undefined) return m.kept; + let kept: Kept | null = null; + const d = pidDir(pid); + try { + if (d && existsSync(/*turbopackIgnore: true*/ join(d, "index.json"))) { + const j = JSON.parse(readFileSync(/*turbopackIgnore: true*/ join(d, "index.json"), "utf8")) as Kept; + if (j && j.pid === pid && Array.isArray(j.skills) && Array.isArray(j.approvals)) kept = j; + } + } catch { + kept = null; + } + entry(pid).kept = kept; + return kept; +} + +/** On a runner: a workspace's published skills and the approvals that count, as last read from Orgo; null without one. */ +export function runnerOverlay(pid: string): { skills: RunnerSkill[]; approvals: Set } | null { + if (!onRunner()) return null; + const kept = keptOf(pid); + return kept ? { skills: kept.skills, approvals: new Set(kept.approvals) } : null; +} + +/** On a runner: a published version's folder as a bundle (SKILL.md split, its files), from the copy kept; null when it isn't there. */ +export function runnerBundle(pid: string, kind: SkillKind, slug: string, version: string): Bundle | null { + if (!onRunner()) return null; + const x = keptOf(pid)?.skills.find((s) => s.kind === kind && s.slug === slug && s.version === version); + const dir = versionDir(pid, kind, slug, version); + if (!x || !dir) return null; + try { + const md = readFileSync(/*turbopackIgnore: true*/ join(dir, "SKILL.md"), "utf8"); + const parsed = parseSkillMd(md); + const files: BundleFile[] = x.files.map((f) => { + const bytes = readFileSync(/*turbopackIgnore: true*/ join(dir, f.path)); + return f.binary ? { path: f.path, content: null, data: bytes, sha256: f.sha256, size: bytes.length, executable: f.executable } : { path: f.path, content: bytes.toString("utf8"), sha256: f.sha256, size: bytes.length, executable: f.executable }; + }); + if (files.some((f) => sha256Hex(f.data ?? Buffer.from(f.content ?? "", "utf8")) !== f.sha256)) return null; + return { frontmatter: parsed.ok ? (parsed.frontmatter ?? {}) : {}, body: parsed.ok ? parsed.body : md, skillMd: md, files }; + } catch { + return null; + } +} + +/** One file of Orgo's export (GET …/skills/{id}/export): its text inline, or its bytes at `url`. */ +export type ExportFile = { content?: string; url?: string; sha256?: string; executable?: boolean }; + +/** + * A file of Orgo's export as it was stored: its text inline, or its bytes from `url` (a short presigned GET, no key + * sent: orgo-web gives one for every binary file and for large text). Checked against its sha256; null when it can't + * be fetched or doesn't match, so nothing half there is ever written. + */ +export async function exportedFile(path: string, f: ExportFile, timeoutMs = 120_000): Promise<{ path: string; content: string | null; data?: Buffer; executable: boolean } | null> { + let bytes: Buffer; + if (typeof f.content === "string") bytes = Buffer.from(f.content, "utf8"); + else if (typeof f.url === "string" && /^https?:\/\//.test(f.url)) { + const res = await fetch(f.url, { signal: AbortSignal.timeout(timeoutMs) }).catch(() => null); + if (!res?.ok) return null; + bytes = Buffer.from(await res.arrayBuffer()); + } else return null; + if (f.sha256 && sha256Hex(bytes) !== f.sha256) return null; + const text = typeof f.content === "string" ? f.content : textOf(path, bytes); + return text !== null ? { path, content: text, executable: !!f.executable } : { path, content: null, data: bytes, executable: !!f.executable }; +} + +/** A skill of orgo-web's list as a runner reads it. */ +type Listed = { id?: unknown; slug?: unknown; name?: unknown; description?: unknown; type?: unknown; status?: unknown; bot_ids?: unknown; updated_at?: unknown; current_version?: { version?: unknown; risk?: unknown; label?: unknown } | null }; + +const kindOf = (type: unknown): SkillKind => (type === "app" || type === "software" ? "app" : "skill"); + +/** + * Read a workspace's published skills from Orgo with this computer's own key (on a runner only, at most once a minute + * a workspace), and keep them on its disk. A caller waits a few seconds at most; the read goes on after that. + */ +export async function refreshRunnerSkills(wsId: string): Promise { + if (!onRunner()) return; + const pid = getState().workspaces?.find((w) => w.id === wsId)?.orgoProjectId; + if (!pid || root() === null) return; + const m = entry(pid); + if (!m.going) { + if (Date.now() - m.tried < THROTTLE_MS) return; + m.tried = Date.now(); + m.going = readFromOrgo(pid) + .catch((e: Error) => console.warn(`[runner-skills] reading the workspace's skills: ${e.message}`)) + .finally(() => (m.going = undefined)); + } + let timer: ReturnType | undefined; + await Promise.race([m.going, new Promise((done) => (timer = setTimeout(done, WAIT_MS)))]); + clearTimeout(timer); +} + +async function readFromOrgo(pid: string) { + const { apiKey, callOrgo } = await import("./orgo"); + const key = await apiKey().catch(() => ""); + if (!key) return; + const at = `/api/projects/${encodeURIComponent(pid)}/skills`; + const got = await callOrgo<{ skills?: Listed[] }>(key, at, { timeoutMs: CALL_MS }); + // An orgo-web from before (403 computer_key), another workspace's (404), or Orgo away: what was kept stays. + if (!got || got.status !== 200 || !Array.isArray(got.json.skills)) return; + const before = keptOf(pid); + const skills: RunnerSkill[] = []; + for (const x of got.json.skills) { + const version = x?.current_version?.version; + if (!x || x.status !== "published" || typeof x.id !== "string" || typeof x.slug !== "string" || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(x.slug) || typeof version !== "string" || !isVersion(version)) continue; + const kind = kindOf(x.type); + const had = before?.skills.find((s) => s.id === x.id && s.version === version && s.kind === kind && s.slug === x.slug); + const base = { + id: x.id, + slug: x.slug, + kind, + name: typeof x.name === "string" ? x.name : x.slug, + description: typeof x.description === "string" ? x.description : "", + version, + botIds: Array.isArray(x.bot_ids) ? x.bot_ids.filter((b): b is string => typeof b === "string") : [], + ...(typeof x.current_version?.risk === "string" && (RISKS as readonly string[]).includes(x.current_version.risk) ? { risk: x.current_version.risk } : {}), + updatedAt: typeof x.updated_at === "string" ? x.updated_at : "", + }; + // A version kept here already: its files and label are as they were (a version never changes). + if (had && versionDir(pid, kind, x.slug, version) && existsSync(/*turbopackIgnore: true*/ join(versionDir(pid, kind, x.slug, version)!, "SKILL.md"))) { + skills.push({ ...base, ...(had.label ? { label: had.label } : {}), files: had.files }); + continue; + } + const fetched = await fetchVersion(key, at, pid, x.id, kind, x.slug, version); + if (fetched) skills.push({ ...base, ...(fetched.label ? { label: fetched.label } : {}), files: fetched.files }); + else { + // Not read now: an older version kept here still serves, until one is. + const older = before?.skills.find((s) => s.id === x.id); + if (older) skills.push(older); + } + } + // The approvals that count: the owner's and an Admin's (approved_by_role), never a Member's. None read: as kept. + const ap = await callOrgo<{ approvals?: { sha256?: unknown; approved_by_role?: unknown }[] }>(key, `${at}/approvals`, { timeoutMs: CALL_MS }); + const approvals = + ap?.status === 200 && Array.isArray(ap.json.approvals) + ? ap.json.approvals.filter((a) => typeof a?.sha256 === "string" && /^[0-9a-f]{64}$/.test(a.sha256) && (a.approved_by_role === "owner" || a.approved_by_role === "admin")).map((a) => a.sha256 as string) + : (before?.approvals ?? []); + const kept: Kept = { pid, at: Date.now(), skills, approvals }; + const d = pidDir(pid)!; + mkdirSync(/*turbopackIgnore: true*/ d, { recursive: true }); + writeFileSync(/*turbopackIgnore: true*/ join(d, "index.json.tmp"), JSON.stringify(kept), { mode: 0o600 }); + renameSync(/*turbopackIgnore: true*/ join(d, "index.json.tmp"), join(d, "index.json")); + entry(pid).kept = kept; + // A version no longer listed goes from this computer. + for (const s of before?.skills ?? []) if (!skills.some((x) => x.kind === s.kind && x.slug === s.slug && x.version === s.version)) { + const dir = versionDir(pid, s.kind, s.slug, s.version); + if (!dir) continue; + rmSync(/*turbopackIgnore: true*/ dir, { recursive: true, force: true }); + // Its skill's folder too, once no version is left in it. + if (existsSync(/*turbopackIgnore: true*/ dirname(dir)) && !readdirSync(/*turbopackIgnore: true*/ dirname(dir)).length) rmSync(/*turbopackIgnore: true*/ dirname(dir), { recursive: true, force: true }); + } +} + +/** One version's files from Orgo's export, written under its folder here (fresh); its label as its SKILL.md says it. Null when any of it couldn't be read. */ +async function fetchVersion(key: string, at: string, pid: string, id: string, kind: SkillKind, slug: string, version: string): Promise<{ label?: string; files: RunnerSkill["files"] } | null> { + const { callOrgo } = await import("./orgo"); + const ex = await callOrgo<{ files?: Record; version?: string }>(key, `${at}/${encodeURIComponent(id)}/export`, { timeoutMs: CALL_MS }); + if (!ex || ex.status !== 200 || !ex.json.files || ex.json.version !== version) return null; + const md = ex.json.files["SKILL.md"]?.content; + if (typeof md !== "string") return null; + const parsed = parseSkillMd(md); + const got = await Promise.all( + Object.entries(ex.json.files) + .filter(([p]) => p !== "SKILL.md") + .map(async ([p, f]) => { + const n = normalizePath(p); + return "reason" in n || n.path !== p ? null : exportedFile(p, f, 30_000); + }), + ); + if (got.some((f) => f === null)) return null; + const dir = versionDir(pid, kind, slug, version); + if (!dir) return null; + const tmp = `${dir}.tmp`; + rmSync(/*turbopackIgnore: true*/ tmp, { recursive: true, force: true }); + mkdirSync(/*turbopackIgnore: true*/ tmp, { recursive: true }); + writeFileSync(/*turbopackIgnore: true*/ join(tmp, "SKILL.md"), md); + const files: RunnerSkill["files"] = []; + for (const f of got as NonNullable<(typeof got)[number]>[]) { + const to = join(/*turbopackIgnore: true*/ tmp, f.path); + if (!resolve(to).startsWith(resolve(tmp) + sep)) return null; + const bytes = f.data ?? Buffer.from(f.content ?? "", "utf8"); + mkdirSync(dirname(to), { recursive: true }); + // Kept, never run from here: a script goes to the computer's skills folder runnable only once approved (shipped). + writeFileSync(to, bytes, { mode: 0o644 }); + files.push({ path: f.path, sha256: sha256Hex(bytes), size: bytes.length, executable: f.executable, binary: f.content === null }); + } + rmSync(/*turbopackIgnore: true*/ dir, { recursive: true, force: true }); + mkdirSync(dirname(dir), { recursive: true }); + renameSync(/*turbopackIgnore: true*/ tmp, dir); + const label = parsed.ok ? keptLabel(labelOf(parsed.frontmatter)) : undefined; + return { ...(label ? { label } : {}), files }; +} + +/** Tests: forget what's kept in memory (the disk's copy stays) and when each workspace was last read. */ +export function runnerSkillsForTests() { + memo().clear(); +} diff --git a/lib/server/shared-skills.ts b/lib/server/shared-skills.ts new file mode 100644 index 000000000..60f923f51 --- /dev/null +++ b/lib/server/shared-skills.ts @@ -0,0 +1,281 @@ +import "server-only"; +import { sharedSkillsWho } from "@/lib/shared"; +import { keptLabel } from "@/lib/skill-labels"; +import type { SkillKind, SkillRef, SkillRisk } from "@/lib/types"; +import { orgoHeaders } from "./app-version"; +import { apiKey } from "./orgo"; +import { orgoOrigin } from "./orgo-auth"; +import { sharedSkillsOpen } from "./shared"; +import { RISKS, type Problem } from "./skill-bundle"; +import { folderPayload, localKind, remoteType, SkillError } from "./workspace-skills"; + +/* + * Skills in a workspace shared with the user (Knowledge, Skills), for an Admin there (lib/shared.ts sharedSkillsWho: a + * Member with Memory and skills next). Its skills are kept in Orgo (orgo-web's /api/projects//skills routes), the copy + * everyone reads while the owner's Mac is off, and orgo-web lets the owner and an Admin list, read, publish, roll back, + * delete and approve with their own Orgo key, so none of it waits for the owner's Mac: this Bops calls orgo-web with the + * user's own key, as Keys does (shared-keys.ts), and the owner's Mac brings the change in on its next sync + * (workspace-skills.ts syncSkills, every few minutes). Never a change act, never the user's own workspaces: `ws` in a + * request is ignored, and a skill is found by its slug in the shared workspace's own list. + * + * What an Admin does here: list and read every skill (its SKILL.md, its files, its versions), publish a draft (orgo-web's + * PATCH status), import a folder from this Mac as a new draft, update one from a folder (its next version, published at + * once, after they said so), roll back, delete, give a skill that names its bots to one more or one fewer, and approve or + * take back a script (orgo-web takes an approval only from a person: this Bops's sign in with Orgo is theirs). Labels, + * which bots have which label (Who has what) and a bot's proposed changes stay the owner's, in their own window. Bodies + * and files are never logged here. + */ + +/** The line for what stays the owner's in a shared workspace (labels, Who has what, a bot's proposals). */ +export const SHARED_OWNER_ONLY = "In a shared workspace, only its owner changes this."; +/** What a Member without the power reads (the Knowledge page shows them nothing to press). */ +export const SHARED_SKILLS_WHO = "Only the owner or an Admin can change this workspace's skills."; +/** What the page says on an orgo-web without the skills routes (a 404 that isn't JSON). */ +export const SKILLS_NOT_ON_ORGO = "Orgo can't keep this workspace's skills yet."; +const NOT_SIGNED_IN = "Sign in to Orgo to work on this workspace's skills."; +/** A skill that names no bots goes to every bot with its label, and which bot has which label is the owner's to say. */ +export const BY_LABEL = "It goes to every bot with its label, and only the owner picks which bots have a label. Ask them, or give it its own bots in their Skills."; + +/** orgo-web's refusals by their code, in the window's words (its own words for any other). */ +const SAID: Record = { + workspace_read_only: { status: 403, words: SHARED_SKILLS_WHO }, + session_required: { status: 403, words: "Orgo takes an approval only from you, signed in to Bops with Orgo. Sign out, then sign in again." }, + secret_found: { status: 422, words: "That looks like a secret. Keep keys in Logins, not in a skill." }, + invalid_bundle: { status: 400, words: "Some of its files can't be kept. Fix the ones listed and try again." }, + unchanged: { status: 409, words: "Nothing changed since the version in use." }, + version_exists: { status: 409, words: "That version is published already." }, + stale_skill: { status: 409, words: "Someone changed it just now. Look at it again, then try again." }, + skill_limit: { status: 409, words: "This workspace holds 500 skills at most. Delete one to add another." }, + storage_quota: { status: 413, words: "That's more than the storage the owner's plan includes." }, + too_large: { status: 413, words: "That's too large to send to Orgo." }, + unknown_sha: { status: 404, words: "Orgo doesn't have that script yet. Try again in a minute." }, + version_not_found: { status: 404, words: "That version isn't there anymore." }, + busy: { status: 503, words: "Orgo is busy. Try again in a minute." }, +}; + +type Json = Record; + +/** + * One call to the shared workspace's skills routes on orgo-web, with the user's own Orgo key. `skill`: the call is about + * one skill, so a 404 says the skill is gone rather than the workspace. Never logs what it sent or got. + */ +async function orgoSkills(projectId: string, path: string, init: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: unknown; skill?: boolean; timeoutMs?: number } = {}): Promise { + let key: string; + try { + key = await apiKey(); + } catch { + throw new SkillError(NOT_SIGNED_IN, 401); + } + let res: Response; + try { + res = await fetch(`${orgoOrigin()}/api/projects/${encodeURIComponent(projectId)}/skills${path}`, { + method: init.method ?? "GET", + headers: { ...orgoHeaders(), Authorization: `Bearer ${key}`, Accept: "application/json", ...(init.body === undefined ? {} : { "Content-Type": "application/json" }) }, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + cache: "no-store", + signal: AbortSignal.timeout(init.timeoutMs ?? 20_000), + }); + } catch { + throw new SkillError("Bops couldn't reach Orgo. Check the internet and try again.", 502); + } + const json = (await res.json().catch(() => null)) as (T & { error?: unknown; code?: unknown; problems?: unknown }) | null; + if (res.ok && json) return json; + if (res.status === 401) throw new SkillError(NOT_SIGNED_IN, 401); + const problems = Array.isArray(json?.problems) ? (json.problems as Problem[]).filter((p) => p && typeof p.path === "string" && typeof p.reason === "string").slice(0, 100) : undefined; + const said = typeof json?.code === "string" && Object.hasOwn(SAID, json.code) ? SAID[json.code] : undefined; + if (said) throw new SkillError(said.words, said.status, problems); + // No such route (an orgo-web before it answers a page, not JSON); or the skill, or the user's place in the workspace, gone. + if (res.status === 404) { + if (!json) throw new SkillError(SKILLS_NOT_ON_ORGO, 503); + throw new SkillError(init.skill ? "That skill isn't there anymore." : "This workspace isn't shared with you anymore.", 404); + } + throw new SkillError(json && typeof json.error === "string" && json.error ? json.error : `Orgo answered ${res.status}.`, res.ok ? 502 : res.status, problems); +} + +/** The shared workspace open now, when the user may work on its skills there; else why not (none open: 409; not theirs: 403). */ +function scope() { + const open = sharedSkillsOpen(); + if (!open) throw new SkillError("Open the workspace again.", 409); + if (!sharedSkillsWho(open)) throw new SkillError(SHARED_SKILLS_WHO, 403); + return open; +} + +/** A skill as orgo-web's list says it (GET …/skills): `current_version.label` is what its SKILL.md says of its label. */ +type RemoteSkill = { + id: string; + slug: string; + name: string; + description: string | null; + type: string; + status: string; + bot_ids?: string[]; + label?: string | null; + created_at?: string; + updated_at: string; + current_version?: { version?: string; risk?: string | null; label?: string } | null; +}; + +/** A skill of orgo-web's as the page lists one (SkillRef): published at its current version, else a draft. */ +function refOf(x: RemoteSkill): SkillRef { + const published = x.status === "published"; + // A label column of orgo-web's when it keeps one, else the one its current version's SKILL.md gives it. + const label = keptLabel(typeof x.label === "string" ? x.label : x.current_version?.label); + const risk = x.current_version?.risk; + return { + slug: x.slug, + kind: localKind(x.type), + name: x.name, + description: x.description ?? "", + ...(label ? { label } : {}), + labelBy: "skill", + status: published ? "published" : "draft", + ...(published && x.current_version?.version ? { version: x.current_version.version } : {}), + ...(risk && (RISKS as readonly string[]).includes(risk) ? { risk: risk as SkillRisk } : {}), + botIds: Array.isArray(x.bot_ids) ? x.bot_ids.filter((id): id is string => typeof id === "string") : [], + source: "orgo", + createdAt: Date.parse(x.created_at ?? "") || 0, + updatedAt: Date.parse(x.updated_at) || 0, + remoteId: x.id, + remoteAt: x.updated_at, + }; +} + +async function listed(projectId: string): Promise { + const got = await orgoSkills<{ skills?: unknown }>(projectId, ""); + return Array.isArray(got.skills) ? (got.skills.filter((x) => !!x && typeof x === "object" && typeof (x as RemoteSkill).id === "string" && typeof (x as RemoteSkill).slug === "string") as RemoteSkill[]) : []; +} + +/** + * GET /api/skills in a shared workspace: its skills and apps as orgo-web lists them now (newest first, as the page shows + * them), always kept in Orgo. Nothing of the user's own workspaces. + */ +export async function sharedSkillsList() { + const open = scope(); + const refs = (await listed(open.projectId)).map(refOf).sort((a, b) => b.updatedAt - a.updatedAt); + return { skills: refs.filter((r) => r.kind === "skill"), apps: refs.filter((r) => r.kind === "app"), labels: [] as string[], labelInfo: [], sync: null, orgo: true, shared: true }; +} + +/** One of the shared workspace's skills by its kind and slug, as orgo-web lists it now. */ +async function findRemote(projectId: string, kind: SkillKind, slug: string): Promise { + const x = slug ? (await listed(projectId)).find((s) => s.slug === slug && localKind(s.type) === kind) : undefined; + if (!x) throw new SkillError(`That ${kind} isn't there anymore.`, 404); + return x; +} + +/** A file orgo-web lists for a version (GET …/skills/{id}). */ +type RemoteFile = { path: string; sha256: string; size_bytes: number; executable: boolean; media_type?: string; approved?: boolean }; + +const TEXT_TYPES = /^(text\/|application\/(json|xml|yaml|x-yaml|toml|javascript|x-sh|x-python|x-shellscript|sql|graphql))/i; +/** Script kinds by name: what Bops counts as a script (skill-bundle.ts isScript) without its first line to read. */ +const SCRIPT_NAMES = /\.(sh|bash|zsh|fish|py|rb|pl|php|js|mjs|cjs|ts|ps1|command)$/i; +const isTextFile = (f: RemoteFile) => TEXT_TYPES.test(f.media_type ?? "text/plain"); + +/** + * Bops' isScript (executable, under scripts/, or a #! line) as far as orgo-web's list says: its bit, its folder, and a + * script's kind of name or type. One whose first line alone makes it a script shows when it's read (the window looks). + */ +const scriptish = (f: RemoteFile) => f.executable || f.path.startsWith("scripts/") || SCRIPT_NAMES.test(f.path) || /x-(sh|python|shellscript)/i.test(f.media_type ?? ""); + +const str = (v: unknown) => (typeof v === "string" ? v : undefined); +const versionQuery = (v: string | undefined) => (v ? `?version=${encodeURIComponent(v)}` : ""); + +/** + * POST /api/skills in a shared workspace (`{action, kind, slug, ...}`, its `ws` ignored): what the user does to one of its + * skills, done in orgo-web with their own key. Anything else answers SHARED_OWNER_ONLY. + */ +export async function sharedSkillAct(b: Json): Promise { + const open = scope(); + const pid = open.projectId; + const kind: SkillKind = b.kind === "app" ? "app" : "skill"; + const slug = str(b.slug) ?? ""; + switch (b.action) { + case "detail": { + const x = await findRemote(pid, kind, slug); + const version = str(b.version); + const got = await orgoSkills<{ version?: { version?: string; body?: string; frontmatter?: Json; files?: RemoteFile[] } | null; versions?: unknown }>(pid, `/${encodeURIComponent(x.id)}${versionQuery(version)}`, { skill: true }); + const v = got.version ?? null; + const versions = Array.isArray(got.versions) ? got.versions.filter((y): y is string => typeof y === "string") : []; + const ref = refOf(x); + return { + ref: { ...ref, versions: versions.map((y) => ({ version: y, at: 0, sha256: "" })) }, + body: v?.body ?? "", + frontmatter: v?.frontmatter ?? {}, + files: (v?.files ?? []).map((f) => ({ path: f.path, size: f.size_bytes, sha256: f.sha256, executable: f.executable, script: scriptish(f), approved: !!f.approved, binary: !isTextFile(f) })), + dir: null, + missing: !v, + changed: false, + shared: true, + }; + } + case "file": { + const x = await findRemote(pid, kind, slug); + const path = str(b.path) ?? ""; + if (!path || path.split("/").some((p) => p === ".." || p === "")) throw new SkillError("That file isn't in it.", 404); + const f = await orgoSkills<{ path?: string; sha256?: string; size_bytes?: number; content?: string; url?: string }>(pid, `/${encodeURIComponent(x.id)}/files/${path.split("/").map(encodeURIComponent).join("/")}${versionQuery(str(b.version))}`, { skill: true }); + // Text up to 1 MB comes inline; a binary or larger file only as a link, which isn't shown here. + if (typeof f.content === "string") return { path: f.path ?? path, content: f.content, sha256: f.sha256 ?? "" }; + return { path: f.path ?? path, content: null, sha256: f.sha256 ?? "", binary: true, size: f.size_bytes ?? 0 }; + } + case "publish": { + const x = await findRemote(pid, kind, slug); + if (x.status === "published") throw new SkillError("It's published already. To change it, update it from a folder.", 409); + const got = await orgoSkills<{ skill?: RemoteSkill }>(pid, `/${encodeURIComponent(x.id)}`, { method: "PATCH", body: { status: "published", if_updated_at: x.updated_at }, skill: true }); + return { ref: refOf({ ...x, ...(got.skill ?? {}), status: "published" }) }; + } + case "rollback": { + const x = await findRemote(pid, kind, slug); + const version = str(b.version) ?? ""; + if (!/^\d+\.\d+\.\d+/.test(version)) throw new SkillError("A version looks like 1.2.0."); + const got = await orgoSkills<{ skill?: RemoteSkill }>(pid, `/${encodeURIComponent(x.id)}/rollback`, { method: "POST", body: { version }, skill: true }); + return { ref: refOf({ ...x, ...(got.skill ?? {}) }) }; + } + case "delete": { + const x = await findRemote(pid, kind, slug); + await orgoSkills(pid, `/${encodeURIComponent(x.id)}`, { method: "DELETE", skill: true }); + return { ok: true }; + } + case "bot": { + const botId = str(b.botId); + if (!botId || typeof b.on !== "boolean") throw new SkillError("Which bot, and on or off?"); + if (!open.botIds.includes(botId)) throw new SkillError("That bot isn't in this workspace.", 404); + const x = await findRemote(pid, kind, slug); + const now = Array.isArray(x.bot_ids) ? x.bot_ids : []; + // One that names no bots follows its label (lib/skill-labels.ts botHasSkill): whose label is the owner's to say. + if (!now.length) throw new SkillError(BY_LABEL, 403); + const next = b.on ? [...new Set([...now, botId])] : now.filter((id) => id !== botId); + if (!next.length) throw new SkillError("At least one bot keeps it. Delete it instead."); + const got = await orgoSkills<{ skill?: RemoteSkill }>(pid, `/${encodeURIComponent(x.id)}`, { method: "PATCH", body: { bot_ids: next, if_updated_at: x.updated_at }, skill: true }); + return { ref: refOf({ ...x, ...(got.skill ?? {}), bot_ids: got.skill?.bot_ids ?? next }) }; + } + case "approve": { + const sha256 = str(b.sha256) ?? ""; + if (!/^[0-9a-f]{64}$/.test(sha256)) throw new SkillError("That isn't a script's sha256."); + const path = str(b.path)?.slice(0, 512); + if (b.on === false) await orgoSkills(pid, `/approvals?sha256=${sha256}`, { method: "DELETE" }).catch((e: unknown) => { + // Not approved there (anymore): nothing to take back. + if (!(e instanceof SkillError && e.status === 404)) throw e; + }); + else await orgoSkills(pid, "/approvals", { method: "POST", body: { sha256, ...(path ? { path_hint: path } : {}) } }); + return { ok: true }; + } + case "import": { + const path = str(b.path); + if (!path || !path.startsWith("/")) throw new SkillError("Pick a folder."); + if (slug) { + // Update from folder: the folder is its next version, published in Orgo at once (the window asked first). + const x = await findRemote(pid, kind, slug); + const f = folderPayload(path, slug); + const got = await orgoSkills<{ skill?: RemoteSkill; version?: { version?: string } }>(pid, `/${encodeURIComponent(x.id)}/versions`, { method: "POST", body: { body: f.skillMd, files: f.files }, skill: true, timeoutMs: 120_000 }); + const version = got.version?.version ?? got.skill?.current_version?.version; + return { ref: refOf({ ...x, ...(got.skill ?? {}), status: "published", ...(version ? { current_version: { ...(x.current_version ?? {}), version } } : {}) }), skipped: f.skipped, warnings: f.warnings, drafted: f.drafted, published: true }; + } + // A new one: a draft in Orgo, to read and publish here. + const f = folderPayload(path); + const got = await orgoSkills<{ skill?: RemoteSkill }>(pid, "", { method: "POST", body: { name: f.name, description: f.description, type: remoteType(kind), body: f.skillMd, files: f.files, status: "draft", source: "import" }, timeoutMs: 120_000 }); + if (!got.skill?.id) throw new SkillError("Orgo didn't say what it saved. Look in Skills again.", 502); + return { ref: refOf(got.skill), skipped: f.skipped, warnings: f.warnings, drafted: f.drafted }; + } + default: + throw new SkillError(SHARED_OWNER_ONLY, 403); + } +} diff --git a/lib/server/shared.ts b/lib/server/shared.ts index 7da930dca..af45a647a 100644 --- a/lib/server/shared.ts +++ b/lib/server/shared.ts @@ -263,6 +263,24 @@ export function sharedKeysOpen(): { }; } +/** + * The shared workspace open now, for its skills (lib/server/shared-skills.ts): its Orgo project (never an id from a + * request), the user's role and what they may change there (SharedWsSlice.can), and its bots. Null when none is open for + * the signed-in user. + */ +export function sharedSkillsOpen(): { projectId: string; name: string; role: "admin" | "member"; can: string[]; botIds: string[] } | null { + const open = openNow(); + if (!open || (open.at.role !== "admin" && open.at.role !== "member")) return null; + const s = open.data?.state; + return { + projectId: open.at.projectId, + name: open.at.name, + role: open.at.role, + can: Array.isArray(s?.can) ? s.can.filter((x): x is string => typeof x === "string") : [], + botIds: (Array.isArray(s?.bots) ? s.bots : []).flatMap((b) => (typeof b?.id === "string" ? [b.id] : [])), + }; +} + /** * The shared workspace open now, for a change made there from this window (change-relay.ts): the id the window has for it, * its bots, what the user may change there (SharedWsSlice.can) and whether the owner's Bops takes it (SharedWsSlice.changes). Null diff --git a/lib/server/workspace-skills.ts b/lib/server/workspace-skills.ts index e6770a94e..f9b2739c1 100644 --- a/lib/server/workspace-skills.ts +++ b/lib/server/workspace-skills.ts @@ -43,6 +43,7 @@ import { type Problem, } from "./skill-bundle"; import { onRunner } from "./persist"; +import { exportedFile, refreshRunnerSkills, runnerBundle, runnerOverlay, type ExportFile, type RunnerSkill } from "./runner-skills"; import { botIdsOn } from "@/cloud/computer-scope"; import { bot, getState, ownerName, update, userDir } from "./store"; import { outbox } from "./sync-outbox"; @@ -129,6 +130,7 @@ const g = globalThis as unknown as { bopsSkillLabelsRoute?: Map; bopsSkillLabelRename?: Map; bopsLabelSuggest?: Map; timer: ReturnType }>; + bopsSkillsPoll?: { timer: ReturnType; ticks: number }; }; export class SkillError extends Error { @@ -180,13 +182,60 @@ const versionDir = (wsId: string, kind: SkillKind, slug: string, version: string /* ---------------- What's in the state ---------------- */ -/** A workspace's skills and apps (both, or one kind), oldest first. */ +/** + * A workspace's skills and apps (both, or one kind), oldest first. On a computer's runner, Orgo's published ones as it + * last read them (runner-skills.ts) laid over the state's: Orgo's name, description, version, bots and label for one it + * lists, one only Orgo has added, and one Orgo had (remoteId) but no longer lists as published shown as a draft, so + * with the owner's Mac off its bots use what the owner or an Admin published. Without a read: the state's, as before. + */ export function refsOf(wsId: string, kind?: SkillKind): SkillRef[] { adoptProcedures(); labelRefs(wsId); const w = workspaceById(wsId); if (!w) return []; - return kind ? [...(w[listKey(kind)] ?? [])] : [...(w.skills ?? []), ...(w.apps ?? [])]; + const own = kind ? [...(w[listKey(kind)] ?? [])] : [...(w.skills ?? []), ...(w.apps ?? [])]; + const over = onRunner() && w.orgoProjectId ? runnerOverlay(w.orgoProjectId) : null; + return over ? overlaid(own, over.skills, kind) : own; +} + +/** A published skill Orgo lists, as a ref here: published at its version, for its bots or by its label. */ +const runnerRef = (x: RunnerSkill, r?: SkillRef): SkillRef => { + // The state's own fields (its uses, the id it had as a procedure), never its label or risk: Orgo's say those. + const rest: Partial = { ...r }; + delete rest.label; + delete rest.risk; + return { + ...rest, + slug: x.slug, + kind: x.kind, + name: x.name, + description: x.description, + ...(x.label ? { label: x.label } : {}), + labelBy: "skill", + status: "published", + version: x.version, + ...(x.risk && (RISKS as readonly string[]).includes(x.risk) ? { risk: x.risk as SkillRisk } : {}), + botIds: x.botIds, + source: r?.source ?? "orgo", + createdAt: r?.createdAt ?? 0, + updatedAt: Date.parse(x.updatedAt) || r?.updatedAt || 0, + remoteId: x.id, + }; +}; + +function overlaid(own: SkillRef[], theirs: RunnerSkill[], kind?: SkillKind): SkillRef[] { + const used = new Set(); + const out = own.map((r) => { + const x = theirs.find((t) => t.kind === r.kind && !used.has(t) && (r.remoteId ? t.id === r.remoteId : t.slug === r.slug)) ?? theirs.find((t) => t.kind === r.kind && !used.has(t) && t.slug === r.slug); + if (x) { + used.add(x); + return runnerRef(x, r); + } + // Orgo had it and doesn't list it as published now (taken back to a draft, or deleted there): not its bots' here. + return r.status === "published" && r.remoteId ? { ...r, status: "draft" as const } : r; + }); + for (const x of theirs) if (!used.has(x) && (!kind || x.kind === kind)) out.push(runnerRef(x)); + return out; } export const findRef = (wsId: string, kind: SkillKind, slug: string) => refsOf(wsId, kind).find((r) => r.slug === slug); @@ -717,6 +766,10 @@ function readBundle(dir: string | null): Bundle | null { * is written again here from the procedure kept in the state. */ function publishedBundle(wsId: string, r: SkillRef): Bundle | null { + // On a computer's runner: the version Orgo serves, as it last read it (runner-skills.ts). + const pid = onRunner() && r.version ? workspaceById(wsId)?.orgoProjectId : undefined; + const fromOrgo = pid ? runnerBundle(pid, r.kind, r.slug, r.version!) : null; + if (fromOrgo) return fromOrgo; if (r.version) { const v = readBundle(versionDir(wsId, r.kind, r.slug, r.version)); if (v) return v; @@ -956,7 +1009,7 @@ export function createSkill(wsId: string, input: NewSkill): { ref: SkillRef; war * rest. Nothing is kept on the computer or in its state: a computer may not write a workspace's skills * (cloud/computer-scope.ts), and the owner's Bops keeps their folders. Answers its name, or why it wasn't saved. */ -export async function saveSkillFromComputer(wsId: string, input: { name: string; description: string; body: string; label: string | null; botId: string }): Promise<{ ok: true; name: string } | { ok: false; why: string }> { +export async function saveSkillFromComputer(wsId: string, input: { name: string; description: string; body: string; label: string | null; botId: string }): Promise<{ ok: true; name: string; slug?: string } | { ok: false; why: string }> { const pid = workspaceById(wsId)?.orgoProjectId; if (!pid) return { ok: false, why: "this workspace isn't on Orgo yet, so a skill can be saved only where its folders are kept" }; const who = bot(input.botId); @@ -982,12 +1035,13 @@ export async function saveSkillFromComputer(wsId: string, input: { name: string; const { apiKey, callOrgo } = await import("./orgo"); const key = await apiKey().catch(() => ""); if (!key) return { ok: false, why: "this computer isn't signed in to Orgo right now" }; - const r = await callOrgo<{ skill?: { name?: string }; error?: string; code?: string }>(key, `/api/projects/${encodeURIComponent(pid)}/skills`, { + const r = await callOrgo<{ skill?: { name?: string; slug?: string }; error?: string; code?: string }>(key, `/api/projects/${encodeURIComponent(pid)}/skills`, { method: "POST", body: { name, description, type: "skill", body: md, files: [], bot_ids: [input.botId], status: "draft" }, timeoutMs: 20_000, }); - if (r?.status === 201) return { ok: true, name: r.json.skill?.name ?? name }; + // (Its slug, Orgo's, so the card under the answer opens it in Skills.) + if (r?.status === 201) return { ok: true, name: r.json.skill?.name ?? name, ...(typeof r.json.skill?.slug === "string" && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(r.json.skill.slug) ? { slug: r.json.skill.slug } : {}) }; if (!r) return { ok: false, why: "Orgo didn't answer" }; const plain = r.status === 403 && typeof r.json.code === "string" ? skillRefusedWords(r.json.code, who.name) : undefined; if (plain) return { ok: false, why: plain }; @@ -1218,7 +1272,11 @@ export function dropBotFromSkills(botId: string, wsId: string) { export type SkillFileInfo = { path: string; size: number; sha256: string; executable: boolean; script: boolean; approved: boolean; binary: boolean }; -const approvedIn = (wsId: string) => new Set((workspaceById(wsId)?.approvals ?? []).map((a) => a.sha256)); +/** The contents approved to run in a workspace: the state's, and on a computer's runner, the owner's and Admins' Orgo has (runner-skills.ts). */ +const approvedIn = (wsId: string) => { + const pid = onRunner() ? workspaceById(wsId)?.orgoProjectId : undefined; + return new Set([...(workspaceById(wsId)?.approvals ?? []).map((a) => a.sha256), ...(pid ? (runnerOverlay(pid)?.approvals ?? []) : [])]); +}; /** A skill as the window shows it: its SKILL.md (a version, else what's being worked on), its files, and where its folder is. */ export function skillDetail(wsId: string, kind: SkillKind, slug: string, version?: string) { @@ -1336,7 +1394,7 @@ export function readSkill(botId: string, nameOrSlug: string, file?: string | nul } const wsId = workspaceOf(b); const bundle = publishedBundle(wsId, r); - if (!bundle) return `"${r.name}" isn't on this Mac right now.`; + if (!bundle) return `"${r.name}" isn't on this ${onRunner() ? "computer" : "Mac"} right now.`; if (file?.trim()) { const n = normalizePath(file.trim()); const f = "reason" in n ? undefined : bundle.files.find((x) => x.path === n.path); @@ -1558,6 +1616,32 @@ export function updateFromFolder(wsId: string, folder: string, kind: SkillKind, return { ref: findRef(wsId, kind, slug)!, skipped: checked.skipped, warnings: checked.warnings, drafted: checked.drafted, published }; } +/** A bundle's files as orgo-web takes them: text as content, a binary file (a program) as content_base64. */ +export const orgoFiles = (files: BundleFile[]) => + files.map((f) => (f.content !== null ? { path: f.path, content: f.content, executable: f.executable } : { path: f.path, content_base64: f.data!.toString("base64"), executable: f.executable })); + +/** + * A folder on this Mac, for a workspace whose skills are kept in Orgo alone here (a shared workspace's Skills: + * shared-skills.ts), read and checked as importFolder checks a new one, or with `slug` as updateFromFolder checks an + * existing skill's next version (its SKILL.md name, when it says one, that skill's), then strictly as a save is (no + * secret, the caps): its name, description, SKILL.md text and files as orgo-web takes them. Nothing is kept here. + */ +export function folderPayload(folder: string, slug?: string) { + const checked = readFolder(folder); + const said = checked.frontmatter.name; + if (slug && said !== undefined && said !== slug) throw new SkillError(`That folder is another skill: its SKILL.md says ${JSON.stringify(said)}, and this one is ${JSON.stringify(slug)}.`, 400, [{ path: "SKILL.md", reason: `frontmatter name: must be ${JSON.stringify(slug)}` }]); + const name = cut(oneLine(checked.name), 120); + const description = oneLine(checked.description); + if (!/[\p{L}\p{N}]/u.test(name)) throw new SkillError("A skill needs a name."); + if (!description) throw new SkillError("Say what it does and when to use it."); + const fm = frontmatterOf(slug ?? checked.slugBase, description, { ...checked.frontmatter, name: slug ?? checked.slugBase }); + noSecretsIn(name, description, fm); + const problems = validateFrontmatter(fm); + if (problems.length) throw new SkillError("The frontmatter has a problem.", 400, problems); + const { files } = checkFiles(checked.body, checked.files.map((f) => ({ path: f.path, content: f.content, data: f.data, executable: f.executable }))); + return { name, description, skillMd: renderSkillMd(fm, checked.body), files: orgoFiles(files), skipped: checked.skipped, warnings: checked.warnings, drafted: checked.drafted }; +} + /* ---------------- Procedures from before ---------------- */ /** A procedure as SKILL.md text: when to use it, its steps, its pitfalls. */ @@ -1737,6 +1821,8 @@ const PIECE = 96 * 1024; * copied it rather than clear it. */ export async function copySkillsToComputer(botId: string, computerId: string, bash: (computerId: string, command: string, timeout?: number) => Promise<{ output: string; exit_code: number }>) { + // On a computer's runner: what Orgo publishes now, read first (runner-skills.ts), so the copy is whole. + if (onRunner() && bot(botId)) await refreshRunnerSkills(workspaceOf(bot(botId)!)); const team = sharers(botId); if (onRunner() && team.some((id) => skillsFor(id).some((r) => !publishedBundle(workspaceOf(bot(id)!), r)))) return 0; const bundles = bundlesFor(team, { builtins: true }); @@ -1824,9 +1910,9 @@ type RemoteSkill = { id: string; slug: string; name: string; description: string /** One of Orgo's record of deletions (GET …/skills?since=): a skill deleted after `since`, never one live now. */ type RemoteDeletion = { id: string; slug: string; type: string; deleted_at: string }; -const remoteType = (kind: SkillKind) => (kind === "app" ? "app" : "skill"); +export const remoteType = (kind: SkillKind) => (kind === "app" ? "app" : "skill"); // "software" is what an older orgo-web called an app. -const localKind = (type: string): SkillKind => (type === "software" || type === "app" ? "app" : "skill"); +export const localKind = (type: string): SkillKind => (type === "software" || type === "app" ? "app" : "skill"); /** One call to orgo-web's skills routes for a workspace, with the signed-in key. Null when Orgo didn't answer. */ async function skillsCall(projectId: string, path: string, init: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: unknown } = {}) { @@ -2092,8 +2178,24 @@ function renamesLater() { type ApprovalChange = { pid: string; sha256: string; on: boolean; path?: string }; /** Approvals and revokes Orgo hasn't taken yet, kept on disk until it does. */ const approvalsOut = outbox("skill-approvals", 2000); -/** `approved_by`: the Orgo user who approved it. */ -type RemoteApproval = { sha256: string; path_hint?: string | null; approved_by?: string | null; approved_at?: string }; +/** + * `approved_by`: the Orgo user who approved it. `approved_by_role`: their role in the workspace now, as orgo-web reads it + * with the list (owner, admin, member; null once they left it); an older orgo-web doesn't say it. + */ +type RemoteApproval = { sha256: string; path_hint?: string | null; approved_by?: string | null; approved_at?: string; approved_by_role?: string | null }; + +/** + * Whose approval of orgo-web's runs a script here: with its role said, the owner's or an Admin's now (an Admin can do + * everything in a workspace but delete it or pay; one made a Member again, or gone, no longer counts); without it (an + * orgo-web from before the role), only the person signed in here, as before. `owner`: that person's Orgo user id. + * A Member's never, whatever switch they have: theirs would run nothing on this Mac (phase 2 counts one only on the bots' + * computers, never here). + */ +export function approvalCounts(a: RemoteApproval | undefined, owner: string | undefined): "owner" | "admin" | null { + if (!a) return null; + if (a.approved_by_role !== undefined) return a.approved_by_role === "owner" ? "owner" : a.approved_by_role === "admin" ? "admin" : null; + return !!owner && a.approved_by === owner ? "owner" : null; +} const g2 = globalThis as unknown as { bopsApprovalsTimer?: ReturnType; bopsApprovalsTries?: number; bopsApprovalsSaid?: boolean; bopsApprovalsParked?: Set }; /** Outbox entries Orgo couldn't place (unknown_sha), by id: sent again only after a sync sent a file with that content (or once a run of Bops). */ const parked = (g2.bopsApprovalsParked ??= new Set()); @@ -2196,18 +2298,18 @@ function markApproval(wsId: string, sha256: string, on: boolean) { } /** - * Orgo's approvals here (GET …/skills/approvals): one the person signed in here made (on orgo.ai, or in - * Bops on another Mac: approved_by) is taken, another member's never (it doesn't run a script on this - * Mac); one Orgo had and no longer lists was revoked there and goes here too; one made here that Orgo - * never had is sent, while its script is in one of the workspace's skills. What waits to be sent from here - * stands. + * Orgo's approvals here (GET …/skills/approvals): the owner's and an Admin's (approvalCounts: orgo-web says the + * approver's role now; an older one, only the person signed in here) are taken, a Member's never (it doesn't run a + * script on this Mac), and one taken before from someone who is no longer an Admin goes; one Orgo had and no longer + * lists was revoked there and goes here too; one made here that Orgo never had is sent, while its script is in one of + * the workspace's skills. What waits to be sent from here stands. Each taken says whose it was (ScriptApproval.by). */ async function pullApprovals(wsId: string, pid: string) { const got = await skillsCall<{ approvals?: RemoteApproval[] }>(pid, "/approvals"); if (!got || got.status !== 200 || !Array.isArray(got.json.approvals)) return; const owner = getState().account?.user.id; const there = new Map(got.json.approvals.filter((a) => typeof a?.sha256 === "string").map((a) => [a.sha256, a])); - const byOwner = (a: RemoteApproval | undefined) => !!owner && !!a && a.approved_by === owner; + const byOwner = (a: RemoteApproval | undefined) => approvalCounts(a, owner) !== null; const waiting = new Set(approvalsOut.waiting(wsId).map((e) => e.item.sha256)); const mine = workspaceById(wsId)?.approvals ?? []; const send: ScriptApproval[] = []; @@ -2217,7 +2319,10 @@ async function pullApprovals(wsId: string, pid: string) { if (waiting.has(a.sha256)) kept.push(a); else if (there.has(a.sha256)) { // Made here, or taken from Orgo while Orgo says this person made it (one taken before from another member's goes). - if (a.inBops || byOwner(there.get(a.sha256)) || !owner) kept.push(a.synced ? a : { ...a, synced: true }); + if (a.inBops || byOwner(there.get(a.sha256)) || !owner) { + const by = a.inBops ? a.by : (approvalCounts(there.get(a.sha256), owner) ?? undefined); + kept.push(a.synced && a.by === by ? a : { ...a, synced: true, ...(by ? { by } : {}) }); + } } // Orgo had it and doesn't now: revoked there. Never had it: sent, while its script is in a skill here. else if (!a.synced) { @@ -2226,7 +2331,7 @@ async function pullApprovals(wsId: string, pid: string) { } } for (const [sha, a] of there) - if (byOwner(a) && !waiting.has(sha) && !kept.some((x) => x.sha256 === sha)) kept.push({ sha256: sha, path: (a.path_hint ?? "").slice(0, 512), at: Date.parse(a.approved_at ?? "") || Date.now(), synced: true }); + if (byOwner(a) && !waiting.has(sha) && !kept.some((x) => x.sha256 === sha)) kept.push({ sha256: sha, path: (a.path_hint ?? "").slice(0, 512), at: Date.parse(a.approved_at ?? "") || Date.now(), synced: true, by: approvalCounts(a, owner)! }); if (kept.length !== mine.length || kept.some((x, i) => x !== mine[i])) update((s) => { const ws = s.workspaces?.find((w) => w.id === wsId); @@ -2284,10 +2389,53 @@ function payloadOf(wsId: string, r: SkillRef) { return { shas: b.files.map((f) => f.sha256), body: renderSkillMd(withLabel(frontmatterOf(r.slug, r.description, b.frontmatter), sentLabelOf(r)), b.body), - files: b.files.map((f) => (f.content !== null ? { path: f.path, content: f.content, executable: f.executable } : { path: f.path, content_base64: f.data!.toString("base64"), executable: f.executable })), + files: orgoFiles(b.files), }; } +/** + * Whether Orgo's version of a skill under the number this Mac uses (its export) has the same text and files as this + * Mac's (the frontmatter aside, which each side writes in its own words); null when it couldn't be read. + */ +async function sameAsOrgo(wsId: string, pid: string, remoteId: string, r: SkillRef): Promise { + const mine = r.version ? readBundle(versionDir(wsId, r.kind, r.slug, r.version)) : null; + if (!mine || !r.version) return null; + const ex = await skillsCall<{ files?: Record }>(pid, `/${encodeURIComponent(remoteId)}/export?version=${encodeURIComponent(r.version)}`); + if (!ex || ex.status !== 200 || !ex.json.files) return null; + const parsed = parseSkillMd(ex.json.files["SKILL.md"]?.content ?? ""); + if (!parsed.ok) return null; + const theirs = Object.entries(ex.json.files).filter(([p]) => p !== "SKILL.md").map(([p, f]) => `${p}:${f.sha256 ?? ""}`).sort().join(); + return parsed.body.replace(/^\n+/, "") === mine.body.replace(/^\n+/, "") && theirs === mine.files.map((f) => `${f.path}:${f.sha256}`).sort().join(); +} + +/** + * The version a skill here is at, under a number Orgo has already (an Admin published that number there while this Mac + * was off, or this Mac rolled back to a number Orgo moved past): its bots' version here becomes a new one, the next + * number free both here and in Orgo (its versions, GET …/skills/{id}), with the same text and files (its SKILL.md's + * metadata.version the new number), and the ref points at it. Every version kept here stays. Answers the new number, or + * null when Orgo's versions couldn't be read or the version's folder isn't here. + */ +async function freeVersion(wsId: string, pid: string, remoteId: string, r: SkillRef): Promise { + const from = r.version; + if (!from) return null; + const got = await skillsCall<{ versions?: unknown }>(pid, `/${encodeURIComponent(remoteId)}`); + if (got?.status !== 200 || !Array.isArray(got.json.versions)) return null; + const theirs = got.json.versions.filter((x): x is string => typeof x === "string"); + const to = nextVersion([...theirs, ...(r.versions ?? []).map((x) => x.version)]); + const was = readBundle(versionDir(wsId, r.kind, r.slug, from)); + const dir = versionDir(wsId, r.kind, r.slug, to); + if (!was || !dir) return null; + const md = renderSkillMd(withVersion(was.frontmatter, to), was.body); + writeTree(dir, md, was.files); + const sha = bundleSha(md, was.files); + patchRef(wsId, r.kind, r.slug, (x) => { + x.versions = [...(x.versions ?? []), { version: to, at: Date.now(), sha256: sha }]; + x.version = to; + }); + console.warn(`[skills] ${r.slug}: Orgo has ${from} already, so this Mac's goes as ${to}`); + return to; +} + /** `removed`: deleted on Orgo, so deleted here. `retry`: Orgo didn't take all of it for now (tried again after a backoff). */ export type SyncResult = { mode: "local" | "orgo"; pushed: number; pulled: number; failed: number; removed?: number; retry?: boolean }; @@ -2454,7 +2602,11 @@ async function syncOnce(wsId: string): Promise { // Its label too, where Orgo keeps it in a column (labelColumn): a move goes at once, without a version. const withLabelColumn = labelColumn.get(pid) === true; let p = await skillsCall<{ skill?: RemoteSkill }>(pid, `/${encodeURIComponent(there.id)}`, { method: "PATCH", body: { name: r.name, description: r.description, bot_ids: r.botIds, status: r.status, ...(withLabelColumn ? { label: columnLabel(r) } : {}) } }); - const v = r.version && there.current_version?.version !== r.version ? payloadOf(wsId, r) : null; + // Both moved to the same number since they last agreed (an Admin published it in Orgo, the owner here): the same + // number with other content is a clash, sent as the next free number below (freeVersion). + const theirsNow = there.current_version?.version ?? ""; + const clash = !!r.version && theirsNow === r.version && r.remoteVersion !== undefined && r.remoteVersion !== theirsNow && (await sameAsOrgo(wsId, pid, there.id, r)) === false; + const v = r.version && (theirsNow !== r.version || clash) ? payloadOf(wsId, r) : null; // The label Orgo's SKILL.md has from now on: the one a version sent carries. let sentLabel: string | undefined = withLabelColumn && p?.status === 200 ? sentLabelOf(r) : undefined; if (!withLabelColumn && p?.status === 200 && hasColumn(p.json.skill)) { @@ -2474,10 +2626,23 @@ async function syncOnce(wsId: string): Promise { } } if (v) { - const sentV = await skillsCall(pid, `/${encodeURIComponent(there.id)}/versions`, { method: "POST", body: { body: v.body, files: v.files, version: r.version } }); + const versionsOf = `/${encodeURIComponent(there.id)}/versions`; + let sentV = await skillsCall<{ code?: string }>(pid, versionsOf, { method: "POST", body: { body: v.body, files: v.files, version: r.version } }); + // That number is Orgo's already (an Admin published one there while this Mac was off, or a roll back here to a + // number Orgo moved past): this Mac's version goes as the next number free on both sides, here too. + if (sentV?.status === 409 && sentV.json.code === "version_exists") { + const next = await freeVersion(wsId, pid, there.id, findRef(wsId, r.kind, r.slug) ?? r); + const now = next ? findRef(wsId, r.kind, r.slug) : undefined; + const again = now ? payloadOf(wsId, now) : null; + if (next && now && again) sentV = await skillsCall<{ code?: string }>(pid, versionsOf, { method: "POST", body: { body: again.body, files: again.files, version: next } }); + } if (sentV && sentV.status < 300) { for (const sha of v.shas) pushed.add(sha); sentLabel = sentLabelOf(r); + } else if (sentV?.status === 409 && sentV.json.code !== "unchanged") { + // Not taken (still a number Orgo has): not marked as agreed, so it goes again. + res.failed++; + continue; } } if (p?.status === 200) { @@ -2587,27 +2752,28 @@ async function syncLabelInfo(wsId: string, pid: string): Promise { return true; } -type ExportFile = { content?: string; url?: string; sha256?: string; executable?: boolean }; +/** Two bundles' text and files the same (the blank line after the frontmatter aside): what skillDetail calls unchanged. */ +const sameWork = (a: Bundle, b: Bundle) => a.body.replace(/^\n+/, "") === b.body.replace(/^\n+/, "") && a.files.map((f) => `${f.path}:${f.sha256}`).join() === b.files.map((f) => `${f.path}:${f.sha256}`).join(); /** - * A file of Orgo's export as it was stored: its text inline, or its bytes from `url` (a short presigned GET, - * no key sent: orgo-web gives one for every binary file and for large text). Checked against its sha256; - * null when it can't be fetched or doesn't match, so nothing half there is ever written. + * Whether a skill's working folder here holds work not published yet, which a pull must keep: its text or files + * differ from the version its bots read (skillDetail's "changed"), or, a draft with no version here, it changed here + * since this Mac and Orgo last agreed. Orgo's version then goes beside it (its .versions folder, the ref's pointer), so + * an Admin's publish never wipes the owner's next version in the making. + */ +function workInFolder(wsId: string, r: SkillRef): boolean { + const here = readBundle(skillDir(wsId, r.kind, r.slug)); + if (!here) return false; + const used = r.version ? readBundle(versionDir(wsId, r.kind, r.slug, r.version)) : null; + if (used) return !sameWork(here, used); + return r.status === "draft" && !!r.syncedAt && r.updatedAt > r.syncedAt; +} + +/** + * Orgo's copy of a skill (its export: SKILL.md and every file) written here, as `into` or as a new one. Into one whose + * folder holds work not published yet (workInFolder), only Orgo's version is written (its .versions folder, and the + * ref's pointer to it): the folder stays as it is. */ -async function exportedFile(path: string, f: ExportFile): Promise<{ path: string; content: string | null; data?: Buffer; executable: boolean } | null> { - let bytes: Buffer; - if (typeof f.content === "string") bytes = Buffer.from(f.content, "utf8"); - else if (typeof f.url === "string" && /^https?:\/\//.test(f.url)) { - const res = await fetch(f.url, { signal: AbortSignal.timeout(120_000) }).catch(() => null); - if (!res?.ok) return null; - bytes = Buffer.from(await res.arrayBuffer()); - } else return null; - if (f.sha256 && sha256Hex(bytes) !== f.sha256) return null; - const text = typeof f.content === "string" ? f.content : textOf(path, bytes); - return text !== null ? { path, content: text, executable: !!f.executable } : { path, content: null, data: bytes, executable: !!f.executable }; -} - -/** Orgo's copy of a skill (its export: SKILL.md and every file) written here, as `into` or as a new one. */ async function pull(wsId: string, pid: string, x: RemoteSkill, into?: SkillRef): Promise { const ex = await skillsCall<{ files?: Record; version?: string }>(pid, `/${encodeURIComponent(x.id)}/export`); if (!ex || ex.status !== 200 || !ex.json.files) return false; @@ -2641,7 +2807,9 @@ async function pull(wsId: string, pid: string, x: RemoteSkill, into?: SkillRef): const unmoved = into.remoteLabel !== undefined && sameLabel(pulledAs, into.remoteLabel); const mine = suggested(into) ? undefined : into.label; const keep = unmoved && !!mine && !sameLabel(pulledAs, mine) ? mine : column && !sameLabel(pulledAs, labelOf(parsed.frontmatter) ?? UNSORTED) ? pulledAs : undefined; - writeTree(skillDir(wsId, kind, into.slug)!, keep ? renderSkillMd(withLabel(parsed.frontmatter ?? {}, keep), parsed.body) : md, files); + // Work not published yet in its folder: kept, and Orgo's version goes beside it (with a version to go to). + const keepFolder = !!version && workInFolder(wsId, into); + if (!keepFolder) writeTree(skillDir(wsId, kind, into.slug)!, keep ? renderSkillMd(withLabel(parsed.frontmatter ?? {}, keep), parsed.body) : md, files); if (version) writeTree(versionDir(wsId, kind, into.slug, version)!, md, files); // Moved on Orgo (another Mac, orgo.ai): that label, the skill's own from now on (Unsorted: none). const label = (column || pulledLabel) && !unmoved ? knownLabel(wsId, pulledAs) : undefined; @@ -2725,6 +2893,49 @@ export function queueSync(wsId: string, tries = 0) { /** Whether Orgo has the skills routes for this project, as last seen (undefined: not asked yet). */ export const skillsRoutesSeen = (projectId: string) => routes.get(projectId); +/** How often the owner's Mac looks at Orgo for changes made there (an Admin's publish, roll back, delete or approval). */ +const POLL_MS = 2 * 60_000; +/** A workspace no one else is in is looked at every this many polls (10 minutes): an Admin with no access entry yet still lands. */ +const POLL_ALONE_EVERY = 5; + +/** + * The workspaces one poll syncs (the `ticks`th): each with an Orgo project whose skills routes Orgo has (or that wasn't + * asked yet), on the owner's Mac. A workspace with people in it (Workspace.memberAccess: someone the owner added) + * every poll; any other every POLL_ALONE_EVERY polls. One with a sync queued, backing off or under way is left to it. + */ +export function skillsPollDue(ticks: number): string[] { + if (onRunner() || userDir() === null) return []; + const timers = g.bopsSkillsSync; + const going = g.bopsSkillsInFlight; + return (getState().workspaces ?? []) + .filter((w) => !!w.orgoProjectId && routes.get(w.orgoProjectId) !== false) + .filter((w) => Object.keys(w.memberAccess ?? {}).length > 0 || ticks % POLL_ALONE_EVERY === 0) + .filter((w) => !timers?.has(w.id) && !going?.has(w.id)) + .map((w) => w.id); +} + +/** + * On the owner's Mac, while Bops runs (workspaces.ts startProjectBinding, once a start): every two minutes, what changed + * in Orgo comes here (syncSkills reads only what changed since the last: ?since=), so an Admin's publish, roll back, + * delete or approval made with this Mac off lands here soon after it's on, and bots on this Mac use it. Never on a + * computer's runner, which reads Orgo itself (runner-skills.ts). + */ +export function startSkillsPoll() { + if (onRunner() || g.bopsSkillsPoll) return; + const poll = { ticks: 0, timer: setInterval(() => { + poll.ticks++; + for (const wsId of skillsPollDue(poll.ticks)) queueSync(wsId); + }, POLL_MS) }; + poll.timer.unref?.(); + g.bopsSkillsPoll = poll; +} + +/** Tests: stop the poll. */ +export function stopSkillsPoll() { + if (g.bopsSkillsPoll) clearInterval(g.bopsSkillsPoll.timer); + g.bopsSkillsPoll = undefined; +} + /** * A deleted workspace's skills and apps go from this Mac (remove.ts deleteWorkspace, while it's still * listed, so its folder is found): its folder (workspaces/, with every skill's diff --git a/lib/server/workspaces.ts b/lib/server/workspaces.ts index 691fa8e5b..995b178ca 100644 --- a/lib/server/workspaces.ts +++ b/lib/server/workspaces.ts @@ -813,7 +813,7 @@ function bindLater() { * hadn't taken when Bops last quit goes now, as its retries lived in memory only: each one's skills are * synced (a create, an edit or a bot dropped since its last sync, SkillRef.updatedAt past syncedAt, and * what Orgo has), and its Orbit is sent where its index still holds what Orgo hasn't taken (deletes, - * items or facts: orbitOutbox). + * items or facts: orbitOutbox). Then skills are looked at in Orgo every couple of minutes (startSkillsPoll). */ export function startProjectBinding() { if (g.bopsWsBindStarted || onRunner() || !stateReady() || stateTooNew()) return; @@ -823,6 +823,8 @@ export function startProjectBinding() { // A deleted workspace's memory that memory didn't take before Bops last quit. void import("./memory-deletes").then((m) => m.memoryDeletesOnStart()).catch((e: Error) => console.warn(`[memory] deletes from before: ${e.message}`)); void pushBoundOnStart().catch((e: Error) => console.warn(`[workspaces] sending what waited from before: ${e.message}`)); + // From then on, what an Admin changed in a workspace's skills in Orgo comes here every couple of minutes. + void import("./workspace-skills").then((m) => m.startSkillsPoll()).catch((e: Error) => console.warn(`[skills] poll: ${e.message}`)); } async function pushBoundOnStart() { diff --git a/lib/shared.ts b/lib/shared.ts index 05d76467d..bfd44c7d0 100644 --- a/lib/shared.ts +++ b/lib/shared.ts @@ -190,6 +190,13 @@ export const powerWho = (state: Viewing): PowerWho => ({ ...(state.shared?.changes ? { can: Object.fromEntries((state.shared.can ?? []).map((p) => [p, true])) as Partial> } : {}), }); +/** + * Whether the viewer works on a shared workspace's skills in Knowledge, Skills (lib/server/shared-skills.ts: orgo-web with + * their own Orgo key, which checks again): an Admin, everything but labels. The one rule the window and the server both + * read. A Member with Memory and skills (Power "memory") comes next, once orgo-web lets them write and says so: here. + */ +export const sharedSkillsWho = (s: { role?: SharedRole; can?: readonly string[] } | null | undefined): boolean => s?.role === "admin"; + export const mayChange = (state: Viewing, power: Power, { soon = false } = {}) => !state.shared || (!!state.shared.can?.includes(power) && (soon || state.shared.changes === true)); diff --git a/lib/types.ts b/lib/types.ts index c584ea9a0..1a8cf9bb0 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -494,9 +494,11 @@ export type ScriptApproval = { synced?: boolean; /** * Made in a Bops window by the person signed in (approveScript), not taken from Orgo. One taken from - * Orgo counts only while Orgo says that person made it: another member's approval never runs a script here. + * Orgo counts only while Orgo says the owner or an Admin made it: a Member's approval never runs a script here. */ inBops?: boolean; + /** Whose it was, as Orgo said when it was taken from there: the owner's or an Admin's (lib/server/workspace-skills.ts approvalCounts). */ + by?: "owner" | "admin"; }; export type WorkspaceLine = { @@ -968,8 +970,17 @@ export type Message = { * Nothing changes until the owner presses Accept in the window (lib/server/skill-proposals.ts). */ skillProposals?: SkillProposal[]; + /** + * Set on a bot's message: skills it saved as drafts this turn (chat.ts create_skill), each a card under the message + * that opens it in Skills, in Knowledge, to read and publish (components/app/skill-drafts.tsx). Publishing is a + * person's, there, with their own sign in: the owner, or an Admin of a shared workspace. + */ + skillDrafts?: SkillDraftNote[]; }; +/** A skill a bot saved as a draft (Message.skillDrafts): which one, to open in Skills. */ +export type SkillDraftNote = { kind: SkillKind; slug: string; name: string }; + /** * A reply a bot wrote for the user, to an email, a Slack message or a Discord message it read with the user's * own account (use_app) in the same turn: it goes back in that thread or channel, from that account, when the diff --git a/scripts/test-chat-tools.mjs b/scripts/test-chat-tools.mjs index 9c2decd40..f1d6dc650 100644 --- a/scripts/test-chat-tools.mjs +++ b/scripts/test-chat-tools.mjs @@ -979,14 +979,17 @@ await check("read_skill, create_skill (a draft), update_skill, and start_task fo assert.equal(t.out(1), 'No skill called "Taxes". Your labels: Apps and source control (7), Finance (1). Open one with read_skill and label to see its skills.'); assert.equal(skillNamed("Weekly invoice summary").uses, 1, "a read counts as a use"); const v = await turnWith("remember how you post the standup", [toolCall("create_skill", { name: "Standup post", description: "When Alex asks for \"the standup notes\"", steps: "1. Read yesterday's chat.\n2. Post three lines.", pitfalls: null })]); - assert.equal(v.out(), 'Saved "Standup post" as a draft. Alex can publish it in Skills, in Knowledge; then you\'ll use it.'); + assert.equal(v.out(), 'Saved "Standup post" as a draft. Alex or an Admin can read it and publish it in Skills, in Knowledge (the card under your answer opens it there); then you\'ll use it.'); + // Its card under the answer: which one, to open in Skills (never a publish of its own). + assert.deepEqual(messages().findLast((m) => m.role === "bot").skillDrafts, [{ kind: "skill", slug: "standup-post", name: "Standup post" }]); assert.equal(skillNamed("Standup post").from.chatId, chatId); assert.equal(skillNamed("Standup post").status, "draft", "a bot's skill is a draft until Alex publishes it"); assert.deepEqual(skillNamed("Standup post").botIds, ["boppy"]); assert.ok(!W.skillsNote("boppy").includes("Standup post"), "a draft isn't in its list yet"); assert.equal(messages().findLast((m) => m.role === "system").text, 'Saved the skill "Standup post" as a draft'); const w = await turnWith("update that skill: just post three lines", [toolCall("update_skill", { name: "standup post", description: "When Alex asks for standup notes", steps: "1. Post three lines.", pitfalls: null })]); - assert.match(w.out(), /^Updated "/); + assert.match(w.out(), /^Updated "[Ss]tandup post" as a draft\. Alex can publish it in Skills, in Knowledge; then you'll use it\.$/, `a change to one is the owner's to publish: ${w.out()}`); + assert.equal(messages().findLast((m) => m.role === "bot").skillDrafts, undefined, "no card for a change"); const w0 = await turnWith("update that skill: the payroll one", [toolCall("update_skill", { name: "Payroll", description: "x", steps: "1. x", pitfalls: null })]); assert.equal(w0.out(), 'Not changed: you have no skill called "Payroll". Use create_skill for a new one.'); // A secret in it: refused, said by where and what kind, never the text. @@ -2142,6 +2145,8 @@ await check("an Admin's turn (MemberTurn.role) has every bot and Your Apps at Wr await Chat.handleMessage(chatId, "can you change the site?", undefined, undefined, undefined, undefined, undefined, undefined, undefined, { ...ivy, ref, role: "admin" }); assert.ok(asked[n], `answered with ${tag}`); for (const tool of appTools) assert.ok(toolNames(asked[n]).includes(tool), `${tag}: ${tool}`); + // An Admin's turn keeps create_skill and update_skill (an Admin can do everything but delete the workspace or pay). + for (const tool of ["create_skill", "update_skill"]) assert.ok(toolNames(asked[n]).includes(tool), `${tag}: ${tool}`); const kept = messages().find((m) => m.member?.ref === ref); assert.deepEqual(kept.member, { ...ivy, ref }, "the role isn't kept on the message"); // A computer's runner (runner-chat.ts answerMessage): a message Bops Cloud wrote, answered as an Admin's. @@ -2705,7 +2710,7 @@ await check("the owner's turn on the bot's computer: its apps on the web and its const { runInTurn } = await import(`${root}/lib/server/turn-context.ts`); // A stand in for orgo-web's skills route: what each POST asked, answered as the test says. const posts = []; - let answer = { status: 201, json: { skill: { id: "sk_1", name: "Weekly recap" } } }; + let answer = { status: 201, json: { skill: { id: "sk_1", slug: "weekly-recap", name: "Weekly recap" } } }; orgoFake.handle = async (req) => { const u = new URL(req.url); // (Anything else this turn asks of orgo-web, its keys say: not found.) @@ -2734,7 +2739,9 @@ await check("the owner's turn on the bot's computer: its apps on the web and its const names = toolNames(t.first); for (const x of ["work_on_app", "deploy_app", "local_app", "create_skill", "update_skill", "propose_skill_change", "start_task"]) assert.ok(names.includes(x), `has ${x}`); for (const x of ["schedule", "manage_routine", "watch", "make_page", "make_image", "make_app", "change_app", "create_bot"]) assert.ok(!names.includes(x), `no ${x}`); - assert.equal(t.out(), "Saved \"Weekly recap\" as a draft in your workspace's skills. Alex can publish it in Skills, in Knowledge; then you'll use it."); + assert.equal(t.out(), "Saved \"Weekly recap\" as a draft in your workspace's skills. Alex or an Admin can read it and publish it in Skills, in Knowledge (the card under your answer opens it there); then you'll use it."); + // Its card under the answer, by Orgo's slug for it. + assert.deepEqual(messages().findLast((m) => m.role === "bot").skillDrafts, [{ kind: "skill", slug: "weekly-recap", name: "Weekly recap" }]); const noteOf = (req) => req.input.filter((x) => x.role === "developer").map((x) => x.content).join("\n"); assert.match(noteOf(t.first), /not from Alex's Mac\. Your workspace's apps on the web \(Local, Preview and Published\) work from here as always\. So do skills: a new one is saved as a draft/); assert.equal(posts.length, 1); diff --git a/scripts/test-shared-skills.mjs b/scripts/test-shared-skills.mjs new file mode 100644 index 000000000..b077ba3c5 --- /dev/null +++ b/scripts/test-shared-skills.mjs @@ -0,0 +1,496 @@ +// Tests for a shared workspace's skills on the viewer's own Bops (lib/server/shared-skills.ts, app/api/skills): an +// Admin there lists, reads, publishes, imports, updates from a folder, rolls back, deletes, gives a skill to its bots +// and approves scripts in orgo-web with their own Orgo key, with the owner's Mac off (no change act, no Bops Cloud); +// a request's `ws` is ignored and the viewer's own workspaces and folders are never touched; a Member without the +// power is refused before Orgo is asked; labels, Who has what and a bot's proposals stay the owner's; and orgo-web's +// refusals come out in plain words. Every word said is checked for dashes and for "Orbit". +// +// orgo-web is a fake (a local server with the skills routes), the shared workspace open is the test's (the real +// lib/server/shared.ts, with what Skills reads of it swapped for globalThis.__ss), the Keychain is never there, and the +// state and folders live in a throwaway folder: nothing reaches a real service. The keys are made up. +// Usage: node --conditions=react-server scripts/test-shared-skills.mjs +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { registerHooks } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +for (const k of Object.keys(process.env)) if (/^(BOPS|OPENAI|AGENTPHONE|AGENTMAIL|HONCHO|COMPOSIO|TYPESAFE|TWILIO|ORGO|CODEX|TAILSCALE|TREG)_/.test(k)) delete process.env[k]; +process.env.BOPS_SELF_HOSTED = "1"; +const UI = "ui-token-test-shared-skills"; +process.env.BOPS_UI_TOKEN = UI; +const root = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); +const scratch = mkdtempSync(join(tmpdir(), "bops-test-shared-skills-")); +const bin = join(scratch, "bin"); +mkdirSync(bin); +writeFileSync(join(bin, "security"), "#!/bin/sh\nexit 44\n", { mode: 0o755 }); +process.env.PATH = `${bin}:${process.env.PATH}`; +process.env.HOME = join(scratch, "home"); +mkdirSync(process.env.HOME); +/** The viewer's own Orgo key (made up): their Bops sign in. */ +const ORGO_KEY = "sk_orgo_test_shared_skills"; +globalThis.bopsOrgoKey = ORGO_KEY; +const work = join(scratch, "work"); +mkdirSync(work); +process.chdir(work); + +const STAND_INS = new Set(["mac", "relay", "desktop", "mirror", "sessions"]); +registerHooks({ + resolve(specifier, context, next) { + if (specifier.startsWith("@/")) specifier = pathToFileURL(`${root}/${specifier.slice(2)}`).href; + try { + return next(specifier, context); + } catch (e) { + if (/^(\.{1,2}\/|\/|file:)/.test(specifier)) + for (const ext of [".ts", ".tsx"]) + try { + return next(specifier + ext, context); + } catch {} + throw e; + } + }, + load(url, context, next) { + // The shared workspace open: the real module, but what Skills reads of it is the test's while globalThis.__ss is set. + if (url.endsWith("/lib/server/shared.ts") && !url.includes("?real")) { + const real = JSON.stringify(`${url}?real`); + return { + format: "module", + shortCircuit: true, + source: `export * from ${real};\nimport * as real from ${real};\nexport const sharedOpen = () => (globalThis.__ss ? true : real.sharedOpen());\nexport const sharedSkillsOpen = () => (globalThis.__ss ? globalThis.__ss : real.sharedSkillsOpen());`, + }; + } + const name = url.match(/\/lib\/server\/([a-z-]+)\.ts$/)?.[1]; + if (!name || !STAND_INS.has(name)) return next(url, context); + const names = [...readFileSync(new URL(url), "utf8").matchAll(/^export (?:async )?(?:function\*? |const |let |class )([A-Za-z_$][\w$]*)/gm)].map((m) => m[1]); + return { format: "module", shortCircuit: true, source: names.map((n) => `export const ${n} = function () { return Promise.resolve(); };`).join("\n") }; + }, +}); +const emitWarning = process.emitWarning.bind(process); +process.emitWarning = (warning, ...rest) => (rest[0]?.code === "MODULE_TYPELESS_PACKAGE_JSON" ? undefined : emitWarning(warning, ...rest)); +const logged = []; +for (const k of ["info", "warn", "error"]) console[k] = (...a) => logged.push(a.map(String).join(" ")); + +/** Made up, in a shape the scanner knows. Nothing here is a real key. */ +const FAKE_STRIPE = ["sk", "live", "0123456789abcdefABCDEF01"].join("_"); + +/* ---------------- A fake orgo-web's skills routes ---------------- */ + +const P = "11111111-2222-4333-8444-555555555555"; +const AT = "2026-11-12T09:00:00.000Z"; +const orgo = { + pages: false, + next: null, + calls: [], + skills: [], + approvals: new Map(), +}; +const reset = () => { + orgo.pages = false; + orgo.next = null; + orgo.calls = []; + orgo.approvals = new Map(); + orgo.skills = [ + // A draft a bot saved on its computer, for one bot. + { id: "a0000000-0000-4000-8000-000000000001", slug: "weekly-report", name: "Weekly report", description: "Sums up the week.", type: "skill", status: "draft", bot_ids: ["max"], updated_at: AT, created_at: AT, versions: ["0.1.0"], current: "0.1.0", label: "Writing", body: "# Weekly report\n\n1. Read the chat.\n", files: [{ path: "scripts/sum.sh", sha256: "d".repeat(64), size_bytes: 20, executable: true, media_type: "text/x-shellscript", approved: false, content: "#!/bin/sh\necho 1\n" }] }, + // Published, under its label, for every bot with it. + { id: "a0000000-0000-4000-8000-000000000002", slug: "triage", name: "Triage", description: "Sorts the inbox.", type: "skill", status: "published", bot_ids: [], updated_at: "2026-11-12T10:00:00.000Z", created_at: AT, versions: ["0.1.0", "0.1.1"], current: "0.1.1", label: "Email", body: "# Triage\n", files: [] }, + // Published, naming its bots. + { id: "a0000000-0000-4000-8000-000000000003", slug: "invoices", name: "Invoices", description: "Sends invoices.", type: "skill", status: "published", bot_ids: ["max", "ivy"], updated_at: AT, created_at: AT, versions: ["1.0.0"], current: "1.0.0", body: "# Invoices\n", files: [] }, + ]; +}; +reset(); +const send = (res, status, obj) => { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(obj)); +}; +const view = (s) => ({ id: s.id, workspace_id: P, slug: s.slug, name: s.name, description: s.description, type: s.type, status: s.status, bot_ids: s.bot_ids, source: "bops", created_at: s.created_at, updated_at: s.updated_at, current_version: s.current ? { version: s.current, file_count: s.files.length + 1, size_bytes: 100, risk: null, created_at: AT, ...(s.label ? { label: s.label } : {}) } : null }); +const server = createServer(async (req, res) => { + const chunks = []; + for await (const c of req) chunks.push(c); + const text = Buffer.concat(chunks).toString(); + const url = new URL(req.url, "http://fake"); + const body = text ? JSON.parse(text) : undefined; + orgo.calls.push({ method: req.method, path: url.pathname, query: url.search, body, auth: req.headers.authorization }); + if (req.headers.authorization !== `Bearer ${ORGO_KEY}`) return send(res, 401, { error: "Not authenticated" }); + if (orgo.pages) { + res.writeHead(404, { "content-type": "text/html" }); + return res.end("404"); + } + // A canned answer for the next call (of `method`, when it names one: the list read before a change goes on as usual). + if (orgo.next && (!orgo.next.method || orgo.next.method === req.method)) { + const n = orgo.next; + orgo.next = null; + return send(res, n.status, n.body); + } + const m = url.pathname.match(/^\/api\/projects\/([^/]+)\/skills(?:\/([^/]+))?(?:\/(versions|rollback|files)(?:\/(.+))?)?$/); + if (!m || m[1] !== P) return send(res, 404, { error: "Workspace not found.", code: "not_found" }); + const [, , sid, sub, rest] = m; + if (sid === "approvals") { + const sha = url.searchParams.get("sha256"); + if (req.method === "POST") { + orgo.approvals.set(body.sha256, { sha256: body.sha256, path_hint: body.path_hint ?? null, approved_by: "u_admin", approved_at: AT, approved_by_role: "admin" }); + return send(res, 201, { approval: orgo.approvals.get(body.sha256) }); + } + if (req.method === "DELETE") return orgo.approvals.delete(sha) ? send(res, 200, { revoked: true, sha256: sha }) : send(res, 404, { error: "That hash is not approved.", code: "not_found" }); + return send(res, 200, { approvals: [...orgo.approvals.values()] }); + } + if (!sid) { + if (req.method === "GET") return send(res, 200, { skills: orgo.skills.map(view), deleted: [], full: true, at: AT }); + if (req.method === "POST") { + const s = { id: `a0000000-0000-4000-8000-00000000010${orgo.skills.length}`, slug: body.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"), name: body.name, description: body.description, type: body.type, status: body.status ?? "published", bot_ids: body.bot_ids ?? [], updated_at: AT, created_at: AT, versions: ["0.1.0"], current: "0.1.0", body: body.body, files: [] }; + orgo.skills.push(s); + return send(res, 201, { skill: view(s), version: { version: "0.1.0" }, warnings: [] }); + } + } + const s = orgo.skills.find((x) => x.id === sid); + if (!s) return send(res, 404, { error: "Skill not found.", code: "not_found" }); + if (!sub && req.method === "GET") { + const v = url.searchParams.get("version") ?? s.current; + if (!s.versions.includes(v)) return send(res, 404, { error: "That version does not exist.", code: "version_not_found" }); + return send(res, 200, { skill: { ...view(s), current_version: undefined }, version: { version: v, frontmatter: { name: s.slug, description: s.description }, body: s.body, files: s.files.map((f) => Object.fromEntries(Object.entries(f).filter(([k]) => k !== "content"))) }, versions: s.versions }); + } + if (sub === "files" && req.method === "GET") { + const f = s.files.find((x) => x.path === decodeURIComponent(rest ?? "")); + return f ? send(res, 200, { path: f.path, sha256: f.sha256, size_bytes: f.size_bytes, executable: f.executable, media_type: f.media_type, content: f.content }) : send(res, 404, { error: "File not found.", code: "not_found" }); + } + if (!sub && req.method === "PATCH") { + if (body.if_updated_at && body.if_updated_at !== s.updated_at) return send(res, 409, { error: "The skill changed since you read it.", code: "stale_skill", skill: view(s) }); + Object.assign(s, { ...(body.status ? { status: body.status } : {}), ...(body.bot_ids ? { bot_ids: body.bot_ids } : {}), updated_at: "2026-11-12T11:00:00.000Z" }); + return send(res, 200, { skill: view(s) }); + } + if (!sub && req.method === "DELETE") { + orgo.skills = orgo.skills.filter((x) => x !== s); + return send(res, 200, { deleted: true, id: s.id }); + } + if (sub === "rollback" && req.method === "POST") { + s.current = body.version; + return send(res, 200, { skill: view(s) }); + } + if (sub === "versions" && req.method === "POST") { + const v = `0.1.${s.versions.length}`; + s.versions.push(v); + Object.assign(s, { current: v, status: "published", body: body.body, updated_at: "2026-11-12T12:00:00.000Z" }); + return send(res, 201, { skill: view(s), version: { version: v } }); + } + return send(res, 405, { error: "no" }); +}); +await new Promise((r) => server.listen(0, "127.0.0.1", r)); +process.env.BOPS_ORGO_ORIGIN = `http://127.0.0.1:${server.address().port}`; + +/* ---------------- Helpers ---------------- */ + +let passed = 0; +let failed = 0; +async function check(name, fn) { + try { + await fn(); + passed++; + console.log(`ok ${name}`); + } catch (e) { + failed++; + console.log(`FAIL ${name}\n ${String(e?.stack ?? e).split("\n").slice(0, 8).join("\n ")}`); + } +} +/** Everything a person could read, checked for dashes and Orbit at the end. */ +const said = []; + +const S = await import(`${root}/lib/server/store.ts`); +const W = await import(`${root}/lib/server/workspace-skills.ts`); +const SS = await import(`${root}/lib/server/shared-skills.ts`); +const Route = await import(`${root}/app/api/skills/route.ts`); +const route = async (body, method = "POST", { window = true } = {}) => { + const res = await Route[method]( + new Request(`http://localhost:3210/api/skills${method === "GET" ? (body ?? "") : ""}`, { method, headers: { "content-type": "application/json", ...(window ? { "x-bops-window": UI } : {}) }, ...(method === "GET" ? {} : { body: JSON.stringify(body) }) }), + ); + const json = await res.json(); + said.push(JSON.stringify(json)); + return { status: res.status, body: json }; +}; +/** An Admin's shared workspace open (lib/server/shared.ts sharedSkillsOpen), or a Member's. */ +const open = (role = "admin", can = []) => void (globalThis.__ss = { projectId: P, name: "Acme", role, can, botIds: ["max", "ivy"] }); +const close = () => void (globalThis.__ss = undefined); +const calls = () => orgo.calls.map((c) => `${c.method} ${c.path}${c.query}`); + +// The viewer's own Bops: their own workspace, with a skill of the same slug as the shared one's, in its folder. +S.update((s) => { + s.owner = { name: "Sam" }; + s.account = { user: { id: "u_admin", email: "sam@example.com" }, signedInAt: 0 }; + s.workspaces = [{ id: "ws_main", name: "Mine", createdAt: 0 }]; + s.bots = [{ id: "max", name: "Max", role: "Sales", color: "#5B8CFF", isMain: true, computerStatus: "none" }]; +}); +const mine = W.createSkill("ws_main", { name: "Weekly report", description: "My own weekly report.", body: "# Mine\n" }).ref; +const mineMd = () => readFileSync(join(process.cwd(), ".data", "workspaces", "ws_main", "skills", mine.slug, "SKILL.md"), "utf8"); +const mineBefore = mineMd(); +const folder = (name, files) => { + const dir = join(scratch, "folders", name); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + for (const [p, c] of Object.entries(files)) { + mkdirSync(join(dir, p, ".."), { recursive: true }); + writeFileSync(join(dir, p), c); + } + return dir; +}; + +/* ---------------- An Admin, with the owner's Mac off ---------------- */ + +await check("an Admin's list is orgo-web's, with their own key, under its labels; the viewer's own workspace isn't read or sent", async () => { + reset(); + open(); + try { + const r = await route("?ws=ws_main", "GET"); + assert.equal(r.status, 200); + assert.deepEqual(calls(), [`GET /api/projects/${P}/skills`]); + assert.ok(orgo.calls.every((c) => c.auth === `Bearer ${ORGO_KEY}`), "the user's own Orgo key"); + assert.deepEqual(r.body.skills.map((x) => [x.slug, x.status, x.version, x.label, x.botIds]), [ + ["triage", "published", "0.1.1", "Email", []], + ["weekly-report", "draft", undefined, "Writing", ["max"]], + ["invoices", "published", "1.0.0", undefined, ["max", "ivy"]], + ]); + assert.deepEqual([r.body.orgo, r.body.shared, r.body.apps], [true, true, []]); + assert.ok(!JSON.stringify(r.body).includes("My own weekly report"), "never the viewer's own skill of that slug"); + } finally { + close(); + } +}); + +await check("detail and a file are orgo-web's: SKILL.md, its files (a script marked, not approved), its versions; no folder", async () => { + reset(); + open(); + try { + const d = await route({ action: "detail", ws: "ws_main", kind: "skill", slug: "weekly-report" }); + assert.equal(d.status, 200); + assert.equal(d.body.body, "# Weekly report\n\n1. Read the chat.\n"); + assert.deepEqual(d.body.files, [{ path: "scripts/sum.sh", size: 20, sha256: "d".repeat(64), executable: true, script: true, approved: false, binary: false }]); + assert.deepEqual(d.body.ref.versions.map((v) => v.version), ["0.1.0"]); + assert.deepEqual([d.body.dir, d.body.missing, d.body.changed], [null, false, false]); + const old = await route({ action: "detail", kind: "skill", slug: "triage", version: "0.1.0" }); + assert.equal(old.status, 200); + assert.ok(orgo.calls.some((c) => c.path.endsWith("/a0000000-0000-4000-8000-000000000002") && c.query === "?version=0.1.0")); + const f = await route({ action: "file", kind: "skill", slug: "weekly-report", path: "scripts/sum.sh" }); + assert.deepEqual(f.body, { path: "scripts/sum.sh", content: "#!/bin/sh\necho 1\n", sha256: "d".repeat(64) }); + assert.equal((await route({ action: "file", kind: "skill", slug: "weekly-report", path: "../x" })).status, 404); + assert.equal((await route({ action: "detail", kind: "skill", slug: "nope" })).status, 404); + } finally { + close(); + } +}); + +await check("Publish sends PATCH status published (as read), only for a draft; the viewer's own skill of that slug stays as it was", async () => { + reset(); + open(); + try { + const r = await route({ action: "publish", ws: "ws_main", kind: "skill", slug: "weekly-report" }); + assert.equal(r.status, 200); + assert.equal(r.body.ref.status, "published"); + const patch = orgo.calls.find((c) => c.method === "PATCH"); + assert.deepEqual([patch.path, patch.body], [`/api/projects/${P}/skills/a0000000-0000-4000-8000-000000000001`, { status: "published", if_updated_at: AT }]); + assert.equal(orgo.skills[0].status, "published"); + const n = orgo.calls.length; + const again = await route({ action: "publish", kind: "skill", slug: "triage" }); + assert.equal(again.status, 409); + assert.match(again.body.error, /published already/); + assert.ok(!orgo.calls.slice(n).some((c) => c.method === "PATCH"), "nothing sent for one published"); + // Changed by someone else between Bops' read and its PATCH: orgo-web's 409 stale_skill, said in words. + orgo.skills[0].status = "draft"; + orgo.next = { method: "PATCH", status: 409, body: { error: "The skill changed since you read it.", code: "stale_skill" } }; + const stale = await route({ action: "publish", kind: "skill", slug: "weekly-report" }); + assert.deepEqual([stale.status, stale.body.error], [409, "Someone changed it just now. Look at it again, then try again."]); + // The viewer's own workspace: untouched. + assert.equal(W.findRef("ws_main", "skill", mine.slug).status, "draft"); + assert.equal(mineMd(), mineBefore); + } finally { + close(); + } +}); + +await check("Roll back, Delete and a skill's own bots go to orgo-web; one that follows its label isn't switched here", async () => { + reset(); + open(); + try { + let r = await route({ action: "rollback", kind: "skill", slug: "triage", version: "0.1.0" }); + assert.equal(r.status, 200); + assert.deepEqual(orgo.calls.at(-1).body, { version: "0.1.0" }); + assert.equal(r.body.ref.version, "0.1.0"); + r = await route({ action: "bot", kind: "skill", slug: "invoices", botId: "ivy", on: false }); + assert.equal(r.status, 200); + assert.deepEqual(orgo.calls.at(-1).body, { bot_ids: ["max"], if_updated_at: AT }); + assert.deepEqual(r.body.ref.botIds, ["max"]); + r = await route({ action: "bot", kind: "skill", slug: "invoices", botId: "max", on: false }); + assert.equal(r.status, 400); + assert.match(r.body.error, /At least one bot keeps it/); + const n = orgo.calls.length; + r = await route({ action: "bot", kind: "skill", slug: "triage", botId: "max", on: false }); + assert.equal(r.status, 403); + assert.equal(r.body.error, SS.BY_LABEL); + r = await route({ action: "bot", kind: "skill", slug: "invoices", botId: "stranger", on: true }); + assert.equal(r.status, 404); + assert.ok(!orgo.calls.slice(n).some((c) => c.method === "PATCH")); + r = await route({ action: "delete", kind: "skill", slug: "invoices" }); + assert.deepEqual([r.status, r.body], [200, { ok: true }]); + assert.equal(orgo.calls.at(-1).method, "DELETE"); + assert.ok(!orgo.skills.some((x) => x.slug === "invoices")); + assert.ok(W.findRef("ws_main", "skill", mine.slug), "the viewer's own skill is still there"); + } finally { + close(); + } +}); + +await check("approving a script goes to orgo-web with the user's own sign in; taking it back too; a key orgo-web won't take, in words", async () => { + reset(); + open(); + try { + let r = await route({ action: "approve", kind: "skill", sha256: "d".repeat(64), path: "scripts/sum.sh", on: true }); + assert.deepEqual([r.status, r.body], [200, { ok: true }]); + const post = orgo.calls.at(-1); + assert.deepEqual([post.method, post.path, post.body, post.auth], ["POST", `/api/projects/${P}/skills/approvals`, { sha256: "d".repeat(64), path_hint: "scripts/sum.sh" }, `Bearer ${ORGO_KEY}`]); + r = await route({ action: "approve", kind: "skill", sha256: "d".repeat(64), on: false }); + assert.equal(r.status, 200); + assert.equal(orgo.calls.at(-1).query, `?sha256=${"d".repeat(64)}`); + // Not approved there (anymore): nothing to take back. + assert.equal((await route({ action: "approve", sha256: "d".repeat(64), on: false })).status, 200); + orgo.next = { method: "POST", status: 403, body: { error: "A person approves scripts.", code: "session_required" } }; + r = await route({ action: "approve", sha256: "d".repeat(64), on: true }); + assert.equal(r.status, 403); + assert.match(r.body.error, /^Orgo takes an approval only from you, signed in to Bops with Orgo\./); + assert.equal((await route({ action: "approve", sha256: "nope", on: true })).status, 400); + assert.deepEqual(W.workspaceById("ws_main").approvals ?? [], [], "nothing kept on this Mac"); + } finally { + close(); + } +}); + +await check("Update from folder publishes the folder as its next version (POST versions); a new folder is a draft (POST skills); checked first", async () => { + reset(); + open(); + try { + const dir = folder("weekly-report", { "SKILL.md": "---\nname: weekly-report\ndescription: Sums up the week, better.\n---\n# Weekly report\n\n1. Read every chat.\n", "scripts/sum.sh": "#!/bin/sh\necho 2\n", "notes.log": "junk" }); + let r = await route({ action: "import", ws: "ws_main", kind: "skill", slug: "weekly-report", path: dir }); + assert.equal(r.status, 200); + const sent = orgo.calls.at(-1); + assert.deepEqual([sent.method, sent.path], ["POST", `/api/projects/${P}/skills/a0000000-0000-4000-8000-000000000001/versions`]); + assert.match(sent.body.body, /^---\nname: weekly-report\ndescription: "?Sums up the week, better\."?\n---\n/); + assert.deepEqual(sent.body.files.map((f) => f.path), ["scripts/sum.sh"], "junk left out"); + assert.equal(sent.body.version, undefined, "orgo-web picks the next number"); + assert.deepEqual([r.body.published, r.body.ref.status, r.body.ref.version], [true, "published", "0.1.1"]); + assert.deepEqual(r.body.skipped.map((p) => p.path), ["notes.log"]); + assert.equal(mineMd(), mineBefore, "the viewer's own folder of that slug untouched"); + // Another skill's folder: refused before Orgo is asked. + const n = orgo.calls.length; + r = await route({ action: "import", kind: "skill", slug: "weekly-report", path: folder("other", { "SKILL.md": "---\nname: other\ndescription: Something else.\n---\n# Other\n" }) }); + assert.equal(r.status, 400); + assert.match(r.body.error, /another skill/); + // A secret in SKILL.md: refused too, never its text. + r = await route({ action: "import", kind: "skill", path: folder("leaky", { "SKILL.md": `---\nname: leaky\ndescription: Leaks.\n---\nkey=${FAKE_STRIPE}\n` }) }); + assert.equal(r.status, 422); + assert.ok(!JSON.stringify(r.body).includes(FAKE_STRIPE)); + assert.equal(orgo.calls.length, n + 1, "only the list was read"); + // A new folder: a draft in Orgo. + r = await route({ action: "import", kind: "skill", path: folder("standup", { "SKILL.md": "---\nname: standup\ndescription: Runs the standup.\n---\n# Standup\n" }) }); + assert.equal(r.status, 200); + const made = orgo.calls.at(-1); + assert.deepEqual([made.method, made.path, made.body.status, made.body.type, made.body.name], ["POST", `/api/projects/${P}/skills`, "draft", "skill", "Standup"]); + assert.equal(r.body.ref.status, "draft"); + assert.ok(!W.findRef("ws_main", "skill", "standup"), "not made in the viewer's own workspace"); + } finally { + close(); + } +}); + +await check("labels, Who has what, a bot's proposals and a chat's Save as skill stay the owner's: refused, Orgo never asked", async () => { + reset(); + open(); + try { + for (const action of ["bot-label", "bot-labels", "label-rename", "label-about", "label-suggest", "proposal", "create", "update", "draft", "save-draft", "nonsense"]) { + const r = await route({ action, ws: "ws_main", kind: "skill", slug: "triage", label: "Email", botId: "max", on: true }); + assert.deepEqual([action, r.status, r.body.error], [action, 403, SS.SHARED_OWNER_ONLY]); + } + assert.deepEqual(calls(), []); + assert.equal(W.workspaceById("ws_main").skills.length, 1, "nothing made in the viewer's own workspace"); + } finally { + close(); + } +}); + +await check("a Member (even with Memory and skills, until phase 2) is refused before Orgo is asked; and only from the Bops window", async () => { + reset(); + for (const can of [[], ["memory"]]) { + open("member", can); + try { + for (const r of [await route("", "GET"), await route({ action: "detail", kind: "skill", slug: "triage" }), await route({ action: "publish", kind: "skill", slug: "weekly-report" })]) { + assert.deepEqual([r.status, r.body.error], [403, SS.SHARED_SKILLS_WHO]); + } + } finally { + close(); + } + } + open(); + try { + const r = await route({ action: "publish", kind: "skill", slug: "weekly-report" }, "POST", { window: false }); + assert.equal(r.status, 403); + assert.equal((await route("", "GET", { window: false })).status, 403); + } finally { + close(); + } + assert.deepEqual(calls(), []); +}); + +await check("orgo-web's refusals in plain words: view only, no skills routes (a page), signed out, a gone workspace", async () => { + reset(); + open(); + try { + orgo.next = { method: "PATCH", status: 403, body: { error: "This workspace is view only. Ask the owner for edit access.", code: "workspace_read_only" } }; + let r = await route({ action: "publish", kind: "skill", slug: "weekly-report" }); + // (The list read first: the refusal is the PATCH's.) + assert.deepEqual([r.status, r.body.error], [403, SS.SHARED_SKILLS_WHO]); + assert.equal(orgo.calls.at(-1).method, "PATCH"); + orgo.next = null; + orgo.skills[0].status = "draft"; + orgo.calls = []; + orgo.pages = true; + r = await route("", "GET"); + assert.deepEqual([r.status, r.body.error], [503, SS.SKILLS_NOT_ON_ORGO]); + orgo.pages = false; + orgo.next = { status: 404, body: { error: "Workspace not found.", code: "not_found" } }; + r = await route("", "GET"); + assert.deepEqual([r.status, r.body.error], [404, "This workspace isn't shared with you anymore."]); + orgo.next = { status: 403, body: { error: "This workspace is view only. Ask the owner for edit access.", code: "workspace_read_only" } }; + r = await route("", "GET"); + assert.deepEqual([r.status, r.body.error], [403, SS.SHARED_SKILLS_WHO]); + globalThis.bopsOrgoKey = "sk_orgo_someone_else"; + r = await route("", "GET"); + assert.equal(r.status, 401); + assert.match(r.body.error, /^Sign in to Orgo/); + } finally { + globalThis.bopsOrgoKey = ORGO_KEY; + close(); + } +}); + +await check("with no shared workspace open, Skills is the viewer's own, as before", async () => { + reset(); + const r = await route("?ws=ws_main", "GET"); + assert.equal(r.status, 200); + assert.deepEqual(r.body.skills.map((x) => x.slug), [mine.slug]); + assert.deepEqual(calls(), []); +}); + +await check("nothing said has a dash or says Orbit, and no body or key was logged", async () => { + const words = [...said, SS.SHARED_OWNER_ONLY, SS.SHARED_SKILLS_WHO, SS.SKILLS_NOT_ON_ORGO, SS.BY_LABEL]; + for (const w of words) { + const errors = [...w.matchAll(/"error":"([^"]*)"/g)].map((m) => m[1]); + for (const e of errors.length ? errors : [w]) { + assert.ok(!/[‒–—―]| - /.test(e), `a dash in: ${e}`); + assert.ok(!/orbit/i.test(e), `Orbit in: ${e}`); + } + } + const log = logged.join("\n"); + for (const secret of [ORGO_KEY, FAKE_STRIPE, "Read every chat"]) assert.ok(!log.includes(secret), `logged: ${secret}`); +}); + +server.close(); +rmSync(scratch, { recursive: true, force: true }); +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/scripts/test-shared.mjs b/scripts/test-shared.mjs index 60cd513cf..30f2de3c0 100644 --- a/scripts/test-shared.mjs +++ b/scripts/test-shared.mjs @@ -1383,8 +1383,11 @@ await check("while one is open, every write to the app's server is answered View for (const ok of ["/api/vault", "/api/account/checkout", "/api/owner", "/api/owner-email"]) assert.equal(callAllowed("POST", ok), true, ok); // Keys go: since a shared workspace has keys of its own, its keys routes act on those alone while it's open (below). assert.equal(callAllowed("POST", "/api/keys"), true, "a shared workspace's own keys"); + // Skills go too: while it's open its skills routes act on its skills in Orgo alone, for an Admin (lib/server/shared-skills.ts). + assert.equal(callAllowed("POST", "/api/skills"), true, "a shared workspace's own skills"); + assert.equal(callAllowed("GET", "/api/skills?ws=x"), true, "a shared workspace's own skills"); // Reads the server answers from the user's own bot or workspace of that id: never the shared one's. - for (const no of ["/api/memory?bot=boppy", "/api/memory/categories?ws=x", "/api/orbit/issues?ws=shared_p1", "/api/skills?ws=x", "/api/routines/suggestions?botId=boppy", "/api/vnc?bot=boppy&display=1", "/api/computer?bot=boppy", "/api/reader?bot=boppy", "/api/crm?ws=x", "/api/made-apps/app_x", "/api/hosted-apps?ws=x", "/api/domains?ws=x", "/api/domains/search?ws=x&q=acme", "/api/domains/6a6a6a6a-1111-4222-8333-444455556666?ws=x"]) + for (const no of ["/api/memory?bot=boppy", "/api/memory/categories?ws=x", "/api/orbit/issues?ws=shared_p1", "/api/routines/suggestions?botId=boppy", "/api/vnc?bot=boppy&display=1", "/api/computer?bot=boppy", "/api/reader?bot=boppy", "/api/crm?ws=x", "/api/made-apps/app_x", "/api/hosted-apps?ws=x", "/api/domains?ws=x", "/api/domains/search?ws=x&q=acme", "/api/domains/6a6a6a6a-1111-4222-8333-444455556666?ws=x"]) assert.equal(callAllowed("GET", no), false, no); assert.equal(callAllowed("GET", "/api/vault"), true); wire.length = 0; diff --git a/scripts/test-skills-ui-shared.mjs b/scripts/test-skills-ui-shared.mjs new file mode 100644 index 000000000..6d5806d0d --- /dev/null +++ b/scripts/test-skills-ui-shared.mjs @@ -0,0 +1,319 @@ +// Tests for Knowledge, Skills in a workspace shared with the user (components/app/skills.tsx, memory-categories.tsx): for +// an Admin there, Skills shows the workspace's skills as Orgo keeps them (GET /api/skills, read again after each change), +// "Kept in Orgo for everyone in" its name; Publish only on a draft; no Import an app folder, no Open in Finder, no label to +// move, no Who has what or Suggest labels; Update from folder asks before it publishes; a skill that follows its label +// says so in place of its bots' switches. Knowledge's other kinds keep the owner's line, with Open Skills for an Admin; a +// Member sees no Skills. Nothing says a dash or Orbit. The .tsx files are compiled with the TypeScript in node_modules as +// they load and rendered into a DOM (linkedom); the server is a stubbed fetch. +// Usage: node scripts/test-skills-ui-shared.mjs (not with --conditions=react-server: these are client components) +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { registerHooks } from "node:module"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "typescript"; + +const root = fileURLToPath(new URL("..", import.meta.url)).replace(/\/$/, ""); +registerHooks({ + resolve(specifier, context, next) { + if (specifier.startsWith("@/")) specifier = pathToFileURL(`${root}/${specifier.slice(2)}`).href; + try { + return next(specifier, context); + } catch (e) { + if (/^(\.{1,2}\/|\/|file:)/.test(specifier)) + for (const ext of [".ts", ".tsx"]) + try { + return next(specifier + ext, context); + } catch {} + throw e; + } + }, + load(url, context, next) { + if (url.endsWith(".css")) return { format: "module", source: "export default {};", shortCircuit: true }; + if (!url.endsWith(".tsx")) return next(url, context); + const source = readFileSync(fileURLToPath(url), "utf8"); + const out = ts.transpileModule(source, { compilerOptions: { jsx: ts.JsxEmit.ReactJSX, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022, verbatimModuleSyntax: false }, fileName: url }); + return { format: "module", source: out.outputText, shortCircuit: true }; + }, +}); +const emitWarning = process.emitWarning.bind(process); +process.emitWarning = (warning, ...rest) => (rest[0]?.code === "MODULE_TYPELESS_PACKAGE_JSON" ? undefined : emitWarning(warning, ...rest)); + +const { parseHTML } = await import("linkedom"); +const { window: win } = parseHTML(""); +const doc = win.document; +win.HTMLElement.prototype.focus = function () {}; +win.HTMLElement.prototype.scrollTo = function () {}; +win.HTMLElement.prototype.scrollIntoView = function () {}; +globalThis.window = globalThis; +globalThis.location = { origin: "http://localhost:3210" }; +globalThis.addEventListener = () => {}; +globalThis.removeEventListener = () => {}; +globalThis.document = doc; +globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} }; +globalThis.sessionStorage = globalThis.localStorage; +globalThis.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); +globalThis.requestAnimationFrame = (f) => setTimeout(f, 0); +globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +// The Mac app's bridge: a folder picked in Finder, and one shown there. +let picked = null; +globalThis.bopsMac = { pickFolder: async () => picked, revealFolder: async () => true }; + +/** The server, stubbed: every call kept, answered from `lists` and `details`. */ +const calls = []; +let lists; +let details; +let answers = {}; +globalThis.fetch = async (url, init = {}) => { + const method = init.method ?? "GET"; + const body = init.body ? JSON.parse(init.body) : undefined; + calls.push({ url: String(url), method, body }); + const reply = (status, json) => ({ ok: status < 400, status, json: async () => json }); + if (String(url).startsWith("/api/skills") && method === "GET") return reply(200, { ...lists, orgo: true, shared: true }); + if (String(url) === "/api/skills" && method === "POST") { + if (body.action === "detail") return reply(200, details[body.slug]); + const a = answers[body.action]; + return a ? reply(a.status ?? 200, a.body) : reply(200, { ok: true, ref: lists.skills.find((x) => x.slug === body.slug) }); + } + return reply(404, {}); +}; + +const { createRoot } = await import("react-dom/client"); +const { act, createElement: h } = await import("react"); +const { SkillsPage } = await import(pathToFileURL(`${root}/components/app/skills.tsx`).href); +const { MemoryView } = await import(pathToFileURL(`${root}/components/app/memory-categories.tsx`).href); +const { sharedWsId } = await import(`${root}/lib/shared.ts`); +const { SkillDrafts, OpenSkill } = await import(pathToFileURL(`${root}/components/app/skill-drafts.tsx`).href); + +let passed = 0; +let failures = 0; +async function check(name, fn) { + try { + await fn(); + passed++; + console.log(`ok ${name}`); + } catch (e) { + failures++; + console.error(`FAIL ${name}\n ${e.stack.split("\n").slice(0, 10).join("\n ")}`); + } +} +const settle = () => new Promise((r) => setTimeout(r, 5)); +async function mount(el) { + const box = doc.createElement("div"); + doc.body.appendChild(box); + const r = createRoot(box); + await act(async () => { + r.render(el); + await settle(); + }); + await act(settle); + return { box, done: async () => (await act(async () => r.unmount()), box.remove()) }; +} +const click = (el) => + act(async () => { + el.dispatchEvent(new win.Event("click", { bubbles: true })); + await settle(); + await settle(); + }); +const buttons = (box) => [...box.querySelectorAll("button")]; +const button = (box, label) => buttons(box).find((b) => b.textContent.trim() === label); +const words = (box) => box.textContent.replace(/\s+/g, " ").trim(); +const seen = []; +const look = (box) => (seen.push(words(box)), words(box)); + +const WS = sharedWsId("p1"); +const ref = (over) => ({ kind: "skill", labelBy: "skill", botIds: [], source: "orgo", createdAt: 0, updatedAt: 1, ...over }); +const reset = () => { + calls.length = 0; + answers = {}; + picked = null; + lists = { + skills: [ + ref({ slug: "weekly-report", name: "Weekly report", description: "Sums up the week.", status: "draft", label: "Writing", botIds: ["max"], updatedAt: 3 }), + ref({ slug: "triage", name: "Triage", description: "Sorts the inbox.", status: "published", version: "0.1.1", label: "Email", updatedAt: 2 }), + ], + apps: [], + }; + const files = [{ path: "scripts/sum.sh", size: 20, sha256: "d".repeat(64), executable: true, script: true, approved: false, binary: false }]; + details = { + "weekly-report": { ref: { ...lists.skills[0], versions: [{ version: "0.1.0", at: 0, sha256: "" }] }, body: "# Weekly report\n", frontmatter: {}, files, dir: null, missing: false, changed: false, shared: true }, + triage: { ref: { ...lists.skills[1], versions: [{ version: "0.1.0", at: 0, sha256: "" }, { version: "0.1.1", at: 0, sha256: "" }] }, body: "# Triage\n", frontmatter: {}, files: [], dir: "/should/not/show", missing: false, changed: true, shared: true }, + }; +}; +/** The shared workspace open, as the window's state has it (AppState.shared): its bots, none of its skills. */ +const state = (role = "admin") => ({ + workspace: WS, + workspaces: [{ id: WS, name: "Acme", createdAt: 0 }], + bots: [ + { id: "max", name: "Max", role: "Sales", color: "#5B8CFF", isMain: true, workspaceId: WS, computerStatus: "none" }, + { id: "ivy", name: "Ivy", role: "Support", color: "#0A0A0A", isMain: false, workspaceId: WS, computerStatus: "none" }, + ], + chats: [], + sessions: [], + messages: [], + routines: [], + account: { user: { id: "u_sam", email: "sam@example.com" }, signedInAt: 0 }, + shared: { projectId: "p1", name: "Acme", owner: "ada@example.com", role, wsId: WS, can: role === "admin" ? ["memory"] : [] }, +}); + +await check("an Admin's Skills is Orgo's list, read once open: no app import, no Who has what or Suggest, and it says where it's kept", async () => { + reset(); + const m = await mount(h(SkillsPage, { state: state(), only: "skill", onClose() {} })); + try { + const t = look(m.box); + assert.ok(calls.some((c) => c.method === "GET" && c.url === "/api/skills"), "the list asked of the server (the shared branch)"); + assert.ok(!calls.some((c) => c.url.includes("sync=1")), "no sync of the viewer's own workspace"); + assert.match(t, /Kept in Orgo for everyone in Acme\./); + assert.match(t, /Weekly report/); + assert.match(t, /Triage/); + assert.ok(button(m.box, "Import a skill folder"), "a skill folder can be imported"); + assert.ok(!button(m.box, "Import an app folder"), "never an app"); + assert.ok(!/Who has what|Suggest labels/.test(t)); + } finally { + await m.done(); + } +}); + +await check("a draft opened: Publish, no label to move, no Open in Finder; pressing it publishes and reads the list again", async () => { + reset(); + const m = await mount(h(SkillsPage, { state: state(), only: "skill", focus: "skill:weekly-report", onClose() {} })); + try { + const t = look(m.box); + assert.ok(button(m.box, "Publish"), "Publish on a draft"); + assert.ok(!m.box.querySelector("[aria-haspopup=menu]"), "no label chip menu"); + assert.match(t, /Writing/); + assert.ok(!button(m.box, "Open in Finder")); + assert.ok(button(m.box, "Update from folder")); + const before = calls.filter((c) => c.method === "GET").length; + await click(button(m.box, "Publish")); + assert.deepEqual(calls.filter((c) => c.method === "POST" && c.body.action === "publish").map((c) => [c.body.slug, c.body.kind]), [["weekly-report", "skill"]]); + assert.ok(calls.filter((c) => c.method === "GET").length > before, "the list read again"); + } finally { + await m.done(); + } +}); + +await check("a published one opened: no Publish (its folder's changes aren't Orgo's), its versions to roll back to, and its label's bots said, not switched", async () => { + reset(); + const m = await mount(h(SkillsPage, { state: state(), only: "skill", focus: "skill:triage", onClose() {} })); + try { + const t = look(m.box); + assert.ok(!buttons(m.box).some((b) => /^Publish/.test(b.textContent.trim())), "no Publish on a published one"); + assert.ok(!button(m.box, "Open in Finder"), "no folder, whatever the detail says"); + assert.ok(button(m.box, "Roll back to this"), "an older version to roll back to"); + assert.match(t, /Every bot with the label Email has it\. The owner picks which bots have a label\./); + assert.ok(!m.box.querySelector("[aria-pressed]"), "no bot switches for a skill that follows its label"); + } finally { + await m.done(); + } +}); + +await check("Update from folder asks before it publishes, and sends nothing until Publish", async () => { + reset(); + picked = "/Users/sam/skills/weekly-report"; + answers.import = { body: { ref: { ...lists.skills[0], status: "published", version: "0.1.1" }, skipped: [], warnings: [], published: true } }; + const m = await mount(h(SkillsPage, { state: state(), only: "skill", focus: "skill:weekly-report", onClose() {} })); + try { + await click(button(m.box, "Update from folder")); + assert.match(look(m.box), /Publish the folder weekly-report as its next version\? Its bots use it at once\./); + assert.ok(!calls.some((c) => c.body?.action === "import"), "nothing sent yet"); + const bar = m.box.querySelector("[data-folder-confirm]"); + await click([...bar.querySelectorAll("button")].find((b) => b.textContent.trim() === "Publish")); + assert.deepEqual(calls.filter((c) => c.body?.action === "import").map((c) => [c.body.slug, c.body.path]), [["weekly-report", "/Users/sam/skills/weekly-report"]]); + assert.match(look(m.box), /Published Weekly report from the folder as its next version\./); + } finally { + await m.done(); + } +}); + +await check("a skill naming its bots: each a switch, sent as on or off", async () => { + reset(); + const m = await mount(h(SkillsPage, { state: state(), only: "skill", focus: "skill:weekly-report", onClose() {} })); + try { + const sw = [...m.box.querySelectorAll("[aria-pressed]")]; + assert.deepEqual(sw.map((b) => [b.textContent.trim(), b.getAttribute("aria-pressed")]), [["Max", "true"], ["Ivy", "false"]]); + await click(sw[1]); + assert.deepEqual(calls.filter((c) => c.body?.action === "bot").map((c) => [c.body.botId, c.body.on]), [["ivy", true]]); + } finally { + await m.done(); + } +}); + +await check("Knowledge in a shared workspace: Skills for an Admin, Open Skills under the owner's line; a Member sees neither", async () => { + reset(); + let opened = null; + const props = (role, category) => ({ state: state(role), user: "u_sam", ws: WS, category, onCategory: (c) => (opened = c), onClose() {} }); + let m = await mount(h(MemoryView, props("admin", "skills"))); + try { + assert.match(look(m.box), /Kept in Orgo for everyone in Acme\./); + } finally { + await m.done(); + } + m = await mount(h(MemoryView, props("admin", "all"))); + try { + assert.match(look(m.box), /Knowledge in Acme is kept on the owner's Bops/); + await click(button(m.box, "Open Skills")); + assert.equal(opened, "skills"); + } finally { + await m.done(); + } + calls.length = 0; + for (const category of ["skills", "all"]) { + m = await mount(h(MemoryView, props("member", category))); + try { + assert.match(look(m.box), /Knowledge in Acme is kept on the owner's Bops/); + assert.ok(!button(m.box, "Open Skills")); + assert.ok(!/Kept in Orgo/.test(words(m.box))); + } finally { + await m.done(); + } + } + assert.ok(!calls.some((c) => c.url.startsWith("/api/skills")), "a Member's window never asks"); +}); + +await check("a bot's draft in a chat: a card that opens it in Skills (never a publish of its own), for the owner and an Admin; a Member sees none", async () => { + reset(); + const msg = { id: "m1", chatId: "bot:max", role: "bot", botId: "max", text: "Saved it.", at: 1, skillDrafts: [{ kind: "skill", slug: "weekly-report", name: "Weekly report" }] }; + const opened = []; + const card = (st) => h(OpenSkill.Provider, { value: (item) => opened.push(item) }, h(SkillDrafts, { message: msg, state: st })); + // An Admin of a shared workspace. + let m = await mount(card(state("admin"))); + try { + assert.match(look(m.box), /Saved as a draft: Weekly report/); + await click(button(m.box, "Review and publish")); + assert.deepEqual(opened, ["skill:weekly-report"]); + assert.deepEqual(calls, [], "the card itself sends nothing"); + } finally { + await m.done(); + } + // A Member there: nothing. + m = await mount(card(state("member"))); + try { + assert.equal(m.box.querySelector("[data-skill-drafts]"), null); + } finally { + await m.done(); + } + // The owner, in their own workspace: once it's published, it says so. + const own = { ...state(), shared: undefined, workspaces: [{ id: "ws_main", name: "Mine", createdAt: 0, skills: [{ ...lists.skills[0], status: "published", version: "0.1.0" }] }] }; + m = await mount(card(own)); + try { + assert.match(look(m.box), /Published: Weekly report/); + assert.ok(button(m.box, "Open in Skills")); + } finally { + await m.done(); + } +}); + +await check("nothing on these pages has a dash or says Orbit", async () => { + for (const t of seen) { + assert.ok(!/[‒–—―]| - /.test(t), `a dash in: ${t.slice(0, 200)}`); + assert.ok(!/orbit/i.test(t), `Orbit in: ${t.slice(0, 200)}`); + } +}); + +console.log(`\n${passed} passed, ${failures} failed`); +process.exit(failures ? 1 : 0); diff --git a/scripts/test-skills-v2.mjs b/scripts/test-skills-v2.mjs index f1549e284..5b2f4e582 100644 --- a/scripts/test-skills-v2.mjs +++ b/scripts/test-skills-v2.mjs @@ -18,6 +18,7 @@ // in a throwaway folder: nothing reaches a real service. The keys in it are made up. // Usage: node --conditions=react-server scripts/test-skills-v2.mjs import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { registerHooks } from "node:module"; import { tmpdir } from "node:os"; @@ -105,6 +106,22 @@ async function fakeOrgo(req, url) { orgo.maps.push({ pid: map[1], body }); return json({ ok: true, kinds: [], ...fields }); } + // A computer's runner reading its workspace's published skills (runner-skills.ts): `runnerRead` by project, as + // orgo-web answers a computer's own key (published ones only, each at its version; approvals with the role); `down` + // (no answer) and `refuse` (an orgo-web from before: 403 computer_key). + const rr = url.pathname.match(/^\/api\/projects\/([^/]+)\/skills(?:\/([^/]+))?(?:\/(export))?$/); + if (rr && orgo.runnerRead?.[rr[1]]) { + const o = orgo.runnerRead[rr[1]]; + if (o.down) throw new TypeError("fetch failed"); + if (o.refuse) return json({ error: "A computer’s own key can’t list a workspace’s skills.", code: "computer_key" }, 403); + const [, , sid, sub] = rr; + const sha = (t) => createHash("sha256").update(t).digest("hex"); + if (!sid) return json({ skills: o.skills.map((x) => ({ id: x.id, slug: x.slug, name: x.name, description: x.description, type: "skill", status: x.status ?? "published", bot_ids: x.bot_ids, updated_at: x.updated_at ?? "2026-11-12T09:00:00.000Z", current_version: { version: x.version } })), deleted: [], full: true }); + if (sid === "approvals") return json({ approvals: o.approvals }); + const x = o.skills.find((y) => y.id === sid); + if (sub === "export" && x) return json({ slug: x.slug, name: x.name, version: x.version, files: { "SKILL.md": { content: x.md }, ...Object.fromEntries(Object.entries(x.files ?? {}).map(([p, c]) => [p, { content: c, sha256: sha(c), executable: p.startsWith("scripts/") }])) } }); + return json({ error: "Skill not found.", code: "not_found" }, 404); + } const labels = url.pathname.match(/^\/api\/projects\/([^/]+)\/skill-labels$/); if (labels) { if (!orgo.labelsRoute) return new Response("", { status: 404, headers: { "content-type": "text/html" } }); @@ -753,6 +770,122 @@ await check("Update from folder: the folder is the skill's next version (its lab assert.equal((await route({ action: "import", ws: "ws_main", slug: "nope", path: f })).status, 404); }); +/* ---------------- A computer's runner, with the owner's Mac off ---------------- */ + +/** On a runner, the computer's own key (made up) as its sign in holds it (orgo-auth.ts runnerKey), never the VMM's look; and a runner that would stop, kept. */ +const A = await import(`${root}/lib/server/orgo-auth.ts`); +A.orgoAuthForTests.leave((why) => logged.push(`left: ${why}`)); +const asRunner = () => { + process.env.BOPS_RUNNER = "1"; + globalThis.bopsRunnerAuth = { ...(globalThis.bopsRunnerAuth ?? {}), picking: Promise.resolve(ORGO_KEY) }; +}; +const asMac = () => { + delete process.env.BOPS_RUNNER; + if (globalThis.bopsRunnerAuth) delete globalThis.bopsRunnerAuth.picking; +}; + +await check("On a computer's runner with the owner's Mac off: Orgo's published skills, read with the computer's key, are its bots' by Bops' own rule; read_skill reads them; a copy carries them, runnable only on the owner's or an Admin's approval", async () => { + const R = await import(`${root}/lib/server/runner-skills.ts`); + const md = (slug, label, text) => `---\nname: ${slug}\ndescription: d\nmetadata:\n label: ${label}\n version: "1.0.0"\n---\n${text}`; + const CLOSE = "#!/bin/sh\necho closing\n"; + const OTHER = "#!/bin/sh\necho other\n"; + const sha = (t) => createHash("sha256").update(t).digest("hex"); + orgo.runnerRead = { + p_lab: { + skills: [ + // An Admin published it in Orgo: by its label, Finance (no bots named). + { id: "a0000000-0000-4000-8000-0000000000c1", slug: "close-the-books", name: "Close the books", description: "Closes the month.", version: "1.0.0", bot_ids: [], md: md("close-the-books", "Finance", "# Close the books\n\n1. Run the close.\n"), files: { "scripts/close.sh": CLOSE, "scripts/other.sh": OTHER } }, + // Named for Ana alone. + { id: "a0000000-0000-4000-8000-0000000000c2", slug: "ana-only", name: "Ana only", description: "Ana's own.", version: "0.2.0", bot_ids: ["ana"], md: md("ana-only", "Email", "# Ana only\n"), files: {} }, + ], + approvals: [ + { sha256: sha(CLOSE), approved_at: "2026-11-12T09:00:00.000Z", approved_by_role: "admin" }, + // A Member's approval: never runs a script. + { sha256: sha(OTHER), approved_at: "2026-11-12T09:00:00.000Z", approved_by_role: "member" }, + ], + }, + }; + setLabels("ben", ["Finance"]); + setLabels("ana", ["Email"]); + asRunner(); + try { + R.runnerSkillsForTests(); + orgo.calls = []; + await R.refreshRunnerSkills("ws_lab"); + assert.deepEqual(orgo.calls.map((c) => `${c.method} ${c.path}`), [ + "GET /api/projects/p_lab/skills", + "GET /api/projects/p_lab/skills/a0000000-0000-4000-8000-0000000000c1/export", + "GET /api/projects/p_lab/skills/a0000000-0000-4000-8000-0000000000c2/export", + "GET /api/projects/p_lab/skills/approvals", + ]); + assert.deepEqual(W.skillsFor("ben").map((r) => [r.slug, r.version, r.label]), [["close-the-books", "1.0.0", "Finance"]], "Ben: the Finance one, by his label"); + assert.deepEqual(W.skillsFor("ana").map((r) => r.slug), ["ana-only"], "Ana: hers by name; not Finance"); + assert.match(W.skillsNote("ben"), /Close the books/); + const read = hear(W.readSkill("ben", "Close the books")); + assert.match(read, /^Skill: Close the books \(version 1\.0\.0\)\n\n# Close the books\n\n1\. Run the close\./); + assert.match(read, /- scripts\/close\.sh \(script, approved\)/, "an Admin's approval runs it"); + assert.match(read, /- scripts\/other\.sh \(script, not approved yet/, "a Member's doesn't"); + assert.match(W.readSkill("ben", "Close the books", "scripts/close.sh"), /echo closing/); + assert.match(W.readSkill("ben", "Close the books", "scripts/other.sh"), /a script not approved yet/); + // Read once a minute: a second look at once asks nothing. + orgo.calls = []; + await R.refreshRunnerSkills("ws_lab"); + assert.deepEqual(orgo.calls, []); + // Copied to Ben's computer: the skill, its approved script runnable, the other one waiting (never its content). + const sent = []; + const n = await W.copySkillsToComputer("ben", "c_ben", async (_id, command) => (sent.push(command), { output: "", exit_code: 0 })); + assert.equal(n, 1); + const all = sent.join("\n"); + assert.match(all, /capabilities\/skills\/close-the-books\/SKILL\.md/); + const closeAt = all.indexOf(Buffer.from(CLOSE).toString("base64")); + assert.ok(closeAt >= 0, "the approved script's content goes"); + assert.ok(all.indexOf("chmod 755 '/workspace/capabilities/skills/close-the-books/scripts/close.sh'", closeAt) > closeAt, "and is made runnable after it is written"); + assert.ok(!all.includes(Buffer.from(OTHER).toString("base64")), "an unapproved script's content never goes"); + assert.doesNotMatch(all, /chmod 755 '[^']*other\.sh'/); + assert.doesNotMatch(all, /ana-only/, "Ana's isn't Ben's"); + // Nothing of it in the state: a computer may not write a workspace's skills. + assert.ok(!(S.getState().workspaces.find((w) => w.id === "ws_lab").skills ?? []).some((r) => r.slug === "close-the-books")); + } finally { + asMac(); + } + // On the Mac: none of it (the Mac's own folders and Orgo sync, as before). + assert.ok(!W.skillsFor("ben").some((r) => r.slug === "close-the-books")); +}); + +await check("On a runner: Orgo away keeps the copy read before (after a restart too); taken back to a draft there, it's off the bots and the computer; an older orgo-web (403) or none read, everything as before", async () => { + const R = await import(`${root}/lib/server/runner-skills.ts`); + asRunner(); + try { + // A restart: memory gone, the disk's copy read; Orgo away: it still serves. + R.runnerSkillsForTests(); + orgo.runnerRead.p_lab.down = true; + await R.refreshRunnerSkills("ws_lab"); + assert.deepEqual(W.skillsFor("ben").map((r) => r.slug), ["close-the-books"], "the copy kept on disk"); + assert.match(W.readSkill("ben", "Close the books"), /Run the close/); + // Back, with Close the books taken back to a draft: off Ben, and its folder off this computer. + orgo.runnerRead.p_lab.down = false; + orgo.runnerRead.p_lab.skills[0].status = "draft"; + R.runnerSkillsForTests(); + await R.refreshRunnerSkills("ws_lab"); + assert.deepEqual(W.skillsFor("ben"), []); + assert.ok(!existsSync(join(S.userDir(), "runner-skills", "p_lab", "skills", "close-the-books"))); + // An orgo-web from before (403 computer_key), and nothing read before: the state's list, as before. + S.update((s) => s.workspaces.find((w) => w.id === "ws_sync").skills = [...(s.workspaces.find((w) => w.id === "ws_sync").skills ?? []), { slug: "from-the-mac", kind: "skill", name: "From the Mac", description: "Published on the Mac.", status: "published", version: "0.1.0", versions: [{ version: "0.1.0", at: 1, sha256: "0".repeat(64) }], botIds: [], source: "bops", createdAt: 1, updatedAt: 1 }]); + orgo.runnerRead.p_sync = { refuse: true, skills: [], approvals: [] }; + await R.refreshRunnerSkills("ws_sync"); + assert.deepEqual(W.skillsFor("syd").map((r) => r.slug).filter((x) => x === "from-the-mac"), ["from-the-mac"], "the state's, as before"); + assert.equal(hear(W.readSkill("syd", "From the Mac")), "\"From the Mac\" isn't on this computer right now."); + const sent = []; + assert.equal(await W.copySkillsToComputer("syd", "c_syd", async (_id, c) => (sent.push(c), { output: "", exit_code: 0 })), 0, "the Mac's last copy left as it is"); + assert.deepEqual(sent, []); + } finally { + asMac(); + S.update((s) => (s.workspaces.find((w) => w.id === "ws_sync").skills = (s.workspaces.find((w) => w.id === "ws_sync").skills ?? []).filter((r) => r.slug !== "from-the-mac"))); + setLabels("ben", undefined); + setLabels("ana", undefined); + } +}); + /* ---------------- Plain words ---------------- */ await check("Plain words: nothing a person or a bot reads has a dash, says Orbit, or holds the made up secret", async () => { diff --git a/scripts/test-skills.mjs b/scripts/test-skills.mjs index fcdff49a0..41dc8d113 100644 --- a/scripts/test-skills.mjs +++ b/scripts/test-skills.mjs @@ -157,6 +157,7 @@ const server = createServer(async (req, res) => { } if (!sid && req.method === "POST") { const s = { id: `sk_${++remote.seq}`, pid, slug: body.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"), name: body.name, description: body.description, type: body.type, status: body.status, bot_ids: body.bot_ids, source: body.source, version: body.version ?? "0.1.0", body: body.body, files: body.files, updated_at: now() }; + s.versions = [s.version]; remote.skills.push(s); return send(res, 201, { skill: view(s), version: { version: s.version }, warnings: [] }); } @@ -169,8 +170,14 @@ const server = createServer(async (req, res) => { return [f.path, { ...where, sha256: f.badSha ?? sha(bytes), executable: !!f.executable }]; }; if (sub === "export") return send(res, 200, { slug: s.slug, name: s.name, description: s.description, type: s.type, version: s.version, files: { "SKILL.md": { content: s.body }, ...Object.fromEntries((s.files ?? []).map(exported)) } }); + // Its versions (GET …/skills/{id}): every number it ever had. + const numbers = () => (s.versions ??= [s.version].filter(Boolean)); + if (!sub && req.method === "GET") return send(res, 200, { skill: view(s), version: { version: s.version, body: s.body }, versions: numbers() }); if (sub === "versions" && req.method === "POST") { - Object.assign(s, { body: body.body ?? s.body, files: body.files ?? s.files, version: body.version, updated_at: now() }); + // A number it has already, as orgo-web: versions never change. + if (body.version && numbers().includes(body.version)) return send(res, 409, { error: `Version ${body.version} already exists. Versions never change; pick a new number.`, code: "version_exists" }); + numbers().push(body.version); + Object.assign(s, { body: body.body ?? s.body, files: body.files ?? s.files, version: body.version, status: "published", updated_at: now() }); return send(res, 201, { skill: view(s), version: { version: s.version } }); } if (req.method === "PATCH") { @@ -207,6 +214,8 @@ const hear = (...xs) => (said.push(...xs.map((x) => (typeof x === "string" ? x : const S = await import(`${root}/lib/server/store.ts`); const W = await import(`${root}/lib/server/workspace-skills.ts`); const B = await import(`${root}/lib/server/skill-bundle.ts`); +// How many of Bops' own skills are under its apps label (lib/builtin-skills.ts): what a prompt counts there. +const OWN_APPS = (await import(`${root}/lib/builtin-skills.ts`)).builtinSkills(true).filter((x) => x.label === "Apps and source control").length; const Route = await import(`${root}/app/api/skills/route.ts`); const route = async (body, method = "POST") => { const res = await Route[method](new Request(`http://localhost:3210/api/skills${method === "GET" ? body ?? "" : ""}`, { method, headers: { "content-type": "application/json", "x-bops-window": UI }, ...(method === "GET" ? {} : { body: JSON.stringify(body) }) })); @@ -281,7 +290,7 @@ await check("A new skill is a folder and a draft: its bots don't see it until it assert.equal(readFileSync(join(dir, "SKILL.md"), "utf8"), '---\nname: weekly-report\ndescription: "Sums up the week when asked for \\"the weekly report\\""\n---\n\n# Weekly report\n\n1. Read the chat.\n'); assert.notEqual(statSync(join(dir, "scripts", "sum.sh")).mode & 0o111, 0, "its script executable in its folder"); assert.deepEqual(W.skillsFor("boppy"), [], "a draft isn't any bot's yet"); - assert.equal(W.readSkill("boppy", "Weekly report"), 'No skill called "Weekly report". Your labels: Apps and source control (5). Open one with read_skill and label to see its skills.', "Bops' own only"); + assert.equal(W.readSkill("boppy", "Weekly report"), `No skill called "Weekly report". Your labels: Apps and source control (${OWN_APPS}). Open one with read_skill and label to see its skills.`, "Bops' own only"); // The same name again: its own slug. const twin = W.createSkill("ws_main", { name: "Weekly report", description: "Another", body: "# Two\n" }).ref; assert.equal(twin.slug, "weekly-report-2"); @@ -471,10 +480,10 @@ await check("Versions are version shaped (never a path); a key in a description await check("The prompts list skills and apps under their labels: up to 15 of its own by name and description, more as one line per label", async () => { W.publishSkill("ws_main", "app", "orgo-doctor"); const note = hear(W.skillsNote("max")); - assert.match(note, /^Your skills: [^\n]+\nApps and source control \(5\): [^\n]+\nWriting \(1\):\n- Weekly report: [^\n]+\nUnsorted \(1\):\n- Orgo doctor \(app\): Finds what's wrong with a computer and says the fix\.$/); + assert.match(note, new RegExp(`^Your skills: [^\\n]+\\nApps and source control \\(${OWN_APPS}\\): [^\\n]+\\nWriting \\(1\\):\\n- Weekly report: [^\\n]+\\nUnsorted \\(1\\):\\n- Orgo doctor \\(app\\): Finds what's wrong with a computer and says the fix\\.$`)); for (let i = 0; i < 20; i++) W.createSkill("ws_main", { name: `Many ${i}`, description: `Number ${i}`, body: `# ${i}\n`, status: "published" }); const many = W.skillsNote("max"); - assert.deepEqual(many.split("\n").filter((l) => l.startsWith("- ")).map((l) => l.slice(0, l.indexOf(":"))), ["- Apps and source control (5)", "- Writing (1)", "- Unsorted (21)"]); + assert.deepEqual(many.split("\n").filter((l) => l.startsWith("- ")).map((l) => l.slice(0, l.indexOf(":"))), [`- Apps and source control (${OWN_APPS})`, "- Writing (1)", "- Unsorted (21)"]); assert.match(many, /\n- Unsorted \(21\): Orgo doctor \(app\), Many 0, Many 1, /); assert.doesNotMatch(many, /Number \d/, "past 15 of its own, no descriptions: read_skill with the label lists them"); for (let i = 0; i < 20; i++) await W.deleteSkill("ws_main", "skill", `many-${i}`); @@ -784,6 +793,93 @@ await check("Several Macs agree: a sync reads only what changed since the last ( clearTimers(); }); +await check("An Admin's publish in Orgo while the owner's Mac was off comes in without wiping the owner's unpublished folder; a version number both took goes as the next free one, nothing kept here lost", async () => { + const clearTimers = () => { + for (const t of globalThis.bopsSkillsSync?.values() ?? []) clearTimeout(t); + globalThis.bopsSkillsSync?.clear(); + }; + const ref = (slug) => W.findRef("ws_two", "skill", slug); + const remoteOf = (slug) => remote.skills.find((x) => x.id === ref(slug).remoteId); + const later = (ms) => new Date(Date.now() + ms).toISOString(); + const md = (slug, text, version) => `---\nname: ${slug}\ndescription: Hands a ticket over.\nmetadata:\n version: "${version}"\n---\n${text}`; + // Published here and in step with Orgo. + const a = W.createSkill("ws_two", { name: "Handoff", description: "Hands a ticket over.", body: "# Handoff\n\n1. Read it.\n", status: "published" }).ref; + const b = W.createSkill("ws_two", { name: "Escalate", description: "Hands a ticket over.", body: "# Escalate\n\n1. Page someone.\n", status: "published" }).ref; + clearTimers(); + await W.syncSkills("ws_two"); + assert.equal(remoteOf(a.slug).version, "0.1.0"); + + // Handoff: the owner works on its folder (not published); an Admin publishes 0.1.1 in Orgo, later. + W.updateSkill("ws_two", "skill", a.slug, { body: "# Handoff\n\n1. Read it.\n2. The owner's step, not published.\n" }); + Object.assign(remoteOf(a.slug), { body: md(a.slug, "# Handoff\n\n1. Read it.\n2. The Admin's step.\n", "0.1.1"), version: "0.1.1", updated_at: later(5_000) }); + remoteOf(a.slug).versions.push("0.1.1"); + clearTimers(); + await W.syncSkills("ws_two"); + assert.equal(ref(a.slug).version, "0.1.1", "Orgo's version is the one in use"); + assert.match(W.readSkill("tess", a.slug), /The Admin's step/, "its bots read the Admin's"); + const d = W.skillDetail("ws_two", "skill", a.slug); + assert.match(d.body, /The owner's step, not published/, "the owner's folder kept"); + assert.equal(d.changed, true, "and said to differ from the version in use"); + assert.ok(existsSync(join(dataDir(), "p_two", ".versions", "skills", a.slug, "0.1.1", "SKILL.md"))); + + // Escalate: an Admin publishes 0.1.1 in Orgo; then, before this Mac read it, the owner publishes their own 0.1.1 here, later. + Object.assign(remoteOf(b.slug), { body: md(b.slug, "# Escalate\n\n1. The Admin's way.\n", "0.1.1"), version: "0.1.1", updated_at: later(-30_000) }); + remoteOf(b.slug).versions.push("0.1.1"); + W.updateSkill("ws_two", "skill", b.slug, { body: "# Escalate\n\n1. The owner's way.\n" }); + assert.equal(W.publishSkill("ws_two", "skill", b.slug).version, "0.1.1"); + clearTimers(); + remote.calls = []; + const r = await W.syncSkills("ws_two"); + const posts = remote.calls.filter((c) => c.method === "POST" && c.path.endsWith("/versions")).map((c) => c.body.version); + assert.deepEqual(posts, ["0.1.1", "0.1.2"], "refused as Orgo's number, then sent as the next free one"); + assert.equal(r.failed, 0, JSON.stringify(r)); + assert.deepEqual([remoteOf(b.slug).version, ref(b.slug).version], ["0.1.2", "0.1.2"]); + assert.match(remoteOf(b.slug).body, /The owner's way/); + // Said in the log, once. + const said = logged.findIndex((l) => l === `[skills] ${b.slug}: Orgo has 0.1.1 already, so this Mac's goes as 0.1.2`); + assert.ok(said >= 0); + logged.splice(said, 1); + assert.ok(existsSync(join(dataDir(), "p_two", ".versions", "skills", b.slug, "0.1.2", "SKILL.md"))); + assert.match(readFileSync(join(dataDir(), "p_two", ".versions", "skills", b.slug, "0.1.2", "SKILL.md"), "utf8"), /version: "0\.1\.2"/, "its SKILL.md here says the new number"); + assert.deepEqual(ref(b.slug).versions.map((v) => v.version), ["0.1.0", "0.1.1", "0.1.2"], "every version kept here stays"); + assert.match(W.readSkill("tess", b.slug), /^Skill: Escalate \(version 0\.1\.2\)[\s\S]*The owner's way/); + // The next sync agrees: nothing more sent. + clearTimers(); + remote.calls = []; + await W.syncSkills("ws_two"); + assert.equal(remote.calls.filter((c) => c.path.endsWith("/versions")).length, 0); + for (const x of [a, b]) await W.deleteSkill("ws_two", "skill", x.slug); + clearTimers(); +}); + +await check("The owner's Mac looks at Orgo every couple of minutes: a workspace with people every time, one alone every fifth, none with a sync already going, never on a runner", async () => { + const due = (n) => W.skillsPollDue(n); + // (One started by a start's binding, earlier in this file: stopped, so this one's is the test's.) + W.stopSkillsPoll(); + S.update((s) => { + s.workspaces.find((w) => w.id === "ws_two").memberAccess = { "admin@example.com": { others: "write", at: 1 } }; + }); + assert.deepEqual(due(1), ["ws_two"], "with people: every poll"); + S.update((s) => void delete s.workspaces.find((w) => w.id === "ws_two").memberAccess); + assert.deepEqual(due(1), [], "alone: not every poll"); + assert.deepEqual(due(5), ["ws_two"], "alone: every fifth"); + W.queueSync("ws_two"); + assert.deepEqual(due(5), [], "a sync queued already"); + for (const t of globalThis.bopsSkillsSync?.values() ?? []) clearTimeout(t); + globalThis.bopsSkillsSync?.clear(); + process.env.BOPS_RUNNER = "1"; + try { + assert.deepEqual(due(5), [], "never on a computer's runner"); + W.startSkillsPoll(); + assert.equal(globalThis.bopsSkillsPoll, undefined, "no poll there either"); + } finally { + delete process.env.BOPS_RUNNER; + } + W.startSkillsPoll(); + assert.ok(globalThis.bopsSkillsPoll, "on the Mac"); + W.stopSkillsPoll(); +}); + await check("Script approvals reach Orgo with the Bops sign-in key: approved and revoked through an outbox kept on disk, one whose skill isn't on Orgo yet sent after its sync, orgo.ai's taken here, its revokes too", async () => { const O = await import(`${root}/lib/server/sync-outbox.ts`); const clearTimers = () => { @@ -863,6 +959,30 @@ await check("Script approvals reach Orgo with the Bops sign-in key: approved and clearTimers(); await W.syncSkills("ws_two"); assert.equal(W.skillDetail("ws_two", "skill", k.slug).files[0].approved, false, "taken before from another member's: gone"); + // Orgo says whose role it is now: an Admin's approval runs it here (an Admin can do everything but delete the workspace + // or pay); a Member's never; an Admin made a Member again, or gone from the workspace, no longer. + const ws2 = () => S.getState().workspaces.find((w) => w.id === "ws_two"); + remote.approvals.set(`p_two:${shaGo}`, { ...editors(), approved_by_role: "admin" }); + clearTimers(); + await W.syncSkills("ws_two"); + assert.equal(W.skillDetail("ws_two", "skill", k.slug).files[0].approved, true, "an Admin's approval runs it here"); + assert.equal(ws2().approvals.find((a) => a.sha256 === shaGo).by, "admin"); + for (const role of ["member", null]) { + remote.approvals.set(`p_two:${shaGo}`, { ...editors(), approved_by_role: role }); + clearTimers(); + await W.syncSkills("ws_two"); + assert.equal(W.skillDetail("ws_two", "skill", k.slug).files[0].approved, false, `approved_by_role ${role}: not here`); + } + // The owner's, by role, even made on another account's Bops signed in as the owner elsewhere: runs. + remote.approvals.set(`p_two:${shaGo}`, { ...editors(), approved_by: "u_owner_elsewhere", approved_by_role: "owner" }); + clearTimers(); + await W.syncSkills("ws_two"); + assert.equal(ws2().approvals.find((a) => a.sha256 === shaGo)?.by, "owner"); + // As it was: another member's, from an orgo-web that doesn't say roles. + remote.approvals.set(`p_two:${shaGo}`, editors()); + clearTimers(); + await W.syncSkills("ws_two"); + assert.equal(W.skillDetail("ws_two", "skill", k.slug).files[0].approved, false); // Approved in this window: it runs, though Orgo's record stays the other member's. W.approveScript("ws_two", shaGo); await wait(() => S.getState().workspaces.find((w) => w.id === "ws_two").approvals.find((a) => a.sha256 === shaGo)?.synced);