diff --git a/apps/web/src/agent-source-read.ts b/apps/web/src/agent-source-read.ts index be9492d54..39698771c 100644 --- a/apps/web/src/agent-source-read.ts +++ b/apps/web/src/agent-source-read.ts @@ -11,11 +11,10 @@ import { import { type } from "arktype"; import { fetchSourceFile } from "./git-fetch"; +import { withGitToken } from "./git-token"; export class AgentSourceReadError extends Error {} -const GitTokenMintShape = type({ id: "string", secret: "string" }); - const ToolPackagePinShape = type({ name: "string", version: "string" }); const AgentWorkflowJsonShape = type({ @@ -34,14 +33,6 @@ const AgentWorkflowJsonShape = type({ const READ_TOKEN_LIFETIME_MS = 10 * 60 * 1000; -async function readErrorBody(response: Response): Promise { - const body: unknown = await response.json().catch(() => undefined); - const envelope = type({ - error: { code: "string", userMessage: "string", refId: "string" }, - })(body); - return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage; -} - /** An existing agent's deploy source, in the shape `deployAgentSource`'s * `NewAgentInput` needs plus the sources it declares for inference. */ export type AgentSource = { @@ -63,53 +54,36 @@ async function readAgentWorkflowStep( assetName: string, fetchImpl: typeof fetch, ): Promise { - const tokensPath = `/api/tenants/${encodeURIComponent(tenantId)}/git-tokens`; - const minted = await fetchImpl(tokensPath, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: `agent-read-${crypto.randomUUID()}`, - resource: `asset:${assetId}`, - refPattern: "refs/heads/main", - actions: ["can_read"], - expiresAt: new Date(Date.now() + READ_TOKEN_LIFETIME_MS).toISOString(), - }), - }); - if (!minted.ok) { - throw new AgentSourceReadError(`minting a read token failed: ${await readErrorBody(minted)}`); - } - const token = GitTokenMintShape(await minted.json()); - if (token instanceof type.errors) { - throw new AgentSourceReadError( - `the read token came back an unexpected shape: ${token.summary}`, - ); - } - const url = new URL( `/api/tenants/${encodeURIComponent(tenantId)}/assets/workflow/${assetName}.git`, globalThis.location.origin, ).toString(); - try { - const definitionFile = await fetchSourceFile({ - url, - token: token.secret, - filepath: WORKFLOW_SOURCE_DEFINITION_PATH, - }); - const workflowJson = parseWorkflowSourceDefinition(definitionFile, assetId); - const parsed = AgentWorkflowJsonShape(JSON.parse(workflowJson)); - if (parsed instanceof type.errors) { - throw new AgentSourceReadError( - `this agent's source came back an unexpected shape: ${parsed.summary}`, - ); - } - const step = Object.values(parsed.steps)[0]; - if (step === undefined) { - throw new AgentSourceReadError("this agent's source has no steps to read a prompt from"); - } - return step; - } finally { - await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { method: "DELETE" }); - } + return withGitToken({ + tenantId, + assetId, + actions: ["can_read"], + lifetimeMs: READ_TOKEN_LIFETIME_MS, + fetchImpl, + use: async (token) => { + const definitionFile = await fetchSourceFile({ + url, + token, + filepath: WORKFLOW_SOURCE_DEFINITION_PATH, + }); + const workflowJson = parseWorkflowSourceDefinition(definitionFile, assetId); + const parsed = AgentWorkflowJsonShape(JSON.parse(workflowJson)); + if (parsed instanceof type.errors) { + throw new AgentSourceReadError( + `this agent's source came back an unexpected shape: ${parsed.summary}`, + ); + } + const step = Object.values(parsed.steps)[0]; + if (step === undefined) { + throw new AgentSourceReadError("this agent's source has no steps to read a prompt from"); + } + return step; + }, + }); } /** Mints a read-only token, fetches the asset's `main`, and parses out the diff --git a/apps/web/src/git-fetch.ts b/apps/web/src/git-fetch.ts index ce8988d0d..ade7bd0e1 100644 --- a/apps/web/src/git-fetch.ts +++ b/apps/web/src/git-fetch.ts @@ -4,7 +4,7 @@ // pkt-lines fine, so no hand-rolled wire code is needed here. import LightningFS from "@isomorphic-git/lightning-fs"; import { Buffer } from "buffer"; -import git from "isomorphic-git"; +import git, { Errors } from "isomorphic-git"; import http from "isomorphic-git/http/web"; globalThis.Buffer ??= Buffer; @@ -13,29 +13,37 @@ export class GitFetchError extends Error {} const MAIN_REF = "refs/heads/main"; -/** Fetches `main` and returns `filepath`'s contents as text. */ -export async function fetchSourceFile(args: { +async function cloneAndFetchMain(args: { url: string; token: string; - filepath: string; -}): Promise { +}): Promise<{ fs: InstanceType; dir: string }> { const fs = new LightningFS(`workbench-fetch-${crypto.randomUUID()}`, { wipe: true }); const dir = "/repo"; await fs.promises.mkdir(dir); await git.init({ fs, dir, defaultBranch: "main" }); + await git.fetch({ + fs, + http, + dir, + url: args.url, + ref: MAIN_REF, + // The hub's git server advertises no `shallow` capability, so a + // depth-limited fetch is rejected outright; fetch the full branch. + singleBranch: true, + tags: false, + headers: { Authorization: `Bearer ${args.token}` }, + }); + return { fs, dir }; +} + +/** Fetches `main` and returns `filepath`'s contents as text. */ +export async function fetchSourceFile(args: { + url: string; + token: string; + filepath: string; +}): Promise { try { - await git.fetch({ - fs, - http, - dir, - url: args.url, - ref: MAIN_REF, - // The hub's git server advertises no `shallow` capability, so a - // depth-limited fetch is rejected outright; fetch the full branch. - singleBranch: true, - tags: false, - headers: { Authorization: `Bearer ${args.token}` }, - }); + const { fs, dir } = await cloneAndFetchMain(args); const oid = await git.resolveRef({ fs, dir, ref: "FETCH_HEAD" }); const { blob } = await git.readBlob({ fs, dir, oid, filepath: args.filepath }); return new TextDecoder().decode(blob); @@ -43,3 +51,29 @@ export async function fetchSourceFile(args: { throw new GitFetchError(cause instanceof Error ? cause.message : String(cause)); } } + +/** Fetches `main` and returns `filepath`'s contents as text, or `""` when + * the branch has no commits yet or the file isn't in it — the two shapes + * a freshly created, still-empty asset takes. Any other failure (auth, + * network, a malformed repo) still throws. */ +export async function fetchSourceFileOrEmpty(args: { + url: string; + token: string; + filepath: string; +}): Promise { + try { + const { fs, dir } = await cloneAndFetchMain(args); + let oid: string; + try { + oid = await git.resolveRef({ fs, dir, ref: "FETCH_HEAD" }); + } catch (cause) { + if (cause instanceof Errors.NotFoundError) return ""; + throw cause; + } + const { blob } = await git.readBlob({ fs, dir, oid, filepath: args.filepath }); + return new TextDecoder().decode(blob); + } catch (cause) { + if (cause instanceof Errors.NotFoundError) return ""; + throw new GitFetchError(cause instanceof Error ? cause.message : String(cause)); + } +} diff --git a/apps/web/src/git-token.ts b/apps/web/src/git-token.ts new file mode 100644 index 000000000..cc91cb133 --- /dev/null +++ b/apps/web/src/git-token.ts @@ -0,0 +1,55 @@ +// Short-lived git tokens scoped to one asset — the mint/use/revoke shape +// every reader and pusher of an asset's smart-HTTP git remote needs. +// Pulled out of `agent-source-read.ts` and `agent-deploy.ts`'s copies so a +// new caller (skill content) mints and revokes through the same helper +// rather than a third copy of this dance. +import { type } from "arktype"; + +export class GitTokenError extends Error {} + +const GitTokenMintShape = type({ id: "string", secret: "string" }); + +async function readErrorBody(response: Response): Promise { + const body: unknown = await response.json().catch(() => undefined); + const envelope = type({ + error: { code: "string", userMessage: "string", refId: "string" }, + })(body); + return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage; +} + +/** Mints a token scoped to `assetId` on `refs/heads/main`, runs `use` with + * its secret, and revokes it afterward whether `use` succeeds or throws. */ +export async function withGitToken(args: { + tenantId: string; + assetId: string; + actions: readonly ("can_read" | "can_push")[]; + lifetimeMs: number; + fetchImpl?: typeof fetch; + use: (token: string) => Promise; +}): Promise { + const fetchImpl = args.fetchImpl ?? fetch; + const tokensPath = `/api/tenants/${encodeURIComponent(args.tenantId)}/git-tokens`; + const minted = await fetchImpl(tokensPath, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: `git-token-${crypto.randomUUID()}`, + resource: `asset:${args.assetId}`, + refPattern: "refs/heads/main", + actions: args.actions, + expiresAt: new Date(Date.now() + args.lifetimeMs).toISOString(), + }), + }); + if (!minted.ok) { + throw new GitTokenError(`minting a git token failed: ${await readErrorBody(minted)}`); + } + const token = GitTokenMintShape(await minted.json()); + if (token instanceof type.errors) { + throw new GitTokenError(`the git token came back an unexpected shape: ${token.summary}`); + } + try { + return await args.use(token.secret); + } finally { + await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { method: "DELETE" }); + } +} diff --git a/apps/web/src/pages/skill-detail-page.tsx b/apps/web/src/pages/skill-detail-page.tsx index 03accecb7..58410c2b4 100644 --- a/apps/web/src/pages/skill-detail-page.tsx +++ b/apps/web/src/pages/skill-detail-page.tsx @@ -1,33 +1,45 @@ // The skill detail page at `/skills/`. // -// this used to be a full editor over `@corbits/skills`' own -// registry — description/body editing with diff review, restore-by- -// version off that asset's git history, a "pinned by" list, and a -// private/shared visibility toggle. That registry (and the workflow -// routes it served) was deleted: skills are native `kind:"skill"` hub -// assets now, and the stock asset routes (`@intx/hub-api`'s -// `routes/assets.ts`) carry only metadata — id, name, displayName, -// creator, timestamps. There is no stock route yet to read a skill's -// SKILL.md content, its version history, who has it pinned, or a -// scope/visibility flag, so none of that can be rendered here without -// vendoring a replacement surface. This page is scoped down to what the -// stock routes actually carry until one exists — see the PR body -// for the gap. -import { PageShell, RichEmptyState, Section, formatRelativeTime } from "@corbits/react-ui"; +// this used to be a full editor over `@corbits/skills`' own registry — +// description/body editing with diff review, restore-by-version off that +// asset's git history, a "pinned by" list, and a private/shared visibility +// toggle. That registry (and the workflow routes it served) was deleted: +// skills are native `kind:"skill"` hub assets now, and the stock asset +// routes (`@intx/hub-api`'s `routes/assets.ts`) carry only metadata — id, +// name, displayName, creator, timestamps. There is still no stock route +// for a skill's version history, pinned-by list, or scope/visibility +// flag, so those stay out of this page. Its `SKILL.md` content, though, +// is readable and writable the same way agent source is: over the +// asset's own smart-HTTP git remote with a short-lived token (see +// `skill-source.ts`). +import { + Button, + PageShell, + RichEmptyState, + Section, + Textarea, + formatRelativeTime, + toast, +} from "@corbits/react-ui"; import { Lightning } from "@/lib/icons"; import { WorkbenchLoadingState } from "@/chat"; import { ApiQueryError, describeApiError } from "@/lib/api-query"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useState, type ReactNode } from "react"; import { tenantKeys } from "../query-client"; import { useBench } from "../bench-context"; import { SKILLS_PATH_PREFIX, skillIdFromPath } from "../path-ids"; import { skillDisplayName } from "../skill-display-name"; +import { readSkillSource, writeSkillSource } from "../skill-source"; import { StageTopBar } from "../shell/stage-top-bar"; import { loadSkill, type SkillSummary } from "../skills-api"; +function errorText(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + type PageState = | { readonly status: "loading" } | { readonly status: "ready"; readonly skill: SkillSummary } @@ -128,20 +140,98 @@ export function SkillDetailPage({

-
-

- This skill's description, instructions, version history, and pinned-by list lived in the - workbench-specific skill registry removed by. They will return once a stock route for - reading and writing skill content exists. -

-
+ , ); } +/** SKILL.md's editor: a read `useQuery`, handed off to a `SkillDraftEditor` + * keyed on the fetched content so its draft state seeds once per value + * read rather than through an effect. */ +function SkillSourceEditor({ + tenantId, + skill, +}: { + readonly tenantId: string; + readonly skill: SkillSummary; +}) { + const queryClient = useQueryClient(); + const sourceKey = [...tenantKeys.skills(tenantId), skill.assetId, "source"] as const; + const source = useQuery({ + queryKey: sourceKey, + queryFn: () => readSkillSource(tenantId, skill.assetId, skill.name), + }); + + const save = useMutation({ + mutationFn: (content: string) => writeSkillSource(tenantId, skill.assetId, skill.name, content), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: sourceKey }); + toast("Saved SKILL.md"); + }, + onError: (cause) => toast(`Couldn't save SKILL.md: ${errorText(cause)}`), + }); + + if (source.isError) { + return ( +
+ } + title="Couldn't load SKILL.md" + description={errorText(source.error)} + actions={[{ label: "Retry", onClick: () => void source.refetch() }]} + /> +
+ ); + } + + return ( +
+ {source.data === undefined ? ( +