diff --git a/.changeset/managed-agent-skills.md b/.changeset/managed-agent-skills.md new file mode 100644 index 0000000000..89eabf40b7 --- /dev/null +++ b/.changeset/managed-agent-skills.md @@ -0,0 +1,7 @@ +--- +"@executor-js/sdk": minor +"@executor-js/plugin-toolkits": minor +"executor": minor +--- + +Manage Agent Skills in Executor. Import packages from GitHub, edit them, review source updates, assign workspace skills to toolkits, choose manual or model invocation, inspect dependencies and revisions, deliver enabled skills through MCP and code execution, and sync them to native agent directories with the CLI. diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index e41a723b89..91f875f8a1 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -91,6 +91,8 @@ import { type ExecutorServerConnection, type ExecutorServerConnectionInput, type ExecutorServerHeaders, + type ManagedSkillId, + type Owner, } from "@executor-js/sdk/shared"; import { decodeAccessTokenClaims, @@ -167,6 +169,11 @@ import { validateCliServerConnectionProfileName, type CliServerConnectionStore, } from "./server-profile"; +import { + defaultAgentSkillsDirectory, + defaultClaudeSkillsDirectory, + materializeSkills, +} from "./skill-materializer"; import { buildResumeContentTemplate, buildDescribeToolCode, @@ -2219,6 +2226,471 @@ const toolsCommand = Command.make("tools").pipe( Command.withDescription("Discover available tools and integrations"), ); +interface CliSkillSummary { + readonly id: ManagedSkillId; + readonly owner: Owner; + readonly name: string | null; + readonly delivery: + | { readonly kind: "blocked" } + | { readonly kind: "disabled" } + | { readonly kind: "enabled"; readonly invocation: "manual" | "model" }; +} + +const selectCliSkill = (skills: readonly CliSkillSummary[], selector: string): CliSkillSummary => { + const byId = skills.find((skill) => skill.id === selector); + if (byId) return byId; + const slash = selector.indexOf("/"); + const owner = slash === -1 ? null : selector.slice(0, slash); + const name = slash === -1 ? selector : selector.slice(slash + 1); + const matches = skills + .filter((skill) => skill.name === name && (owner === null || skill.owner === owner)) + .sort((left, right) => (left.owner === right.owner ? 0 : left.owner === "user" ? -1 : 1)); + const selected = matches[0]; + if (!selected) throw new Error(`Managed skill not found: ${selector}`); + return selected; +}; + +const skillCommandTarget = (input: { + readonly baseUrl: Option.Option; + readonly server: Option.Option; + readonly scope: Option.Option; +}) => + Effect.gen(function* () { + applyScope(input.scope); + const target = serverTargetFromOptions(input); + const connection = yield* resolveExecutorServerConnection(target); + const client = yield* makeApiClient(connection, target); + return { client, target }; + }); + +const skillsListCommand = Command.make( + "list", + { baseUrl: serverBaseUrl, server: serverProfile, scope }, + (options) => + Effect.gen(function* () { + const { client } = yield* skillCommandTarget(options); + const skills = yield* client.skills.list({}); + if (skills.length === 0) { + console.log("No managed skills."); + return; + } + for (const skill of skills) { + const delivery = + skill.delivery.kind === "enabled" + ? `enabled:${skill.delivery.invocation}` + : skill.delivery.kind; + console.log(`${skill.owner}/${skill.name ?? "blocked"}\t${delivery}\t${skill.id}`); + } + }), +).pipe(Command.withDescription("List Executor-managed Agent Skills")); + +const skillsShowCommand = Command.make( + "show", + { + selector: Args.string("selector"), + baseUrl: serverBaseUrl, + server: serverProfile, + scope, + }, + ({ selector, ...options }) => + Effect.gen(function* () { + const { client } = yield* skillCommandTarget(options); + const selected = selectCliSkill(yield* client.skills.list({}), selector); + const skill = yield* client.skills.get({ params: { skillId: selected.id } }); + console.log(JSON.stringify(skill, null, 2)); + }), +).pipe(Command.withDescription("Show one managed skill and its revision history")); + +const skillsAddCommand = Command.make( + "add", + { + source: Args.string("source"), + owner: Options.choice("owner", ["user", "org"] as const).pipe(Options.withDefault("user")), + follow: Options.boolean("follow"), + yes: Options.boolean("yes"), + baseUrl: serverBaseUrl, + server: serverProfile, + scope, + }, + ({ source: sourceInput, owner, follow, yes, ...options }) => + Effect.gen(function* () { + const { client } = yield* skillCommandTarget(options); + const preview = yield* client.skills.discover({ + payload: { source: sourceInput, owner, tracking: follow ? "follow" : "pin" }, + }); + for (const candidate of preview.candidates) { + console.log( + `${candidate.revision.name ?? "blocked"}\t${candidate.revision.description ?? "No description"}\t${candidate.upstreamRevision}`, + ); + } + for (const rejected of preview.rejected) { + console.error(`Rejected ${rejected.directory || "."}: ${rejected.reason}`); + } + if (!yes) { + console.log("Preview only. Run again with --yes to import these candidates."); + return; + } + for (const candidate of preview.candidates) { + const imported = yield* client.skills.importCandidate({ + payload: { candidateId: candidate.id }, + }); + console.log(`Imported ${imported.owner}/${imported.name ?? "blocked"} (${imported.id})`); + } + }), +).pipe(Command.withDescription("Preview and import skills from GitHub or skills.sh")); + +const skillsDeliveryCommand = ( + name: "enable" | "disable", + delivery: "manual" | "model" | "disabled", +) => + Command.make( + name, + { + selector: Args.string("selector"), + yes: Options.boolean("yes"), + baseUrl: serverBaseUrl, + server: serverProfile, + scope, + }, + ({ selector, yes, ...options }) => + Effect.gen(function* () { + if (delivery === "model" && !yes) { + return yield* Effect.fail( + new Error("Model invocation requires explicit confirmation with --yes."), + ); + } + const { client } = yield* skillCommandTarget(options); + const selected = selectCliSkill(yield* client.skills.list({}), selector); + const skill = yield* client.skills.setDelivery({ + params: { skillId: selected.id }, + payload: { + delivery: + delivery === "disabled" + ? { kind: "disabled" } + : { kind: "enabled", invocation: delivery }, + }, + }); + console.log(`${skill.owner}/${skill.name ?? "blocked"}: ${delivery}`); + }), + ); + +const skillsEnableCommand = Command.make( + "enable", + { + selector: Args.string("selector"), + invocation: Options.choice("invocation", ["manual", "model"] as const).pipe( + Options.withDefault("manual"), + ), + yes: Options.boolean("yes"), + baseUrl: serverBaseUrl, + server: serverProfile, + scope, + }, + ({ selector, invocation, yes, ...options }) => + Effect.gen(function* () { + if (invocation === "model" && !yes) { + return yield* Effect.fail( + new Error("Model invocation requires explicit confirmation with --yes."), + ); + } + const { client } = yield* skillCommandTarget(options); + const selected = selectCliSkill(yield* client.skills.list({}), selector); + const skill = yield* client.skills.setDelivery({ + params: { skillId: selected.id }, + payload: { delivery: { kind: "enabled", invocation } }, + }); + console.log(`${skill.owner}/${skill.name ?? "blocked"}: enabled:${invocation}`); + }), +).pipe(Command.withDescription("Enable delivery, with explicit opt-in for model selection")); + +const skillsDisableCommand = skillsDeliveryCommand("disable", "disabled").pipe( + Command.withDescription("Disable delivery while retaining the managed package"), +); + +const skillsDetachCommand = Command.make( + "detach-source", + { + selector: Args.string("selector"), + yes: Options.boolean("yes"), + baseUrl: serverBaseUrl, + server: serverProfile, + scope, + }, + ({ selector, yes, ...options }) => + Effect.gen(function* () { + if (!yes) return yield* Effect.fail(new Error("Source detach requires --yes.")); + const { client } = yield* skillCommandTarget(options); + const selected = selectCliSkill(yield* client.skills.list({}), selector); + yield* client.skills.setSource({ + params: { skillId: selected.id }, + payload: { change: { kind: "detach" } }, + }); + console.log(`Detached source from ${selected.owner}/${selected.name ?? "blocked"}.`); + }), +).pipe(Command.withDescription("Keep the package but stop tracking its source")); + +const skillsCheckCommand = Command.make( + "check", + { + selector: Args.string("selector"), + baseUrl: serverBaseUrl, + server: serverProfile, + scope, + }, + ({ selector, ...options }) => + Effect.gen(function* () { + const { client } = yield* skillCommandTarget(options); + const selected = selectCliSkill(yield* client.skills.list({}), selector); + const result = yield* client.skills.checkSource({ params: { skillId: selected.id } }); + if (result.kind === "noUpdate") { + console.log("No source update available."); + return; + } + if (result.kind === "sourceFailure") { + return yield* Effect.fail(new Error(result.message)); + } + console.log(`Candidate ${result.candidate.id} (${result.candidate.upstreamRevision})`); + for (const change of result.review.changes) { + console.log(`${change.kind}\t${change.conflict ? "conflict" : "clean"}\t${change.path}`); + } + }), +).pipe(Command.withDescription("Check a tracked source and print its file-level diff")); + +const skillsSyncCommand = Command.make( + "sync", + { + selector: Args.string("selector"), + yes: Options.boolean("yes"), + baseUrl: serverBaseUrl, + server: serverProfile, + scope, + }, + ({ selector, yes, ...options }) => + Effect.gen(function* () { + const { client } = yield* skillCommandTarget(options); + const selected = selectCliSkill(yield* client.skills.list({}), selector); + const result = yield* client.skills.checkSource({ params: { skillId: selected.id } }); + if (result.kind === "noUpdate") { + console.log("No source update available."); + return; + } + if (result.kind === "sourceFailure") return yield* Effect.fail(new Error(result.message)); + for (const change of result.review.changes) { + console.log(`${change.kind}\t${change.conflict ? "conflict" : "clean"}\t${change.path}`); + } + if (result.review.conflicts.length > 0) { + return yield* Effect.fail( + new Error(`Resolve conflicts in the dashboard: ${result.review.conflicts.join(", ")}`), + ); + } + if (!yes) { + console.log("Preview only. Run again with --yes to apply this candidate."); + return; + } + const updated = yield* client.skills.applyUpdate({ + params: { skillId: selected.id, candidateId: result.candidate.id }, + payload: { + expectedActiveRevisionId: result.review.expectedActiveRevisionId, + expectedBaselineRevisionId: result.review.expectedBaselineRevisionId, + resolutions: [], + }, + }); + console.log(`Updated ${updated.owner}/${updated.name ?? "blocked"}.`); + }), +).pipe(Command.withDescription("Check, review, and apply a conflict-free source update")); + +const sourceSymbolicReference = (source: { + readonly kind: "github" | "wellKnown" | "mcp" | "local"; + readonly requestedRef?: string; + readonly entryId?: string; + readonly uri?: string; + readonly path?: string; +}): string => + source.kind === "github" + ? (source.requestedRef ?? "main") + : source.kind === "wellKnown" + ? (source.entryId ?? "default") + : source.kind === "mcp" + ? (source.uri ?? "skill") + : (source.path ?? "."); + +const skillsPinCommand = Command.make( + "pin", + { + selector: Args.string("selector"), + baseUrl: serverBaseUrl, + server: serverProfile, + scope, + }, + ({ selector, ...options }) => + Effect.gen(function* () { + const { client } = yield* skillCommandTarget(options); + const selected = selectCliSkill(yield* client.skills.list({}), selector); + const skill = yield* client.skills.get({ params: { skillId: selected.id } }); + if (skill.source.kind !== "imported") + return yield* Effect.fail(new Error("Skill has no source.")); + const upstreamRevision = + skill.source.tracking.kind === "tracked" + ? skill.source.tracking.resolvedRevision + : skill.source.tracking.upstreamRevision; + yield* client.skills.setSource({ + params: { skillId: skill.id }, + payload: { + change: { kind: "setTracking", tracking: { kind: "pinned", upstreamRevision } }, + }, + }); + console.log(`Pinned ${skill.owner}/${skill.name ?? "blocked"} to ${upstreamRevision}.`); + }), +).pipe(Command.withDescription("Pin a source to its current immutable revision")); + +const skillsFollowCommand = Command.make( + "follow", + { + selector: Args.string("selector"), + reference: Options.string("ref").pipe(Options.optional), + baseUrl: serverBaseUrl, + server: serverProfile, + scope, + }, + ({ selector, reference, ...options }) => + Effect.gen(function* () { + const { client } = yield* skillCommandTarget(options); + const selected = selectCliSkill(yield* client.skills.list({}), selector); + const skill = yield* client.skills.get({ params: { skillId: selected.id } }); + if (skill.source.kind !== "imported") + return yield* Effect.fail(new Error("Skill has no source.")); + const resolvedRevision = + skill.source.tracking.kind === "tracked" + ? skill.source.tracking.resolvedRevision + : skill.source.tracking.upstreamRevision; + const symbolicReference = + Option.getOrUndefined(reference) ?? sourceSymbolicReference(skill.source.locator); + yield* client.skills.setSource({ + params: { skillId: skill.id }, + payload: { + change: { + kind: "setTracking", + tracking: { kind: "tracked", symbolicReference, resolvedRevision }, + }, + }, + }); + console.log(`Following ${symbolicReference} for ${skill.owner}/${skill.name ?? "blocked"}.`); + }), +).pipe(Command.withDescription("Follow a symbolic source ref with manual update review")); + +const skillsRemoveCommand = Command.make( + "remove", + { + selector: Args.string("selector"), + yes: Options.boolean("yes"), + baseUrl: serverBaseUrl, + server: serverProfile, + scope, + }, + ({ selector, yes, ...options }) => + Effect.gen(function* () { + if (!yes) return yield* Effect.fail(new Error("Skill removal requires --yes.")); + const { client } = yield* skillCommandTarget(options); + const selected = selectCliSkill(yield* client.skills.list({}), selector); + yield* client.skills.remove({ params: { skillId: selected.id } }); + console.log(`Removed ${selected.owner}/${selected.name ?? "blocked"}.`); + }), +).pipe(Command.withDescription("Remove a managed skill and its revision history")); + +const skillsPullCommand = Command.make( + "pull", + { + directory: Options.string("dir").pipe(Options.optional), + target: Options.choice("target", ["agents", "claude", "all"] as const).pipe( + Options.withDefault("agents"), + ), + force: Options.boolean("force"), + baseUrl: serverBaseUrl, + server: serverProfile, + scope, + }, + ({ directory, target: projectionTarget, force, ...options }) => + Effect.gen(function* () { + const { client, target } = yield* skillCommandTarget(options); + const connection = yield* resolveExecutorServerConnection(target); + const summaries = (yield* client.skills.list({})).filter( + (skill) => skill.delivery.kind === "enabled" && skill.name !== null, + ); + const skills = yield* Effect.forEach(summaries, (summary) => + Effect.gen(function* () { + const detail = yield* client.skills.get({ params: { skillId: summary.id } }); + const exported = yield* client.skills.export({ + params: { skillId: summary.id }, + query: { kind: "portable" }, + }); + if (exported.kind !== "portable") { + return yield* Effect.fail(new Error(`Unexpected backup for ${summary.id}.`)); + } + const revision = detail.revisions.find( + (candidate) => candidate.packageDigest === exported.packageDigest, + ); + if (!revision) { + return yield* Effect.fail(new Error(`Missing active revision for ${summary.id}.`)); + } + const digests = new Map(revision.files.map((file) => [file.path, file.digest])); + return { + id: summary.id, + owner: summary.owner, + name: String(exported.name), + revisionDigest: exported.packageDigest, + files: exported.files.map((file) => ({ + path: file.path, + digest: digests.get(file.path) ?? "", + bytes: Uint8Array.from(Buffer.from(file.bytes, "base64")), + })), + }; + }), + ); + const explicit = Option.getOrUndefined(directory); + const roots = explicit + ? [resolve(explicit)] + : projectionTarget === "all" + ? [defaultAgentSkillsDirectory(), defaultClaudeSkillsDirectory()] + : projectionTarget === "claude" + ? [defaultClaudeSkillsDirectory()] + : [defaultAgentSkillsDirectory()]; + for (const root of roots) { + const result = yield* Effect.tryPromise({ + try: () => + materializeSkills({ + root, + origin: connection.origin, + skills, + force, + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }); + console.log( + `${root}: ${result.added} added, ${result.updated} updated, ${result.removed} removed, ${result.unchanged} unchanged`, + ); + for (const reason of result.skipped) console.error(`Skipped: ${reason}`); + } + }), +).pipe(Command.withDescription("Materialize enabled managed skills into native agent directories")); + +const skillsCommand = Command.make("skills").pipe( + Command.withSubcommands([ + skillsListCommand, + skillsShowCommand, + skillsAddCommand, + skillsEnableCommand, + skillsDisableCommand, + skillsCheckCommand, + skillsSyncCommand, + skillsPinCommand, + skillsFollowCommand, + skillsDetachCommand, + skillsRemoveCommand, + skillsPullCommand, + ] as const), + Command.withDescription("Manage Executor-owned Agent Skills"), +); + const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -3328,6 +3800,7 @@ const root = Command.make("executor").pipe( callCommand, resumeCommand, toolsCommand, + skillsCommand, installCommand, loginCommand, logoutCommand, diff --git a/apps/cli/src/skill-materializer.test.ts b/apps/cli/src/skill-materializer.test.ts new file mode 100644 index 0000000000..5fca92bbeb --- /dev/null +++ b/apps/cli/src/skill-materializer.test.ts @@ -0,0 +1,84 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { ManagedSkillId, SkillPackageDigest } from "@executor-js/sdk/shared"; + +import { materializeSkills } from "./skill-materializer"; + +const bytes = new TextEncoder().encode("managed contents\n"); +const fileDigest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +const skill = { + id: ManagedSkillId.make("skl_test"), + owner: "user" as const, + name: "safe-skill", + revisionDigest: SkillPackageDigest.make("sha256:package"), + files: [{ path: "SKILL.md", digest: fileDigest, bytes }], +}; + +describe("skill materializer", () => { + it("preserves drift unless forced and keeps unknown files", async () => { + const root = await mkdtemp(join(tmpdir(), "executor-skills-")); + try { + const first = await materializeSkills({ + root, + origin: "https://executor.example", + skills: [skill], + force: false, + }); + expect(first.added).toBe(1); + const directory = join(root, skill.name); + await writeFile(join(directory, "SKILL.md"), "local edit\n"); + await writeFile(join(directory, "notes.txt"), "keep me\n"); + + const skipped = await materializeSkills({ + root, + origin: "https://executor.example", + skills: [skill], + force: false, + }); + expect(skipped.skipped).toEqual(["safe-skill has local changes: SKILL.md"]); + expect(await readFile(join(directory, "SKILL.md"), "utf8")).toBe("local edit\n"); + + const forced = await materializeSkills({ + root, + origin: "https://executor.example", + skills: [skill], + force: true, + }); + expect(forced.updated).toBe(1); + expect(await readFile(join(directory, "SKILL.md"), "utf8")).toBe("managed contents\n"); + expect(await readFile(join(directory, "notes.txt"), "utf8")).toBe("keep me\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("removes only unchanged generated files when a skill is no longer enabled", async () => { + const root = await mkdtemp(join(tmpdir(), "executor-skills-")); + try { + await materializeSkills({ + root, + origin: "https://executor.example", + skills: [skill], + force: false, + }); + const directory = join(root, skill.name); + await writeFile(join(directory, "notes.txt"), "keep me\n"); + const removed = await materializeSkills({ + root, + origin: "https://executor.example", + skills: [], + force: false, + }); + expect(removed.removed).toBe(1); + expect(await readFile(join(directory, "notes.txt"), "utf8")).toBe("keep me\n"); + await expect(readFile(join(directory, "SKILL.md"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/skill-materializer.ts b/apps/cli/src/skill-materializer.ts new file mode 100644 index 0000000000..7923aad491 --- /dev/null +++ b/apps/cli/src/skill-materializer.ts @@ -0,0 +1,312 @@ +import { createHash, randomUUID } from "node:crypto"; +import { homedir } from "node:os"; +import { + chmod, + copyFile, + lstat, + mkdir, + open, + readdir, + readFile, + rename, + rmdir, + unlink, +} from "node:fs/promises"; +import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path"; +import { Option, Schema } from "effect"; +import type { ManagedSkillId, Owner, SkillPackageDigest } from "@executor-js/sdk/shared"; + +export const SKILL_MARKER_FILENAME = ".executor-skill.json"; + +const Marker = Schema.Struct({ + version: Schema.Literal(1), + origin: Schema.String, + skillId: Schema.String, + owner: Schema.Literals(["user", "org"]), + name: Schema.String, + revisionDigest: Schema.String, + files: Schema.Record(Schema.String, Schema.String), +}); +const decodeSkillMarker = Schema.decodeUnknownOption(Schema.fromJsonString(Marker)); + +export type SkillMarker = typeof Marker.Type; + +export interface MaterializedSkill { + readonly id: ManagedSkillId; + readonly owner: Owner; + readonly name: string; + readonly revisionDigest: SkillPackageDigest; + readonly files: readonly { + readonly path: string; + readonly digest: string; + readonly bytes: Uint8Array; + }[]; +} + +export interface MaterializeResult { + readonly added: number; + readonly updated: number; + readonly unchanged: number; + readonly removed: number; + readonly skipped: readonly string[]; +} + +export const defaultAgentSkillsDirectory = (): string => join(homedir(), ".agents", "skills"); +export const defaultClaudeSkillsDirectory = (): string => join(homedir(), ".claude", "skills"); + +export const parseSkillMarker = (raw: string): SkillMarker | null => + Option.getOrNull(decodeSkillMarker(raw)); + +const digest = (bytes: Uint8Array): string => + `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + +const assertInside = (root: string, path: string): void => { + const rel = relative(root, path); + if (rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel))) return; + throw new Error(`Skill path escapes its target directory: ${path}`); +}; + +const lstatOrNull = async (path: string) => { + try { + return await lstat(path); + } catch (cause) { + if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT") { + return null; + } + throw cause; + } +}; + +const assertNoSymlinkComponents = async (path: string): Promise => { + const absolute = resolve(path); + const root = parse(absolute).root; + const segments = absolute.slice(root.length).split(sep).filter(Boolean); + let current = root; + for (const segment of segments) { + current = join(current, segment); + const info = await lstatOrNull(current); + if (info?.isSymbolicLink()) throw new Error(`Refusing symlinked skill path: ${current}`); + } +}; + +const walkRegularFiles = async (root: string, current = root): Promise => { + const entries = await readdir(current, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const path = join(current, entry.name); + assertInside(root, path); + const info = await lstat(path); + if (info.isSymbolicLink()) throw new Error(`Refusing symlink inside managed skill: ${path}`); + if (info.isDirectory()) files.push(...(await walkRegularFiles(root, path))); + else if (info.isFile()) files.push(path); + else throw new Error(`Refusing non-regular skill entry: ${path}`); + } + return files; +}; + +const ensureDirectory = async (path: string): Promise => { + await assertNoSymlinkComponents(dirname(path)); + await mkdir(path, { recursive: true, mode: 0o755 }); + await assertNoSymlinkComponents(path); +}; + +const readMarker = async (directory: string): Promise => { + const markerPath = join(directory, SKILL_MARKER_FILENAME); + const info = await lstatOrNull(markerPath); + if (info === null || !info.isFile() || info.isSymbolicLink()) return null; + return parseSkillMarker(await readFile(markerPath, "utf8")); +}; + +const currentDigest = async (path: string): Promise => { + const info = await lstatOrNull(path); + if (info === null) return null; + if (!info.isFile() || info.isSymbolicLink()) + throw new Error(`Refusing unsafe skill file: ${path}`); + return digest(await readFile(path)); +}; + +const removeRegularTree = async (root: string): Promise => { + const files = await walkRegularFiles(root); + for (const file of files) await unlink(file); + const directories: string[] = []; + const collect = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const child = join(directory, entry.name); + directories.push(child); + await collect(child); + } + }; + await collect(root); + for (const directory of directories.sort((a, b) => b.length - a.length)) await rmdir(directory); + await rmdir(root); +}; + +const writeRegularFile = async (path: string, bytes: Uint8Array): Promise => { + await mkdir(dirname(path), { recursive: true, mode: 0o755 }); + const handle = await open(path, "wx", 0o644); + try { + await handle.writeFile(bytes); + } finally { + await handle.close(); + } + await chmod(path, 0o644); +}; + +const materializeOne = async (input: { + readonly root: string; + readonly origin: string; + readonly skill: MaterializedSkill; + readonly force: boolean; +}): Promise<"added" | "updated" | "unchanged" | `skipped:${string}`> => { + const destination = join(input.root, input.skill.name); + assertInside(input.root, destination); + const destinationInfo = await lstatOrNull(destination); + if (destinationInfo?.isSymbolicLink()) return `skipped:${input.skill.name} is a symlink`; + if (destinationInfo !== null && !destinationInfo.isDirectory()) { + return `skipped:${input.skill.name} is not a directory`; + } + const existingMarker = destinationInfo === null ? null : await readMarker(destination); + if (destinationInfo !== null && existingMarker === null) { + return `skipped:${input.skill.name} is not managed by Executor`; + } + if (existingMarker !== null && existingMarker.origin !== input.origin) { + return `skipped:${input.skill.name} belongs to another Executor server`; + } + const desiredFiles = Object.fromEntries( + input.skill.files.map((file) => [file.path, file.digest]), + ); + if ( + existingMarker !== null && + existingMarker.skillId === input.skill.id && + existingMarker.revisionDigest === input.skill.revisionDigest && + Object.entries(desiredFiles).every( + ([path, expected]) => existingMarker.files[path] === expected, + ) + ) { + const unchanged = await Promise.all( + Object.entries(existingMarker.files).map( + async ([path, expected]) => (await currentDigest(join(destination, path))) === expected, + ), + ); + if (unchanged.every(Boolean)) return "unchanged"; + } + if (existingMarker !== null && !input.force) { + const drift = await Promise.all( + Object.entries(existingMarker.files).map(async ([path, expected]) => ({ + path, + changed: (await currentDigest(join(destination, path))) !== expected, + })), + ); + const changed = drift.filter((entry) => entry.changed).map((entry) => entry.path); + if (changed.length > 0) + return `skipped:${input.skill.name} has local changes: ${changed.join(", ")}`; + } + + await ensureDirectory(input.root); + const temporary = join(input.root, `.${input.skill.name}.executor-${randomUUID()}.tmp`); + const backup = join(input.root, `.${input.skill.name}.executor-${randomUUID()}.bak`); + await mkdir(temporary, { mode: 0o755 }); + if (destinationInfo !== null) { + for (const path of await walkRegularFiles(destination)) { + const relativePath = relative(destination, path); + if (relativePath === SKILL_MARKER_FILENAME || desiredFiles[relativePath] !== undefined) + continue; + const target = join(temporary, relativePath); + assertInside(temporary, target); + await mkdir(dirname(target), { recursive: true, mode: 0o755 }); + await copyFile(path, target); + await chmod(target, 0o644); + } + } + for (const file of input.skill.files) { + const target = join(temporary, file.path); + assertInside(temporary, target); + await writeRegularFile(target, file.bytes); + } + const marker: SkillMarker = { + version: 1, + origin: input.origin, + skillId: String(input.skill.id), + owner: input.skill.owner, + name: input.skill.name, + revisionDigest: String(input.skill.revisionDigest), + files: desiredFiles, + }; + await writeRegularFile( + join(temporary, SKILL_MARKER_FILENAME), + new TextEncoder().encode(`${JSON.stringify(marker, null, 2)}\n`), + ); + if (destinationInfo !== null) await rename(destination, backup); + try { + await rename(temporary, destination); + } catch (cause) { + if (destinationInfo !== null) await rename(backup, destination); + throw cause; + } + if (destinationInfo !== null) await removeRegularTree(backup); + return destinationInfo === null ? "added" : "updated"; +}; + +const removeStaleSkill = async (input: { + readonly root: string; + readonly directory: string; + readonly marker: SkillMarker; + readonly origin: string; +}): Promise => { + if (input.marker.origin !== input.origin) return false; + for (const [path, expected] of Object.entries(input.marker.files)) { + const target = join(input.directory, path); + assertInside(input.directory, target); + if ((await currentDigest(target)) === expected) await unlink(target); + } + await unlink(join(input.directory, SKILL_MARKER_FILENAME)); + const directories = (await walkRegularFiles(input.directory)).map(dirname); + for (const directory of [...new Set(directories)].sort((a, b) => b.length - a.length)) { + await rmdir(directory).catch(() => undefined); + } + await rmdir(input.directory).catch(() => undefined); + return true; +}; + +export const materializeSkills = async (input: { + readonly root: string; + readonly origin: string; + readonly skills: readonly MaterializedSkill[]; + readonly force: boolean; +}): Promise => { + const root = resolve(input.root); + await ensureDirectory(root); + const effective = new Map(); + for (const skill of input.skills) { + const current = effective.get(skill.name); + if (!current || (current.owner === "org" && skill.owner === "user")) + effective.set(skill.name, skill); + } + let added = 0; + let updated = 0; + let unchanged = 0; + let removed = 0; + const skipped: string[] = []; + for (const skill of effective.values()) { + const result = await materializeOne({ ...input, root, skill }); + if (result === "added") added += 1; + else if (result === "updated") updated += 1; + else if (result === "unchanged") unchanged += 1; + else skipped.push(result.slice("skipped:".length)); + } + for (const name of await readdir(root)) { + if (effective.has(name) || name.startsWith(".")) continue; + const directory = join(root, name); + const info = await lstat(directory); + if (!info.isDirectory() || info.isSymbolicLink()) continue; + const marker = await readMarker(directory); + if ( + marker !== null && + (await removeStaleSkill({ root, directory, marker, origin: input.origin })) + ) { + removed += 1; + } + } + return { added, updated, unchanged, removed, skipped }; +}; diff --git a/apps/cloud/drizzle/0021_woozy_sprite.sql b/apps/cloud/drizzle/0021_woozy_sprite.sql new file mode 100644 index 0000000000..0b618468df --- /dev/null +++ b/apps/cloud/drizzle/0021_woozy_sprite.sql @@ -0,0 +1,53 @@ +CREATE TABLE "skill" ( + "id" varchar(255) NOT NULL, + "name" varchar(255), + "description" text, + "active_revision_id" varchar(255) NOT NULL, + "delivery" json NOT NULL, + "source" json NOT NULL, + "requirements" json, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "skill_candidate" ( + "id" varchar(255) NOT NULL, + "source" json NOT NULL, + "package_digest" varchar(255) NOT NULL, + "name" varchar(255), + "description" text, + "frontmatter" json, + "files" json NOT NULL, + "diagnostics" json NOT NULL, + "created_at" timestamp NOT NULL, + "expires_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "skill_revision" ( + "id" varchar(255) NOT NULL, + "skill_id" varchar(255) NOT NULL, + "package_digest" varchar(255) NOT NULL, + "name" varchar(255), + "description" text, + "frontmatter" json, + "files" json NOT NULL, + "diagnostics" json NOT NULL, + "created_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "skill_uidx" ON "skill" USING btree ("tenant","owner","subject","id");--> statement-breakpoint +CREATE UNIQUE INDEX "skill_name_uidx" ON "skill" USING btree ("tenant","owner","subject","name");--> statement-breakpoint +CREATE UNIQUE INDEX "skill_candidate_uidx" ON "skill_candidate" USING btree ("tenant","owner","subject","id");--> statement-breakpoint +CREATE UNIQUE INDEX "skill_revision_uidx" ON "skill_revision" USING btree ("tenant","owner","subject","id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0021_snapshot.json b/apps/cloud/drizzle/meta/0021_snapshot.json new file mode 100644 index 0000000000..d4c45967ec --- /dev/null +++ b/apps/cloud/drizzle/meta/0021_snapshot.json @@ -0,0 +1,2174 @@ +{ + "id": "6f1c99a4-c709-47b5-927b-c03b5f0c8bdb", + "prevId": "88f2845b-be28-4ad3-92b2-2cac819478e5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_email_lower_idx": { + "name": "accounts_email_lower_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_tombstones": { + "name": "membership_tombstones", + "schema": "", + "columns": { + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "membership_tombstones_organization_id_idx": { + "name": "membership_tombstones_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "membership_tombstones_account_id_accounts_id_fk": { + "name": "membership_tombstones_account_id_accounts_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "membership_tombstones_organization_id_organizations_id_fk": { + "name": "membership_tombstones_organization_id_organizations_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_membership_id_unique": { + "name": "memberships_membership_id_unique", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_organization_id_idx": { + "name": "memberships_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backfilled_at": { + "name": "backfilled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workos_sync": { + "name": "workos_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "range_start": { + "name": "range_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "drained_at": { + "name": "drained_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_revision_id": { + "name": "active_revision_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "delivery": { + "name": "delivery", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "requirements": { + "name": "requirements", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "skill_uidx": { + "name": "skill_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_name_uidx": { + "name": "skill_name_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_candidate": { + "name": "skill_candidate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "package_digest": { + "name": "package_digest", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frontmatter": { + "name": "frontmatter", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "diagnostics": { + "name": "diagnostics", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "skill_candidate_uidx": { + "name": "skill_candidate_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_revision": { + "name": "skill_revision", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "package_digest": { + "name": "package_digest", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frontmatter": { + "name": "frontmatter", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "diagnostics": { + "name": "diagnostics", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "skill_revision_uidx": { + "name": "skill_revision_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 73842e4d59..1dddbd58c6 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1789575639971, "tag": "0020_workos_sync_drained_at", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1790018092609, + "tag": "0021_woozy_sprite", + "breakpoints": true } ] } diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 0db709b884..572e696e14 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -251,6 +251,83 @@ export const artifact = pgTable( (table) => [uniqueIndex("artifact_uidx").on(table.tenant, table.owner, table.subject, table.id)], ); +export const skill = pgTable( + "skill", + { + id: varchar("id", { length: 255 }).notNull(), + name: varchar("name", { length: 255 }), + description: text("description"), + active_revision_id: varchar("active_revision_id", { length: 255 }).notNull(), + delivery: json("delivery").notNull(), + source: json("source").notNull(), + requirements: json("requirements"), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), + }, + (table) => [ + uniqueIndex("skill_uidx").on(table.tenant, table.owner, table.subject, table.id), + uniqueIndex("skill_name_uidx").on(table.tenant, table.owner, table.subject, table.name), + ], +); + +export const skill_revision = pgTable( + "skill_revision", + { + id: varchar("id", { length: 255 }).notNull(), + skill_id: varchar("skill_id", { length: 255 }).notNull(), + package_digest: varchar("package_digest", { length: 255 }).notNull(), + name: varchar("name", { length: 255 }), + description: text("description"), + frontmatter: json("frontmatter"), + files: json("files").notNull(), + diagnostics: json("diagnostics").notNull(), + created_at: timestamp("created_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), + }, + (table) => [ + uniqueIndex("skill_revision_uidx").on(table.tenant, table.owner, table.subject, table.id), + ], +); + +export const skill_candidate = pgTable( + "skill_candidate", + { + id: varchar("id", { length: 255 }).notNull(), + source: json("source").notNull(), + package_digest: varchar("package_digest", { length: 255 }).notNull(), + name: varchar("name", { length: 255 }), + description: text("description"), + frontmatter: json("frontmatter"), + files: json("files").notNull(), + diagnostics: json("diagnostics").notNull(), + created_at: timestamp("created_at").notNull(), + expires_at: timestamp("expires_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), + }, + (table) => [ + uniqueIndex("skill_candidate_uidx").on(table.tenant, table.owner, table.subject, table.id), + ], +); + export const plugin_storage = pgTable( "plugin_storage", { diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index a7268e2764..b54252f57e 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -37,6 +37,9 @@ import { oauth_client, oauth_session, plugin_storage, + skill, + skill_candidate, + skill_revision, subject, tool, tool_policy, @@ -159,6 +162,56 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { subject: "s", }); + await db.insert(skill_revision).values({ + id: `skr-${tag}`, + skill_id: `skl-${tag}`, + package_digest: `sha256:${tag}`, + name: `skill-${tag}`, + description: "Skill", + frontmatter: { name: `skill-${tag}`, description: "Skill" }, + files: [], + diagnostics: [], + created_at: now, + tenant, + owner: "o", + subject: "s", + }); + await db.insert(skill).values({ + id: `skl-${tag}`, + name: `skill-${tag}`, + description: "Skill", + active_revision_id: `skr-${tag}`, + delivery: { kind: "enabled", invocation: "manual" }, + source: { kind: "authored" }, + created_at: now, + updated_at: now, + tenant, + owner: "o", + subject: "s", + }); + await db.insert(skill_candidate).values({ + id: `skc-${tag}`, + source: { + locator: { + kind: "local", + path: `/skills/${tag}`, + digest: `sha256:${tag}`, + }, + tracking: { kind: "pinned", upstreamRevision: `sha256:${tag}` }, + }, + package_digest: `sha256:${tag}`, + name: `candidate-${tag}`, + description: "Candidate", + frontmatter: { name: `candidate-${tag}`, description: "Candidate" }, + files: [], + diagnostics: [], + created_at: now, + expires_at: new Date(now.getTime() + 30 * 60 * 1000), + tenant, + owner: "o", + subject: "s", + }); + const orgNs = `o:${tenant}/plugin`; const userNs = `u:${tenant}:subject/plugin`; await db.insert(blob).values({ @@ -186,6 +239,9 @@ const TENANT_TABLES = [ plugin_storage, subject, artifact, + skill_revision, + skill_candidate, + skill, ] as const; // Tables that are NOT purged by org id, each with the reason it is exempt. Any diff --git a/apps/cloud/src/db/org-deletion.ts b/apps/cloud/src/db/org-deletion.ts index abcff12f3b..3020761e3a 100644 --- a/apps/cloud/src/db/org-deletion.ts +++ b/apps/cloud/src/db/org-deletion.ts @@ -34,6 +34,9 @@ import { oauth_client, oauth_session, plugin_storage, + skill, + skill_candidate, + skill_revision, subject, tool, tool_policy, @@ -68,6 +71,9 @@ export const purgeOrganizationData = ( await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); await tx.delete(subject).where(eq(subject.tenant, organizationId)); await tx.delete(artifact).where(eq(artifact.tenant, organizationId)); + await tx.delete(skill_candidate).where(eq(skill_candidate.tenant, organizationId)); + await tx.delete(skill_revision).where(eq(skill_revision.tenant, organizationId)); + await tx.delete(skill).where(eq(skill.tenant, organizationId)); // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by // owner: `o:/` (org scope) and `u::/` diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 701166ece7..97a61712d0 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -386,6 +386,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport.update({ + id: '/{-$orgSlug}/skills', + path: '/{-$orgSlug}/skills', + getParentRoute: () => rootRouteImport, + } as any) const SecretsRoute = SecretsRouteImport.update({ id: '/{-$orgSlug}/secrets', path: '/{-$orgSlug}/secrets', @@ -111,6 +122,22 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport.update({ + id: '/new', + path: '/new', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute, + } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteImport.update( + { + id: '/$skillId', + path: '/$skillId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute, + } as any, + ) const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({ id: '/{-$orgSlug}/resume/$executionId', path: '/{-$orgSlug}/resume/$executionId', @@ -154,6 +181,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRouteImport.update( + { + id: '/edit', + path: '/edit', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport.update( { @@ -162,6 +198,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRouteImport.update( + { + id: '/updates/$candidateId', + path: '/updates/$candidateId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute, + } as any, + ) export interface FileRoutesByFullPath { '/create-org': typeof CreateOrgRoute @@ -173,6 +218,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -183,8 +229,12 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/$skillId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute + '/{-$orgSlug}/skills/$skillId/edit': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute } export interface FileRoutesByTo { '/create-org': typeof CreateOrgRoute @@ -196,6 +246,7 @@ export interface FileRoutesByTo { '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -206,8 +257,12 @@ export interface FileRoutesByTo { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/$skillId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute + '/{-$orgSlug}/skills/$skillId/edit': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -220,6 +275,7 @@ export interface FileRoutesById { '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -230,8 +286,12 @@ export interface FileRoutesById { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/$skillId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute + '/{-$orgSlug}/skills/$skillId/edit': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -245,6 +305,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' @@ -255,8 +316,12 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/$skillId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' + | '/{-$orgSlug}/skills/$skillId/edit' + | '/{-$orgSlug}/skills/$skillId/updates/$candidateId' fileRoutesByTo: FileRoutesByTo to: | '/create-org' @@ -268,6 +333,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' @@ -278,8 +344,12 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/$skillId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' + | '/{-$orgSlug}/skills/$skillId/edit' + | '/{-$orgSlug}/skills/$skillId/updates/$candidateId' id: | '__root__' | '/create-org' @@ -291,6 +361,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' @@ -301,8 +372,12 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/$skillId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' + | '/{-$orgSlug}/skills/$skillId/edit' + | '/{-$orgSlug}/skills/$skillId/updates/$candidateId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -315,6 +390,7 @@ export interface RootRouteChildren { OrgRoute: typeof OrgRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute SecretsRoute: typeof SecretsRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -378,6 +454,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/skills': { + id: '/{-$orgSlug}/skills' + path: '/{-$orgSlug}/skills' + fullPath: '/{-$orgSlug}/skills' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/secrets': { id: '/{-$orgSlug}/secrets' path: '/{-$orgSlug}/secrets' @@ -427,6 +510,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute } + '/{-$orgSlug}/skills/new': { + id: '/{-$orgSlug}/skills/new' + path: '/new' + fullPath: '/{-$orgSlug}/skills/new' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute + } + '/{-$orgSlug}/skills/$skillId': { + id: '/{-$orgSlug}/skills/$skillId' + path: '/$skillId' + fullPath: '/{-$orgSlug}/skills/$skillId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute + } '/{-$orgSlug}/resume/$executionId': { id: '/{-$orgSlug}/resume/$executionId' path: '/{-$orgSlug}/resume/$executionId' @@ -469,6 +566,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute } + '/{-$orgSlug}/skills/$skillId/edit': { + id: '/{-$orgSlug}/skills/$skillId/edit' + path: '/edit' + fullPath: '/{-$orgSlug}/skills/$skillId/edit' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute + } '/{-$orgSlug}/integrations/add/$pluginKey': { id: '/{-$orgSlug}/integrations/add/$pluginKey' path: '/{-$orgSlug}/integrations/add/$pluginKey' @@ -476,6 +580,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': { + id: '/{-$orgSlug}/skills/$skillId/updates/$candidateId' + path: '/updates/$candidateId' + fullPath: '/{-$orgSlug}/skills/$skillId/updates/$candidateId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute + } } } @@ -494,6 +605,42 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, ) +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteChildren, + ) + +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -521,6 +668,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, SecretsRoute: SecretsRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 62d8685747..d1df925031 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -168,6 +168,7 @@ export class McpSessionDO extends McpAgentSessionDOBase rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport.update({ + id: '/{-$orgSlug}/skills', + path: '/{-$orgSlug}/skills', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({ id: '/{-$orgSlug}/secrets', @@ -69,6 +80,22 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport.update({ + id: '/new', + path: '/new', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute, + } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteImport.update( + { + id: '/$skillId', + path: '/$skillId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update( { @@ -110,6 +137,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRouteImport.update( + { + id: '/edit', + path: '/edit', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( { @@ -126,11 +162,21 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRouteImport.update( + { + id: '/updates/$candidateId', + path: '/updates/$candidateId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute, + } as any, + ) export interface FileRoutesByFullPath { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute @@ -139,14 +185,19 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/$skillId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillId/edit': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute } export interface FileRoutesByTo { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute @@ -155,15 +206,20 @@ export interface FileRoutesByTo { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/$skillId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillId/edit': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute @@ -172,9 +228,13 @@ export interface FileRoutesById { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/$skillId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillId/edit': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -182,6 +242,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' @@ -190,14 +251,19 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/$skillId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillId/edit' + | '/{-$orgSlug}/skills/$skillId/updates/$candidateId' fileRoutesByTo: FileRoutesByTo to: | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' @@ -206,14 +272,19 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/$skillId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillId/edit' + | '/{-$orgSlug}/skills/$skillId/updates/$candidateId' id: | '__root__' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' @@ -222,15 +293,20 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/$skillId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillId/edit' + | '/{-$orgSlug}/skills/$skillId/updates/$candidateId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute @@ -265,6 +341,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/skills': { + id: '/{-$orgSlug}/skills' + path: '/{-$orgSlug}/skills' + fullPath: '/{-$orgSlug}/skills' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/secrets': { id: '/{-$orgSlug}/secrets' path: '/{-$orgSlug}/secrets' @@ -293,6 +376,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute } + '/{-$orgSlug}/skills/new': { + id: '/{-$orgSlug}/skills/new' + path: '/new' + fullPath: '/{-$orgSlug}/skills/new' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute + } + '/{-$orgSlug}/skills/$skillId': { + id: '/{-$orgSlug}/skills/$skillId' + path: '/$skillId' + fullPath: '/{-$orgSlug}/skills/$skillId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute + } '/{-$orgSlug}/resume/$executionId': { id: '/{-$orgSlug}/resume/$executionId' path: '/{-$orgSlug}/resume/$executionId' @@ -328,6 +425,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute } + '/{-$orgSlug}/skills/$skillId/edit': { + id: '/{-$orgSlug}/skills/$skillId/edit' + path: '/edit' + fullPath: '/{-$orgSlug}/skills/$skillId/edit' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -342,6 +446,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': { + id: '/{-$orgSlug}/skills/$skillId/updates/$candidateId' + path: '/updates/$candidateId' + fullPath: '/{-$orgSlug}/skills/$skillId/updates/$candidateId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute + } } } @@ -360,6 +471,42 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, ) +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteChildren, + ) + +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -382,6 +529,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: diff --git a/apps/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts index 4d09b63152..4301759bc2 100644 --- a/apps/host-selfhost/web/routeTree.gen.ts +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport } from './../../../packages/react/src/routes/skills' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' @@ -20,13 +21,17 @@ import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as AdminRouteImport } from './routes/app/admin' import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport } from './../../../packages/react/src/routes/skills.new' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteImport } from './../../../packages/react/src/routes/skills.$skillId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRouteImport } from './../../../packages/react/src/routes/skills.$skillId.edit' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRouteImport } from './../../../packages/react/src/routes/skills.$skillId.updates.$candidateId' const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport.update({ @@ -52,6 +57,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute = path: '/{-$orgSlug}/toolkits', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport.update({ + id: '/{-$orgSlug}/skills', + path: '/{-$orgSlug}/skills', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({ id: '/{-$orgSlug}/secrets', @@ -94,6 +105,22 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport.update({ + id: '/new', + path: '/new', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute, + } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteImport.update( + { + id: '/$skillId', + path: '/$skillId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update( { @@ -135,6 +162,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRouteImport.update( + { + id: '/edit', + path: '/edit', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( { @@ -151,6 +187,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRouteImport.update( + { + id: '/updates/$candidateId', + path: '/updates/$candidateId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute, + } as any, + ) export interface FileRoutesByFullPath { '/join/$code': typeof JoinDotcodeRoute @@ -159,6 +204,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -168,9 +214,13 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/$skillId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillId/edit': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute } export interface FileRoutesByTo { '/join/$code': typeof JoinDotcodeRoute @@ -179,6 +229,7 @@ export interface FileRoutesByTo { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -188,9 +239,13 @@ export interface FileRoutesByTo { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/$skillId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillId/edit': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -200,6 +255,7 @@ export interface FileRoutesById { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -209,9 +265,13 @@ export interface FileRoutesById { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/$skillId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillId/edit': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -222,6 +282,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' @@ -231,9 +292,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/$skillId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillId/edit' + | '/{-$orgSlug}/skills/$skillId/updates/$candidateId' fileRoutesByTo: FileRoutesByTo to: | '/join/$code' @@ -242,6 +307,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' @@ -251,9 +317,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/$skillId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillId/edit' + | '/{-$orgSlug}/skills/$skillId/updates/$candidateId' id: | '__root__' | '/join/$code' @@ -262,6 +332,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' @@ -271,9 +342,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/$skillId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillId/edit' + | '/{-$orgSlug}/skills/$skillId/updates/$candidateId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -283,6 +358,7 @@ export interface RootRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -325,6 +401,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/skills': { + id: '/{-$orgSlug}/skills' + path: '/{-$orgSlug}/skills' + fullPath: '/{-$orgSlug}/skills' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/secrets': { id: '/{-$orgSlug}/secrets' path: '/{-$orgSlug}/secrets' @@ -374,6 +457,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute } + '/{-$orgSlug}/skills/new': { + id: '/{-$orgSlug}/skills/new' + path: '/new' + fullPath: '/{-$orgSlug}/skills/new' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute + } + '/{-$orgSlug}/skills/$skillId': { + id: '/{-$orgSlug}/skills/$skillId' + path: '/$skillId' + fullPath: '/{-$orgSlug}/skills/$skillId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute + } '/{-$orgSlug}/resume/$executionId': { id: '/{-$orgSlug}/resume/$executionId' path: '/{-$orgSlug}/resume/$executionId' @@ -409,6 +506,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute } + '/{-$orgSlug}/skills/$skillId/edit': { + id: '/{-$orgSlug}/skills/$skillId/edit' + path: '/edit' + fullPath: '/{-$orgSlug}/skills/$skillId/edit' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -423,6 +527,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': { + id: '/{-$orgSlug}/skills/$skillId/updates/$candidateId' + path: '/updates/$candidateId' + fullPath: '/{-$orgSlug}/skills/$skillId/updates/$candidateId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute + } } } @@ -441,6 +552,42 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, ) +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDoteditRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteChildren, + ) + +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillIdRouteWithChildren, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -466,6 +613,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts index f3681e1217..f60c0ec1f4 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -122,6 +122,7 @@ export const createServerHandlers = async (token: string): Promise=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + "@opentelemetry/configuration/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], "@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-transformer": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg=="], @@ -6423,8 +6425,6 @@ "agents/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], - "agents/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "agents/yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], "ahooks/dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], @@ -6549,6 +6549,8 @@ "duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "effect/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "electron-builder/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], @@ -6663,6 +6665,8 @@ "knip/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "knip/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], "macos-version/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], diff --git a/e2e/local/boot-process.test.ts b/e2e/local/boot-process.test.ts index f5c43cca20..855fd7d612 100644 --- a/e2e/local/boot-process.test.ts +++ b/e2e/local/boot-process.test.ts @@ -8,11 +8,24 @@ import { BootReadinessTimeoutError, bootProcesses, isBootReadinessTimeout, + waitForHttp, waitForBoot, } from "../setup/boot"; import { claimAndBoot, isAddrInUse } from "../src/ports"; describe("e2e boot process lifecycle", () => { + it("uses integer probe deadlines accepted by Bun", async () => { + let failure: unknown; + try { + await waitForHttp("http://127.0.0.1:1", { timeoutMs: 10 }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(BootReadinessTimeoutError); + expect((failure as BootReadinessTimeoutError).lastError).not.toBeInstanceOf(RangeError); + }); + it("fails immediately with the boot log when a child exits", async () => { const tempDir = mkdtempSync(join(tmpdir(), "executor-e2e-boot-")); const logFile = join(tempDir, "boot.log"); diff --git a/e2e/scenarios/managed-skills.test.ts b/e2e/scenarios/managed-skills.test.ts new file mode 100644 index 0000000000..0e408cde0f --- /dev/null +++ b/e2e/scenarios/managed-skills.test.ts @@ -0,0 +1,192 @@ +import { expect } from "@effect/vitest"; +import { Effect, Encoding } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([] as const); + +scenario( + "Managed skills · create, inspect requirements, and opt in to model selection", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const { client } = yield* Api; + const browser = yield* Browser; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const executor = yield* client(api, identity); + const created = yield* executor.skills.create({ + payload: { + owner: "org", + package: { + files: [ + { + path: "SKILL.md", + bytes: Encoding.encodeBase64( + new TextEncoder().encode( + "---\nname: release-notes\ndescription: Draft release notes from merged changes.\ndisable-model-invocation: true\n---\n\n# Release notes\n", + ), + ), + }, + { + path: "references/style.md", + bytes: Encoding.encodeBase64( + new TextEncoder().encode("# Style\n\nLead with the user-visible change."), + ), + }, + ], + }, + requirements: [{ kind: "runtime", command: "git", version: null }], + }, + }); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the managed skill", async () => { + await visit(page, `/skills/${created.id}`); + await page.getByRole("heading", { name: "release-notes" }).waitFor(); + expect(await page.getByText("Runtime: git", { exact: true }).isVisible()).toBe(true); + expect(await page.getByText("Not checked", { exact: true }).isVisible()).toBe(true); + await page + .getByRole("button", { name: "Allow model selection help", exact: true }) + .hover(); + await page + .getByRole("tooltip") + .getByText( + "This skill asks agents not to select it automatically. Changing this switch overrides that preference in Executor.", + { exact: true }, + ) + .waitFor(); + }); + + await step("Opt in to model selection", async () => { + await page.getByRole("switch").nth(1).click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().endsWith(`/api/skills/${created.id}/delivery`) && + response.status() === 200, + ), + page + .getByRole("alertdialog") + .getByRole("button", { name: "Allow model selection" }) + .click(), + ]); + await visit(page, page.url()); + await expect.poll(() => page.getByRole("switch").nth(1).isChecked()).toBe(true); + }); + }); + + const session = mcp.session(identity); + const tools = yield* session.describeTools(); + expect(tools.find(({ name }) => name === "skills")?.description).toContain( + "`release-notes`", + ); + + const index = yield* session.call("skills", {}); + expect(index.text).toContain("release-notes"); + expect(index.text).toContain("`execute`"); + + const loaded = yield* session.call("skills", { name: "release-notes", owner: "org" }); + expect(loaded.ok).toBe(true); + expect(loaded.text).toContain("# Release notes"); + expect(loaded.text).not.toContain("disable-model-invocation"); + expect(loaded.text).toContain("references/style.md"); + + const reference = yield* session.call("skills", { + name: "release-notes", + owner: "org", + file: "references/style.md", + }); + expect(reference.ok).toBe(true); + expect(reference.text).toContain("Lead with the user-visible change."); + }), + executor.skills.remove({ params: { skillId: created.id } }).pipe(Effect.orDie), + ); + }), +); + +scenario( + "Managed skills · create stays in sync with the skills and toolkit views", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const { client } = yield* Api; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + const executor = yield* client(api, identity); + + yield* Effect.ensuring( + browser.session(identity, async ({ page, step }) => { + await step("Open skill management for a toolkit with no skills", async () => { + await visit(page, "/toolkits"); + await page.getByRole("button", { name: "Add personal toolkit", exact: true }).click(); + await page.getByLabel("Toolkit name").fill("Feedback toolkit"); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().endsWith("/api/toolkits") && + response.request().method() === "POST" && + response.status() === 200, + ), + page.getByRole("button", { name: "Create toolkit", exact: true }).click(), + ]); + await page.getByRole("link", { name: /Feedback toolkit/ }).click(); + await page.getByRole("button", { name: "Manage skills", exact: true }).click(); + await page.getByText("Add a managed skill before assigning skills").waitFor(); + await page.getByRole("link", { name: "Add skill", exact: true }).click(); + }); + + await step("Create a managed skill and see it without refreshing", async () => { + await page.getByRole("heading", { name: "New skill", exact: true }).waitFor(); + await page + .getByLabel("Contents of SKILL.md") + .fill( + "---\nname: feedback-skill\ndescription: Capture product feedback.\n---\n\n# Feedback skill\n", + ); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().endsWith("/api/skills") && + response.request().method() === "POST" && + response.status() === 200, + ), + page.getByRole("button", { name: "Save skill", exact: true }).click(), + ]); + await page.getByRole("heading", { name: "feedback-skill", exact: true }).waitFor(); + await page + .getByRole("navigation") + .getByRole("link", { name: "Skills", exact: true }) + .click(); + await page.getByText("feedback-skill", { exact: true }).waitFor(); + }); + + await step("See the new skill in toolkit management without refreshing", async () => { + await page.getByRole("link", { name: "Toolkits", exact: true }).click(); + await page.getByRole("link", { name: /Feedback toolkit/ }).click(); + await page.getByRole("button", { name: "Manage skills", exact: true }).click(); + await page.getByText("feedback-skill", { exact: true }).waitFor(); + }); + + await step("Remove the temporary toolkit", async () => { + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await page.getByRole("button", { name: "Delete toolkit", exact: true }).click(); + await page.getByRole("alertdialog").getByRole("button", { name: "Delete" }).click(); + await page.getByRole("heading", { name: "Toolkits", exact: true }).waitFor(); + }); + }), + Effect.gen(function* () { + const skills = yield* executor.skills.list(); + yield* Effect.forEach( + skills.filter((skill) => skill.name === "feedback-skill"), + (skill) => executor.skills.remove({ params: { skillId: skill.id } }), + { discard: true }, + ); + }).pipe(Effect.orDie), + ); + }), +); diff --git a/e2e/scenarios/mcp-passthrough.test.ts b/e2e/scenarios/mcp-passthrough.test.ts index 05b0ce5ae4..fc703d91c7 100644 --- a/e2e/scenarios/mcp-passthrough.test.ts +++ b/e2e/scenarios/mcp-passthrough.test.ts @@ -211,7 +211,7 @@ scenario( expect( await page .getByText( - "Discover connected accounts with integrations and read the guide with skills.", + "Discover connected accounts with integrations, then read Executor guides or managed Agent Skills with skills.", { exact: false }, ) .isVisible(), diff --git a/e2e/setup/boot.ts b/e2e/setup/boot.ts index bec463023d..31106d704e 100644 --- a/e2e/setup/boot.ts +++ b/e2e/setup/boot.ts @@ -191,7 +191,7 @@ export const waitForHttp = async ( while (performance.now() < deadline) { options.signal?.throwIfAborted(); try { - const remainingMs = Math.max(1, deadline - performance.now()); + const remainingMs = Math.max(1, Math.floor(deadline - performance.now())); const probeTimeout = AbortSignal.timeout(Math.min(HTTP_PROBE_TIMEOUT_MS, remainingMs)); const signal = options.signal ? AbortSignal.any([options.signal, probeTimeout]) diff --git a/e2e/setup/selfhost.globalsetup.ts b/e2e/setup/selfhost.globalsetup.ts index 39bd59081f..0b5b250cba 100644 --- a/e2e/setup/selfhost.globalsetup.ts +++ b/e2e/setup/selfhost.globalsetup.ts @@ -47,6 +47,10 @@ export default async function setup(): Promise<(() => Promise) | void> { const procs = await bootSelfhost({ port, webBaseUrl: `http://localhost:${port}`, + // Bun resolves localhost to IPv4 in its fetch client even when the OS + // resolver and Vite choose IPv6. Bind IPv4 explicitly so readiness, + // API clients, and the browser all reach the same listener. + host: "127.0.0.1", admin: SELFHOST_ADMIN, logFile: bootLogFile, sandboxTimeoutMs: E2E_SANDBOX_TIMEOUT_MS, diff --git a/packages/app/src/routeTree.gen.ts b/packages/app/src/routeTree.gen.ts index 649117c693..5e2d834049 100644 --- a/packages/app/src/routeTree.gen.ts +++ b/packages/app/src/routeTree.gen.ts @@ -12,17 +12,22 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as DotDotDotDotDotDotReactSrcRoutesIndexRouteImport } from './../../react/src/routes/index' import { Route as DotDotDotDotDotDotReactSrcRoutesToolsRouteImport } from './../../react/src/routes/tools' import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport } from './../../react/src/routes/toolkits' +import { Route as DotDotDotDotDotDotReactSrcRoutesSkillsRouteImport } from './../../react/src/routes/skills' import { Route as SecretsRouteImport } from './routes/app/secrets' import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './../../react/src/routes/policies' import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts' import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug' +import { Route as DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRouteImport } from './../../react/src/routes/skills.new' +import { Route as DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRouteImport } from './../../react/src/routes/skills.$skillId' import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../react/src/routes/integrations.browse' import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug' import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../react/src/routes/artifacts.$artifactId' +import { Route as DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDoteditRouteImport } from './../../react/src/routes/skills.$skillId.edit' import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$' import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../react/src/routes/integrations.add.$pluginKey' +import { Route as DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRouteImport } from './../../react/src/routes/skills.$skillId.updates.$candidateId' const DotDotDotDotDotDotReactSrcRoutesIndexRoute = DotDotDotDotDotDotReactSrcRoutesIndexRouteImport.update({ @@ -42,6 +47,12 @@ const DotDotDotDotDotDotReactSrcRoutesToolkitsRoute = path: '/{-$orgSlug}/toolkits', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotReactSrcRoutesSkillsRoute = + DotDotDotDotDotDotReactSrcRoutesSkillsRouteImport.update({ + id: '/{-$orgSlug}/skills', + path: '/{-$orgSlug}/skills', + getParentRoute: () => rootRouteImport, + } as any) const SecretsRoute = SecretsRouteImport.update({ id: '/{-$orgSlug}/secrets', path: '/{-$orgSlug}/secrets', @@ -65,6 +76,18 @@ const DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute = path: '/$toolkitSlug', getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesToolkitsRoute, } as any) +const DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute = + DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRouteImport.update({ + id: '/new', + path: '/new', + getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesSkillsRoute, + } as any) +const DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRoute = + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRouteImport.update({ + id: '/$skillId', + path: '/$skillId', + getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesSkillsRoute, + } as any) const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute = DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport.update({ id: '/{-$orgSlug}/resume/$executionId', @@ -95,6 +118,12 @@ const DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute = path: '/$artifactId', getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesArtifactsRoute, } as any) +const DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDoteditRoute = + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDoteditRouteImport.update({ + id: '/edit', + path: '/edit', + getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRoute, + } as any) const DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update({ id: '/{-$orgSlug}/plugins/$pluginId/$', @@ -109,11 +138,21 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute = getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute = + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRouteImport.update( + { + id: '/updates/$candidateId', + path: '/updates/$candidateId', + getParentRoute: () => + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRoute, + } as any, + ) export interface FileRoutesByFullPath { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute @@ -122,14 +161,19 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/$skillId': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRouteWithChildren + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillId/edit': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDoteditRoute + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute } export interface FileRoutesByTo { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute @@ -138,15 +182,20 @@ export interface FileRoutesByTo { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/$skillId': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRouteWithChildren + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillId/edit': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDoteditRoute + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute @@ -155,9 +204,13 @@ export interface FileRoutesById { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/$skillId': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRouteWithChildren + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillId/edit': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDoteditRoute + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -165,6 +218,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' @@ -173,14 +227,19 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/$skillId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillId/edit' + | '/{-$orgSlug}/skills/$skillId/updates/$candidateId' fileRoutesByTo: FileRoutesByTo to: | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' @@ -189,14 +248,19 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/$skillId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillId/edit' + | '/{-$orgSlug}/skills/$skillId/updates/$candidateId' id: | '__root__' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' @@ -205,15 +269,20 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/$skillId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillId/edit' + | '/{-$orgSlug}/skills/$skillId/updates/$candidateId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { DotDotDotDotDotDotReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute SecretsRoute: typeof SecretsRoute + DotDotDotDotDotDotReactSrcRoutesSkillsRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute @@ -248,6 +317,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/skills': { + id: '/{-$orgSlug}/skills' + path: '/{-$orgSlug}/skills' + fullPath: '/{-$orgSlug}/skills' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/secrets': { id: '/{-$orgSlug}/secrets' path: '/{-$orgSlug}/secrets' @@ -276,6 +352,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRoute } + '/{-$orgSlug}/skills/new': { + id: '/{-$orgSlug}/skills/new' + path: '/new' + fullPath: '/{-$orgSlug}/skills/new' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRouteImport + parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsRoute + } + '/{-$orgSlug}/skills/$skillId': { + id: '/{-$orgSlug}/skills/$skillId' + path: '/$skillId' + fullPath: '/{-$orgSlug}/skills/$skillId' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRouteImport + parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsRoute + } '/{-$orgSlug}/resume/$executionId': { id: '/{-$orgSlug}/resume/$executionId' path: '/{-$orgSlug}/resume/$executionId' @@ -311,6 +401,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRoute } + '/{-$orgSlug}/skills/$skillId/edit': { + id: '/{-$orgSlug}/skills/$skillId/edit' + path: '/edit' + fullPath: '/{-$orgSlug}/skills/$skillId/edit' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDoteditRouteImport + parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -325,6 +422,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/skills/$skillId/updates/$candidateId': { + id: '/{-$orgSlug}/skills/$skillId/updates/$candidateId' + path: '/updates/$candidateId' + fullPath: '/{-$orgSlug}/skills/$skillId/updates/$candidateId' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRouteImport + parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRoute + } } } @@ -343,6 +447,42 @@ const DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren = DotDotDotDotDotDotReactSrcRoutesArtifactsRouteChildren, ) +interface DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRouteChildren { + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDoteditRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDoteditRoute + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute +} + +const DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRouteChildren: DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRouteChildren = + { + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDoteditRoute: + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDoteditRoute, + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute: + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdDotupdatesDotcandidateIdRoute, + } + +const DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRouteWithChildren = + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRoute._addFileChildren( + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRouteChildren, + ) + +interface DotDotDotDotDotDotReactSrcRoutesSkillsRouteChildren { + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRouteWithChildren + DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute +} + +const DotDotDotDotDotDotReactSrcRoutesSkillsRouteChildren: DotDotDotDotDotDotReactSrcRoutesSkillsRouteChildren = + { + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRoute: + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillIdRouteWithChildren, + DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute: + DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute, + } + +const DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren = + DotDotDotDotDotDotReactSrcRoutesSkillsRoute._addFileChildren( + DotDotDotDotDotDotReactSrcRoutesSkillsRouteChildren, + ) + interface DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -364,6 +504,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotReactSrcRoutesPoliciesRoute, SecretsRoute: SecretsRoute, + DotDotDotDotDotDotReactSrcRoutesSkillsRoute: + DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren, DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren, DotDotDotDotDotDotReactSrcRoutesToolsRoute: diff --git a/packages/core/api/package.json b/packages/core/api/package.json index 31c1beca31..52ee770003 100644 --- a/packages/core/api/package.json +++ b/packages/core/api/package.json @@ -17,6 +17,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", "@executor-js/sdk": "workspace:*", + "@zip.js/zip.js": "^2.8.26", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/core/api/src/account/org-slug.test.ts b/packages/core/api/src/account/org-slug.test.ts index 28e5a1d6f6..e27be7e4c8 100644 --- a/packages/core/api/src/account/org-slug.test.ts +++ b/packages/core/api/src/account/org-slug.test.ts @@ -81,6 +81,7 @@ describe("isValidOrgSlug", () => { "users", "toolkits", "secrets", + "skills", "tools", "resume", "plugins", diff --git a/packages/core/api/src/account/org-slug.ts b/packages/core/api/src/account/org-slug.ts index addd842831..29deec44c9 100644 --- a/packages/core/api/src/account/org-slug.ts +++ b/packages/core/api/src/account/org-slug.ts @@ -47,6 +47,7 @@ export const RESERVED_ORG_SLUGS: ReadonlySet = new Set([ "integrations", "policies", "secrets", + "skills", "tools", "toolkits", "artifacts", diff --git a/packages/core/api/src/api.ts b/packages/core/api/src/api.ts index 4bbe145e23..6f2ef28aec 100644 --- a/packages/core/api/src/api.ts +++ b/packages/core/api/src/api.ts @@ -9,6 +9,7 @@ import { ExecutionsApi } from "./executions/api"; import { OAuthApi } from "./oauth/api"; import { PoliciesApi } from "./policies/api"; import { ArtifactsApi } from "./artifacts/api"; +import { SkillsApi } from "./skills/api"; export const CoreExecutorApi = HttpApi.make("executor") .add(ToolsApi) @@ -19,6 +20,7 @@ export const CoreExecutorApi = HttpApi.make("executor") .add(OAuthApi) .add(PoliciesApi) .add(ArtifactsApi) + .add(SkillsApi) .annotateMerge( OpenApi.annotations({ title: "Executor API", diff --git a/packages/core/api/src/handlers/index.ts b/packages/core/api/src/handlers/index.ts index 360952bd7d..03879431e2 100644 --- a/packages/core/api/src/handlers/index.ts +++ b/packages/core/api/src/handlers/index.ts @@ -8,6 +8,7 @@ import { ExecutionsHandlers } from "./executions"; import { OAuthHandlers } from "./oauth"; import { PoliciesHandlers } from "./policies"; import { ArtifactsHandlers } from "./artifacts"; +import { SkillsHandlers } from "./skills"; export { ToolsHandlers } from "./tools"; export { IntegrationsHandlers } from "./integrations"; @@ -17,6 +18,7 @@ export { ExecutionsHandlers } from "./executions"; export { OAuthHandlers } from "./oauth"; export { PoliciesHandlers } from "./policies"; export { ArtifactsHandlers } from "./artifacts"; +export { SkillsHandlers } from "./skills"; export const CoreHandlers = Layer.mergeAll( ToolsHandlers, @@ -27,4 +29,5 @@ export const CoreHandlers = Layer.mergeAll( OAuthHandlers, PoliciesHandlers, ArtifactsHandlers, + SkillsHandlers, ); diff --git a/packages/core/api/src/handlers/skills.ts b/packages/core/api/src/handlers/skills.ts new file mode 100644 index 0000000000..5c2e9ecee8 --- /dev/null +++ b/packages/core/api/src/handlers/skills.ts @@ -0,0 +1,372 @@ +import { Effect, Encoding, Result } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { FetchHttpClient } from "effect/unstable/http"; +import { + SkillPackageRejectedError, + type ManagedSkill, + type ManagedSkillSummary, + type SkillCandidate, + type SkillRevision, + type SkillUpdateConflictResolution, +} from "@executor-js/sdk"; + +import { ExecutorApi } from "../api"; +import { capture } from "../observability"; +import { ExecutorService } from "../services"; +import { discoverGitHubSkills } from "../skills/github"; + +const revisionToResponse = (revision: SkillRevision) => ({ + ...revision, + name: revision.name === null ? null : String(revision.name), + createdAt: revision.createdAt.getTime(), +}); + +const summaryToResponse = (skill: ManagedSkillSummary) => ({ + ...skill, + name: skill.name === null ? null : String(skill.name), + createdAt: skill.createdAt.getTime(), + updatedAt: skill.updatedAt.getTime(), +}); + +const skillToResponse = (skill: ManagedSkill) => ({ + ...summaryToResponse(skill), + revisions: skill.revisions.map(revisionToResponse), +}); + +const candidateToResponse = (candidate: SkillCandidate) => ({ + ...candidate, + revision: { + ...candidate.revision, + name: candidate.revision.name === null ? null : String(candidate.revision.name), + }, + createdAt: candidate.createdAt.getTime(), + expiresAt: candidate.expiresAt.getTime(), +}); + +const decodePackage = (input: { + readonly files: readonly { + readonly path: string; + readonly mediaType?: string; + readonly bytes: string; + }[]; +}) => + Effect.forEach(input.files, (file) => { + const decoded = Encoding.decodeBase64(file.bytes); + return Result.isSuccess(decoded) + ? Effect.succeed({ path: file.path, mediaType: file.mediaType, bytes: decoded.success }) + : Effect.fail( + new SkillPackageRejectedError({ + diagnostics: [ + { + severity: "blocking", + code: "file_base64_invalid", + message: `File "${file.path}" is not valid base64.`, + path: file.path, + }, + ], + }), + ); + }); + +const decodeConflictResolution = (resolution: { + readonly path: string; + readonly choice: "local" | "upstream" | "custom"; + readonly bytes?: string; + readonly mediaType?: string; +}): Effect.Effect => { + if (resolution.choice !== "custom") { + return Effect.succeed({ path: resolution.path, choice: resolution.choice }); + } + const decoded = Encoding.decodeBase64(resolution.bytes ?? ""); + return Result.isSuccess(decoded) + ? Effect.succeed({ + path: resolution.path, + choice: "custom", + bytes: decoded.success, + mediaType: resolution.mediaType, + }) + : Effect.fail( + new SkillPackageRejectedError({ + diagnostics: [ + { + severity: "blocking", + code: "file_base64_invalid", + message: `Conflict resolution for "${resolution.path}" is not valid base64.`, + path: resolution.path, + }, + ], + }), + ); +}; + +export const SkillsHandlers = HttpApiBuilder.group(ExecutorApi, "skills", (handlers) => + handlers + .handle("list", () => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return (yield* executor.skills.list()).map(summaryToResponse); + }), + ), + ) + .handle("get", ({ params }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return skillToResponse(yield* executor.skills.get({ skillId: params.skillId })); + }), + ), + ) + .handle("readFile", ({ params, query }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const file = yield* executor.skills.readFile({ + skillId: params.skillId, + revisionId: query.revisionId, + path: query.path, + }); + return { manifest: file.manifest, bytes: Encoding.encodeBase64(file.bytes) }; + }), + ), + ) + .handle("create", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const files = yield* decodePackage(payload.package); + return skillToResponse( + yield* executor.skills.create({ + owner: payload.owner, + package: { files }, + delivery: payload.delivery, + requirements: payload.requirements, + }), + ); + }), + ), + ) + .handle("discover", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const result = yield* discoverGitHubSkills(executor, { + input: payload.source, + owner: payload.owner, + tracking: payload.tracking, + }).pipe(Effect.provide(FetchHttpClient.layer)); + return { ...result, candidates: result.candidates.map(candidateToResponse) }; + }), + ), + ) + .handle("importCandidate", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return skillToResponse(yield* executor.skills.importCandidate(payload)); + }), + ), + ) + .handle("edit", ({ params, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const files = yield* decodePackage(payload.package); + return skillToResponse( + yield* executor.skills.edit({ + skillId: params.skillId, + expectedActiveRevisionId: payload.expectedActiveRevisionId, + package: { files }, + }), + ); + }), + ), + ) + .handle("setDelivery", ({ params, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return skillToResponse( + yield* executor.skills.setDelivery({ + skillId: params.skillId, + delivery: payload.delivery, + }), + ); + }), + ), + ) + .handle("setSource", ({ params, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return skillToResponse( + yield* executor.skills.setSource({ + skillId: params.skillId, + change: payload.change, + }), + ); + }), + ), + ) + .handle("setRequirements", ({ params, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return skillToResponse( + yield* executor.skills.setRequirements({ + skillId: params.skillId, + requirements: payload.requirements, + }), + ); + }), + ), + ) + .handle("checkSource", ({ params }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const skill = yield* executor.skills.get({ skillId: params.skillId }); + if (skill.source.kind !== "imported" || skill.source.tracking.kind === "pinned") { + return { kind: "noUpdate" as const }; + } + if (skill.source.locator.kind !== "github") { + return { + kind: "sourceFailure" as const, + message: "This source must be checked by its owning adapter.", + }; + } + const [repositoryOwner, repository] = skill.source.locator.repository.split("/"); + if (!repositoryOwner || !repository) { + return { + kind: "sourceFailure" as const, + message: "The stored GitHub repository locator is invalid.", + }; + } + const result = yield* discoverGitHubSkills(executor, { + owner: skill.owner, + tracking: "follow", + resolvedInput: { + owner: repositoryOwner, + repository, + requestedRef: skill.source.tracking.symbolicReference, + directory: skill.source.locator.directory, + selectedSkills: [], + }, + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.catchTag("SkillSourceUnavailableError", (error) => + Effect.succeed({ sourceFailure: error.message } as const), + ), + ); + if ("sourceFailure" in result) { + return { kind: "sourceFailure" as const, message: result.sourceFailure }; + } + const candidate = result.candidates[0]; + if (!candidate) { + return { + kind: "sourceFailure" as const, + message: result.rejected[0]?.reason ?? "The source no longer contains this skill.", + }; + } + const review = yield* executor.skills.reviewCandidate({ + skillId: skill.id, + candidateId: candidate.id, + }); + return review.changes.length === 0 + ? { kind: "noUpdate" as const } + : { + kind: "updateAvailable" as const, + candidate: candidateToResponse(candidate), + review, + }; + }), + ), + ) + .handle("reviewUpdate", ({ params }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.skills.reviewCandidate({ + skillId: params.skillId, + candidateId: params.candidateId, + }); + }), + ), + ) + .handle("applyUpdate", ({ params, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const resolutions = yield* Effect.forEach(payload.resolutions, decodeConflictResolution); + return skillToResponse( + yield* executor.skills.applyCandidate({ + skillId: params.skillId, + candidateId: params.candidateId, + expectedActiveRevisionId: payload.expectedActiveRevisionId, + expectedBaselineRevisionId: payload.expectedBaselineRevisionId, + resolutions, + }), + ); + }), + ), + ) + .handle("restoreRevision", ({ params, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return skillToResponse( + yield* executor.skills.restoreRevision({ + skillId: params.skillId, + revisionId: params.revisionId, + expectedActiveRevisionId: payload.expectedActiveRevisionId, + }), + ); + }), + ), + ) + .handle("export", ({ params, query }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const exported = yield* executor.skills.export({ + skillId: params.skillId, + revisionId: query.revisionId, + kind: query.kind, + }); + if (exported.kind === "portable") { + return { + kind: exported.kind, + revisionId: exported.revisionId, + packageDigest: exported.packageDigest, + name: String(exported.name), + files: exported.files.map((file) => ({ + ...file, + bytes: Encoding.encodeBase64(file.bytes), + })), + }; + } + return { + kind: exported.kind, + skill: skillToResponse(exported.skill), + revision: revisionToResponse(exported.revision), + revisionId: exported.revision.id, + packageDigest: exported.revision.packageDigest, + name: exported.revision.name === null ? null : String(exported.revision.name), + files: exported.files.map((file) => ({ + ...file, + bytes: Encoding.encodeBase64(file.bytes), + })), + }; + }), + ), + ) + .handle("remove", ({ params }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.skills.remove({ skillId: params.skillId }); + return { removed: true }; + }), + ), + ), +); diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index 2b7f8ea2e1..ba0a9e43c2 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -38,6 +38,16 @@ export { } from "./oauth-popup"; export { PoliciesApi } from "./policies/api"; export { ArtifactsApi } from "./artifacts/api"; +export { + SkillsApi, + SkillPackageFilePayload, + SkillPackagePayload, + SkillRevisionResponse, + ManagedSkillSummaryResponse, + ManagedSkillResponse, + ManagedSkillFileResponse, + ManagedSkillExportResponse, +} from "./skills/api"; export { AccountApi, AccountHttpApi, diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 3891f6cae3..60cc3c364e 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -73,6 +73,7 @@ export const makeMcpBuildServer = connections: executor.connections, tools: executor.tools, integrations: executor.integrations, + skills: executor.skills, ...(hostOptions?.loadAppShellHtml ? { loadAppShellHtml: hostOptions.loadAppShellHtml } : {}), diff --git a/packages/core/api/src/skills/api.ts b/packages/core/api/src/skills/api.ts new file mode 100644 index 0000000000..7bfe78ae48 --- /dev/null +++ b/packages/core/api/src/skills/api.ts @@ -0,0 +1,375 @@ +import { Schema } from "effect"; +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { + InternalError, + ManagedSkillId, + ManagedSkillNotFoundError, + OrgWriteDeniedError, + Owner, + PortableSkillExportRejectedError, + SkillDelivery, + SkillCandidateId, + SkillCandidateExpiredError, + SkillCandidateNotFoundError, + SkillCandidateMismatchError, + SkillDiagnostic, + SkillInvalidTransitionError, + SkillPackageDigest, + SkillPackageManifestFile, + SkillPackageRejectedError, + SkillRevisionConflictError, + SkillRevisionId, + SkillRevisionNotFoundError, + SkillRequirement, + SkillRequirementStatus, + SkillSource, + SkillSourceUnavailableError, + SkillTracking, + SkillUpdateConflictError, + SkillUpdateFileChange, + StagedSkillSource, +} from "@executor-js/sdk/shared"; + +export const SkillPackageFilePayload = Schema.Struct({ + path: Schema.String, + mediaType: Schema.optional(Schema.String), + bytes: Schema.String, +}); + +export const SkillPackagePayload = Schema.Struct({ + files: Schema.Array(SkillPackageFilePayload), +}); + +const SkillWriteDelivery = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("disabled") }), + Schema.Struct({ + kind: Schema.Literal("enabled"), + invocation: Schema.Literals(["manual", "model"]), + }), +]); + +export const SkillRevisionResponse = Schema.Struct({ + id: SkillRevisionId, + packageDigest: SkillPackageDigest, + name: Schema.NullOr(Schema.String), + description: Schema.NullOr(Schema.String), + frontmatter: Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown)), + files: Schema.Array(SkillPackageManifestFile), + diagnostics: Schema.Array(SkillDiagnostic), + createdAt: Schema.Number, +}); + +export const ManagedSkillSummaryResponse = Schema.Struct({ + id: ManagedSkillId, + owner: Owner, + name: Schema.NullOr(Schema.String), + description: Schema.NullOr(Schema.String), + activeRevisionId: SkillRevisionId, + delivery: SkillDelivery, + source: SkillSource, + requirements: Schema.Array(SkillRequirement), + requirementStatuses: Schema.Array(SkillRequirementStatus), + createdAt: Schema.Number, + updatedAt: Schema.Number, +}); + +export const ManagedSkillResponse = Schema.Struct({ + ...ManagedSkillSummaryResponse.fields, + revisions: Schema.Array(SkillRevisionResponse), +}); + +export const ManagedSkillFileResponse = Schema.Struct({ + manifest: SkillPackageManifestFile, + bytes: Schema.String, +}); + +export const SkillCandidateResponse = Schema.Struct({ + id: SkillCandidateId, + owner: Owner, + source: StagedSkillSource, + upstreamRevision: Schema.String, + revision: Schema.Struct({ + packageDigest: SkillPackageDigest, + name: Schema.NullOr(Schema.String), + description: Schema.NullOr(Schema.String), + frontmatter: Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown)), + files: Schema.Array(SkillPackageManifestFile), + diagnostics: Schema.Array(SkillDiagnostic), + }), + createdAt: Schema.Number, + expiresAt: Schema.Number, +}); + +export const SkillUpdateReviewResponse = Schema.Struct({ + skillId: ManagedSkillId, + candidateId: SkillCandidateId, + expectedActiveRevisionId: SkillRevisionId, + expectedBaselineRevisionId: SkillRevisionId, + changes: Schema.Array(SkillUpdateFileChange), + conflicts: Schema.Array(Schema.String), +}); + +const ManagedSkillExportFiles = Schema.Array( + Schema.Struct({ path: Schema.String, mediaType: Schema.String, bytes: Schema.String }), +); + +export const ManagedSkillExportResponse = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("portable"), + revisionId: SkillRevisionId, + packageDigest: SkillPackageDigest, + name: Schema.String, + files: ManagedSkillExportFiles, + }), + Schema.Struct({ + kind: Schema.Literal("backup"), + skill: ManagedSkillResponse, + revision: SkillRevisionResponse, + revisionId: SkillRevisionId, + packageDigest: SkillPackageDigest, + name: Schema.NullOr(Schema.String), + files: ManagedSkillExportFiles, + }), +]); + +const SkillParams = { skillId: ManagedSkillId }; +const SkillRevisionParams = { skillId: ManagedSkillId, revisionId: SkillRevisionId }; +const SkillPackageErrors = [InternalError, SkillPackageRejectedError, OrgWriteDeniedError]; +const SkillMutationErrors = [ + InternalError, + ManagedSkillNotFoundError, + SkillPackageRejectedError, + SkillRevisionConflictError, + OrgWriteDeniedError, +]; + +export const SkillsApi = HttpApiGroup.make("skills") + .add( + HttpApiEndpoint.get("list", "/skills", { + success: Schema.Array(ManagedSkillSummaryResponse), + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.get("get", "/skills/:skillId", { + params: SkillParams, + success: ManagedSkillResponse, + error: [InternalError, ManagedSkillNotFoundError], + }), + ) + .add( + HttpApiEndpoint.get("readFile", "/skills/:skillId/files", { + params: SkillParams, + query: Schema.Struct({ + path: Schema.String, + revisionId: Schema.optional(SkillRevisionId), + }), + success: ManagedSkillFileResponse, + error: [InternalError, ManagedSkillNotFoundError, SkillRevisionNotFoundError], + }), + ) + .add( + HttpApiEndpoint.post("create", "/skills", { + payload: Schema.Struct({ + owner: Owner, + package: SkillPackagePayload, + delivery: Schema.optional(SkillWriteDelivery), + requirements: Schema.optional(Schema.Array(SkillRequirement)), + }), + success: ManagedSkillResponse, + error: SkillPackageErrors, + }), + ) + .add( + HttpApiEndpoint.post("discover", "/skills/discover", { + payload: Schema.Struct({ + source: Schema.String, + owner: Owner, + tracking: Schema.Literals(["pin", "follow"]), + }), + success: Schema.Struct({ + candidates: Schema.Array(SkillCandidateResponse), + rejected: Schema.Array(Schema.Struct({ directory: Schema.String, reason: Schema.String })), + truncated: Schema.Boolean, + }), + error: [ + InternalError, + SkillSourceUnavailableError, + SkillPackageRejectedError, + OrgWriteDeniedError, + ], + }), + ) + .add( + HttpApiEndpoint.post("importCandidate", "/skills/import", { + payload: Schema.Struct({ + candidateId: SkillCandidateId, + delivery: Schema.optional(SkillWriteDelivery), + }), + success: ManagedSkillResponse, + error: [ + InternalError, + SkillCandidateNotFoundError, + SkillCandidateExpiredError, + OrgWriteDeniedError, + ], + }), + ) + .add( + HttpApiEndpoint.put("edit", "/skills/:skillId/package", { + params: SkillParams, + payload: Schema.Struct({ + expectedActiveRevisionId: SkillRevisionId, + package: SkillPackagePayload, + }), + success: ManagedSkillResponse, + error: SkillMutationErrors, + }), + ) + .add( + HttpApiEndpoint.put("setDelivery", "/skills/:skillId/delivery", { + params: SkillParams, + payload: Schema.Struct({ delivery: SkillWriteDelivery }), + success: ManagedSkillResponse, + error: [ + InternalError, + ManagedSkillNotFoundError, + SkillInvalidTransitionError, + OrgWriteDeniedError, + ], + }), + ) + .add( + HttpApiEndpoint.put("setSource", "/skills/:skillId/source", { + params: SkillParams, + payload: Schema.Struct({ + change: Schema.Union([ + Schema.Struct({ kind: Schema.Literal("detach") }), + Schema.Struct({ kind: Schema.Literal("setTracking"), tracking: SkillTracking }), + ]), + }), + success: ManagedSkillResponse, + error: [ + InternalError, + ManagedSkillNotFoundError, + SkillInvalidTransitionError, + OrgWriteDeniedError, + ], + }), + ) + .add( + HttpApiEndpoint.put("setRequirements", "/skills/:skillId/requirements", { + params: SkillParams, + payload: Schema.Struct({ requirements: Schema.Array(SkillRequirement) }), + success: ManagedSkillResponse, + error: [InternalError, ManagedSkillNotFoundError, OrgWriteDeniedError], + }), + ) + .add( + HttpApiEndpoint.post("checkSource", "/skills/:skillId/source/check", { + params: SkillParams, + success: Schema.Union([ + Schema.Struct({ kind: Schema.Literal("noUpdate") }), + Schema.Struct({ + kind: Schema.Literal("updateAvailable"), + candidate: SkillCandidateResponse, + review: SkillUpdateReviewResponse, + }), + Schema.Struct({ kind: Schema.Literal("sourceFailure"), message: Schema.String }), + ]), + error: [ + InternalError, + ManagedSkillNotFoundError, + SkillCandidateNotFoundError, + SkillCandidateExpiredError, + SkillCandidateMismatchError, + SkillPackageRejectedError, + OrgWriteDeniedError, + ], + }), + ) + .add( + HttpApiEndpoint.get("reviewUpdate", "/skills/:skillId/updates/:candidateId", { + params: { skillId: ManagedSkillId, candidateId: SkillCandidateId }, + success: SkillUpdateReviewResponse, + error: [ + InternalError, + ManagedSkillNotFoundError, + SkillCandidateNotFoundError, + SkillCandidateExpiredError, + SkillCandidateMismatchError, + ], + }), + ) + .add( + HttpApiEndpoint.post("applyUpdate", "/skills/:skillId/updates/:candidateId/apply", { + params: { skillId: ManagedSkillId, candidateId: SkillCandidateId }, + payload: Schema.Struct({ + expectedActiveRevisionId: SkillRevisionId, + expectedBaselineRevisionId: SkillRevisionId, + resolutions: Schema.Array( + Schema.Union([ + Schema.Struct({ + path: Schema.String, + choice: Schema.Literals(["local", "upstream"]), + }), + Schema.Struct({ + path: Schema.String, + choice: Schema.Literal("custom"), + bytes: Schema.String, + mediaType: Schema.optional(Schema.String), + }), + ]), + ), + }), + success: ManagedSkillResponse, + error: [ + InternalError, + ManagedSkillNotFoundError, + SkillCandidateNotFoundError, + SkillCandidateExpiredError, + SkillCandidateMismatchError, + SkillRevisionConflictError, + SkillUpdateConflictError, + SkillPackageRejectedError, + OrgWriteDeniedError, + ], + }), + ) + .add( + HttpApiEndpoint.post("restoreRevision", "/skills/:skillId/revisions/:revisionId/restore", { + params: SkillRevisionParams, + payload: Schema.Struct({ expectedActiveRevisionId: SkillRevisionId }), + success: ManagedSkillResponse, + error: [ + InternalError, + ManagedSkillNotFoundError, + SkillRevisionNotFoundError, + SkillRevisionConflictError, + OrgWriteDeniedError, + ], + }), + ) + .add( + HttpApiEndpoint.get("export", "/skills/:skillId/export", { + params: SkillParams, + query: Schema.Struct({ + kind: Schema.Literals(["portable", "backup"]), + revisionId: Schema.optional(SkillRevisionId), + }), + success: ManagedSkillExportResponse, + error: [ + InternalError, + ManagedSkillNotFoundError, + SkillRevisionNotFoundError, + PortableSkillExportRejectedError, + ], + }), + ) + .add( + HttpApiEndpoint.delete("remove", "/skills/:skillId", { + params: SkillParams, + success: Schema.Struct({ removed: Schema.Boolean }), + error: [InternalError, ManagedSkillNotFoundError, OrgWriteDeniedError], + }), + ); diff --git a/packages/core/api/src/skills/github.test.ts b/packages/core/api/src/skills/github.test.ts new file mode 100644 index 0000000000..f9f7d9f60f --- /dev/null +++ b/packages/core/api/src/skills/github.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { makeTestExecutor } from "@executor-js/sdk/testing"; + +import { discoverGitHubSkills } from "./github"; + +const commit = "0123456789abcdef0123456789abcdef01234567"; + +const skillArchive = Effect.promise(async () => { + const writer = new ZipWriter(new Uint8ArrayWriter(), { useWebWorkers: false }); + await writer.add( + `skills-${commit}/skills/example/SKILL.md`, + new TextReader( + "---\nname: example\ndescription: Exercise the archive fallback.\n---\n\n# Example\n", + ), + ); + return writer.close(); +}); + +const discoverWithExhaustedApi = (input: string, requestedRef: string) => + Effect.gen(function* () { + const archive = yield* skillArchive; + const http = HttpClient.make((request) => { + if (request.url.startsWith("https://api.github.com/")) { + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("rate limited", { status: 403 })), + ); + } + if (request.url === `https://github.com/example/skills/commits/${requestedRef}.atom`) { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(`tag:github.com,2008:Grit::Commit/${commit}`, { status: 200 }), + ), + ); + } + if (request.url === `https://github.com/example/skills/archive/${commit}.zip`) { + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(archive, { status: 200 })), + ); + } + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("not found", { status: 404 })), + ); + }); + const executor = yield* makeTestExecutor(); + + return yield* discoverGitHubSkills(executor, { + input, + owner: "user", + tracking: "follow", + }).pipe(Effect.provide(Layer.succeed(HttpClient.HttpClient)(http))); + }); + +const expectArchiveCandidate = (requestedRef: string) => + Effect.gen(function* () { + const input = + requestedRef === "HEAD" + ? "https://github.com/example/skills" + : `https://github.com/example/skills/tree/${requestedRef}/skills`; + const result = yield* discoverWithExhaustedApi(input, requestedRef); + + expect(result.candidates).toHaveLength(1); + expect(result.candidates[0]?.revision.name).toBe("example"); + expect(result.candidates[0]?.source).toMatchObject({ + locator: { requestedRef, resolvedCommit: commit }, + tracking: { symbolicReference: requestedRef, resolvedRevision: commit }, + }); + }); + +describe("discoverGitHubSkills", () => { + it.effect("falls back for a repository URL when the GitHub API limit is exhausted", () => + expectArchiveCandidate("HEAD"), + ); + + it.effect("falls back for a tree URL when the GitHub API limit is exhausted", () => + expectArchiveCandidate("main"), + ); +}); diff --git a/packages/core/api/src/skills/github.ts b/packages/core/api/src/skills/github.ts new file mode 100644 index 0000000000..5f9e4395e7 --- /dev/null +++ b/packages/core/api/src/skills/github.ts @@ -0,0 +1,377 @@ +import { Data, Duration, Effect, Option, Predicate, Schema } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { Uint8ArrayReader, ZipReader, type FileEntry } from "@zip.js/zip.js"; +import { + isSafeSkillFilePath, + OrgWriteDeniedError, + parseGitHubSkillInput, + SKILL_MAX_FILES, + SKILL_MAX_FILE_BYTES, + SKILL_MAX_TOTAL_BYTES, + SkillSourceUnavailableError, + SkillPackageRejectedError, + type Executor, + type GitHubSkillInput, + type Owner, + type SkillCandidate, + type StorageFailure, +} from "@executor-js/sdk"; + +const GitHubRepository = Schema.Struct({ default_branch: Schema.String }); +const GitHubCommit = Schema.Struct({ sha: Schema.String }); +const GitHubTree = Schema.Struct({ + truncated: Schema.optional(Schema.Boolean), + tree: Schema.Array( + Schema.Struct({ + path: Schema.String, + type: Schema.String, + size: Schema.optional(Schema.Number), + }), + ), +}); + +const apiRoot = "https://api.github.com"; +const githubRoot = "https://github.com"; +const rawRoot = "https://raw.githubusercontent.com"; +const maxCandidates = 50; +const maxArchiveBytes = 25 * 1024 * 1024; +const maxArchiveEntries = 20_000; + +class GitHubRateLimitError extends Data.TaggedError("GitHubRateLimitError") {} + +const sourceFailure = (message: string) => new SkillSourceUnavailableError({ message }); + +const decodeRepository = Schema.decodeUnknownEffect(GitHubRepository); +const decodeCommit = Schema.decodeUnknownEffect(GitHubCommit); +const decodeTree = Schema.decodeUnknownEffect(GitHubTree); + +const request = (url: string) => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient; + return yield* http + .execute( + HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeader("accept", "application/vnd.github+json"), + HttpClientRequest.setHeader("user-agent", "executor-managed-skills"), + ), + ) + .pipe( + Effect.timeout(Duration.seconds(20)), + Effect.mapError(() => sourceFailure("GitHub could not be reached.")), + ); + }); + +const successful = (status: number, context: string) => + status === 404 + ? Effect.fail(sourceFailure(`${context} was not found on GitHub.`)) + : status === 403 || status === 429 + ? Effect.fail(new GitHubRateLimitError()) + : status >= 400 + ? Effect.fail(sourceFailure(`GitHub returned HTTP ${status} while reading ${context}.`)) + : Effect.void; + +const successfulPublicRequest = (status: number, context: string) => + status === 404 + ? Effect.fail(sourceFailure(`${context} was not found on GitHub.`)) + : status >= 400 + ? Effect.fail(sourceFailure(`GitHub returned HTTP ${status} while reading ${context}.`)) + : Effect.void; + +const skillDirectories = (paths: readonly string[], root: string): readonly string[] => { + const prefix = root === "" ? "" : `${root}/`; + const directories = new Set(); + for (const path of paths) { + if (!path.startsWith(prefix)) continue; + if (path === "SKILL.md" || path.endsWith("/SKILL.md")) { + directories.add(path === "SKILL.md" ? "" : path.slice(0, -"/SKILL.md".length)); + } + } + return [...directories].sort( + (left, right) => left.split("/").length - right.split("/").length || left.localeCompare(right), + ); +}; + +export interface DiscoverGitHubSkillsInput { + readonly input?: string; + readonly resolvedInput?: GitHubSkillInput; + readonly owner: Owner; + readonly tracking: "pin" | "follow"; +} + +export interface DiscoverGitHubSkillsResult { + readonly candidates: readonly SkillCandidate[]; + readonly rejected: readonly { readonly directory: string; readonly reason: string }[]; + readonly truncated: boolean; +} + +interface RepositoryBlob { + readonly size: number; + readonly read: Effect.Effect; +} + +const stageCandidates = ( + executor: Executor, + input: DiscoverGitHubSkillsInput, + source: GitHubSkillInput, + requestedRef: string, + commit: string, + blobs: ReadonlyMap, +): Effect.Effect< + DiscoverGitHubSkillsResult, + SkillSourceUnavailableError | SkillPackageRejectedError | OrgWriteDeniedError | StorageFailure, + HttpClient.HttpClient +> => + Effect.gen(function* () { + const repositoryLabel = `${source.owner}/${source.repository}`; + const directories = skillDirectories([...blobs.keys()], source.directory); + if (directories.length === 0) { + return yield* sourceFailure(`No SKILL.md was found under ${repositoryLabel}.`); + } + const selected = directories.slice(0, maxCandidates); + const wanted = new Set(source.selectedSkills); + const rejected: Array<{ readonly directory: string; readonly reason: string }> = []; + const candidates = yield* Effect.forEach( + selected, + (directory) => + Effect.gen(function* () { + const basename = directory.slice(directory.lastIndexOf("/") + 1); + if (wanted.size > 0 && !wanted.has(basename)) return null; + const prefix = directory === "" ? "" : `${directory}/`; + const nested = directories.filter( + (other) => other !== directory && other.startsWith(prefix), + ); + const files = [...blobs.entries()] + .filter(([path]) => path.startsWith(prefix)) + .map(([fullPath, blob]) => ({ + fullPath, + path: fullPath.slice(prefix.length), + blob, + })) + .filter( + (file) => + isSafeSkillFilePath(file.path) && + !file.path.split("/").some((segment) => segment.startsWith(".")) && + !nested.some((child) => file.path.startsWith(`${child.slice(prefix.length)}/`)), + ); + const totalBytes = files.reduce((total, file) => total + file.blob.size, 0); + if ( + files.length > SKILL_MAX_FILES || + files.some((file) => file.blob.size > SKILL_MAX_FILE_BYTES) || + totalBytes > SKILL_MAX_TOTAL_BYTES + ) { + rejected.push({ directory, reason: "The package exceeds Executor's size limits." }); + return null; + } + const packageFiles = yield* Effect.forEach( + files, + (file) => file.blob.read.pipe(Effect.map((bytes) => ({ path: file.path, bytes }))), + { concurrency: 6 }, + ); + return yield* executor.skills.stageCandidate({ + owner: input.owner, + package: { files: packageFiles }, + source: { + locator: { + kind: "github", + repository: repositoryLabel, + directory, + requestedRef, + resolvedCommit: commit, + }, + tracking: + input.tracking === "follow" + ? { + kind: "tracked", + symbolicReference: requestedRef, + resolvedRevision: commit, + } + : { kind: "pinned", upstreamRevision: commit }, + }, + }); + }).pipe( + Effect.catchTag("SkillPackageRejectedError", (error) => { + rejected.push({ + directory, + reason: error.diagnostics[0]?.message ?? "The package could not be read.", + }); + return Effect.succeed(null); + }), + ), + { concurrency: 3 }, + ); + return { + candidates: candidates.filter(Predicate.isNotNull), + rejected, + truncated: directories.length > maxCandidates, + }; + }); + +const parseCommitFeed = (body: string): Effect.Effect => { + const commit = /Grit::Commit\/([0-9a-f]{40})/.exec(body)?.[1]; + return commit === undefined + ? Effect.fail(sourceFailure("GitHub returned an invalid commit feed.")) + : Effect.succeed(commit); +}; + +const readArchiveEntry = ( + entry: FileEntry, + path: string, +): Effect.Effect => + Effect.tryPromise({ + try: () => entry.arrayBuffer({ useWebWorkers: false }), + catch: () => sourceFailure(`GitHub could not read ${path}.`), + }).pipe(Effect.map((buffer) => new Uint8Array(buffer))); + +const discoverFromArchive = ( + executor: Executor, + input: DiscoverGitHubSkillsInput, + source: GitHubSkillInput, +): Effect.Effect< + DiscoverGitHubSkillsResult, + SkillSourceUnavailableError | SkillPackageRejectedError | OrgWriteDeniedError | StorageFailure, + HttpClient.HttpClient +> => + Effect.gen(function* () { + const repositoryLabel = `${source.owner}/${source.repository}`; + const requestedRef = source.requestedRef ?? "HEAD"; + const feedResponse = yield* request( + `${githubRoot}/${repositoryLabel}/commits/${encodeURIComponent(requestedRef)}.atom`, + ); + yield* successfulPublicRequest(feedResponse.status, `${repositoryLabel}@${requestedRef}`); + const commit = yield* parseCommitFeed( + yield* feedResponse.text.pipe( + Effect.mapError(() => sourceFailure("GitHub returned an invalid commit feed.")), + ), + ); + const archiveResponse = yield* request( + `${githubRoot}/${repositoryLabel}/archive/${encodeURIComponent(commit)}.zip`, + ); + yield* successfulPublicRequest(archiveResponse.status, `${repositoryLabel}@${commit}`); + const archive = new Uint8Array( + yield* archiveResponse.arrayBuffer.pipe( + Effect.mapError(() => sourceFailure("GitHub could not read the repository archive.")), + ), + ); + if (archive.byteLength > maxArchiveBytes) { + return yield* sourceFailure("The GitHub repository archive is too large to import safely."); + } + const reader = new ZipReader(new Uint8ArrayReader(archive), { useWebWorkers: false }); + return yield* Effect.acquireUseRelease( + Effect.succeed(reader), + (openReader) => + Effect.gen(function* () { + const entries = yield* Effect.tryPromise({ + try: () => openReader.getEntries(), + catch: () => sourceFailure("GitHub returned an invalid repository archive."), + }); + if (entries.length > maxArchiveEntries) { + return yield* sourceFailure( + "The GitHub repository archive has too many entries to import safely.", + ); + } + const root = entries[0]?.filename.split("/")[0]; + if (root === undefined || root === "") { + return yield* sourceFailure("GitHub returned an invalid repository archive."); + } + const prefix = `${root}/`; + const blobs = new Map(); + for (const entry of entries) { + if (entry.directory || !entry.filename.startsWith(prefix)) continue; + const path = entry.filename.slice(prefix.length); + if (path === "") continue; + blobs.set(path, { + size: entry.uncompressedSize, + read: readArchiveEntry(entry, path), + }); + } + return yield* stageCandidates(executor, input, source, requestedRef, commit, blobs); + }), + (openReader) => Effect.promise(() => openReader.close()), + ); + }); + +export const discoverGitHubSkills = ( + executor: Executor, + input: DiscoverGitHubSkillsInput, +): Effect.Effect< + DiscoverGitHubSkillsResult, + SkillSourceUnavailableError | SkillPackageRejectedError | OrgWriteDeniedError | StorageFailure, + HttpClient.HttpClient +> => + Effect.gen(function* () { + const parsed = + input.resolvedInput === undefined + ? parseGitHubSkillInput(input.input ?? "") + : Option.some(input.resolvedInput); + if (Option.isNone(parsed)) + return yield* sourceFailure( + "Enter a GitHub repository, GitHub URL, skills.sh URL, or skills install command.", + ); + const source = parsed.value; + const discoverFromApi = Effect.gen(function* () { + const repositoryLabel = `${source.owner}/${source.repository}`; + const requestedRef = yield* source.requestedRef === null + ? Effect.gen(function* () { + const response = yield* request(`${apiRoot}/repos/${repositoryLabel}`); + yield* successful(response.status, repositoryLabel); + const body = yield* response.json.pipe( + Effect.mapError(() => sourceFailure("GitHub returned invalid JSON.")), + ); + return (yield* decodeRepository(body).pipe( + Effect.mapError(() => + sourceFailure("GitHub returned an invalid repository response."), + ), + )).default_branch; + }) + : Effect.succeed(source.requestedRef); + const commitResponse = yield* request( + `${apiRoot}/repos/${repositoryLabel}/commits/${encodeURIComponent(requestedRef)}`, + ); + yield* successful(commitResponse.status, `${repositoryLabel}@${requestedRef}`); + const commit = yield* decodeCommit( + yield* commitResponse.json.pipe( + Effect.mapError(() => sourceFailure("GitHub returned invalid JSON.")), + ), + ).pipe(Effect.mapError(() => sourceFailure("GitHub returned an invalid commit response."))); + const treeResponse = yield* request( + `${apiRoot}/repos/${repositoryLabel}/git/trees/${encodeURIComponent(commit.sha)}?recursive=1`, + ); + yield* successful(treeResponse.status, `${repositoryLabel}@${commit.sha}`); + const tree = yield* decodeTree( + yield* treeResponse.json.pipe( + Effect.mapError(() => sourceFailure("GitHub returned invalid JSON.")), + ), + ).pipe(Effect.mapError(() => sourceFailure("GitHub returned an invalid repository tree."))); + if (tree.truncated === true) { + return yield* sourceFailure("The GitHub repository tree is too large to import safely."); + } + const blobs = new Map( + tree.tree + .filter((entry) => entry.type === "blob") + .map((entry) => { + const encodedPath = entry.path.split("/").map(encodeURIComponent).join("/"); + return [ + entry.path, + { + size: entry.size ?? 0, + read: Effect.gen(function* () { + const response = yield* request( + `${rawRoot}/${repositoryLabel}/${encodeURIComponent(commit.sha)}/${encodedPath}`, + ); + yield* successfulPublicRequest(response.status, entry.path); + return new Uint8Array( + yield* response.arrayBuffer.pipe( + Effect.mapError(() => sourceFailure(`GitHub could not read ${entry.path}.`)), + ), + ); + }), + }, + ]; + }), + ); + return yield* stageCandidates(executor, input, source, requestedRef, commit.sha, blobs); + }); + return yield* discoverFromApi.pipe( + Effect.catchTag("GitHubRateLimitError", () => discoverFromArchive(executor, input, source)), + ); + }); diff --git a/packages/core/execution/src/engine.ts b/packages/core/execution/src/engine.ts index dad73e2025..20bba03f0e 100644 --- a/packages/core/execution/src/engine.ts +++ b/packages/core/execution/src/engine.ts @@ -1,4 +1,4 @@ -import { Deferred, Effect, Fiber, Predicate, Queue, Ref } from "effect"; +import { Deferred, Effect, Encoding, Fiber, Predicate, Queue, Ref } from "effect"; import type * as Cause from "effect/Cause"; import * as Exit from "effect/Exit"; @@ -348,6 +348,138 @@ const makeFullInvoker = ( const base = makeExecutorToolInvoker(executor, { invokeOptions, onConnectedToolCall }); return { invoke: ({ path, args }) => { + if (path === "skills.search") { + if (!isRecord(args)) { + return Effect.fail( + new ExecutionToolError({ + message: + "skills.search expects an object: { query?: string; limit?: number; offset?: number }", + }), + ); + } + if (args.query !== undefined && typeof args.query !== "string") { + return Effect.fail( + new ExecutionToolError({ + message: "skills.search query must be a string when provided", + }), + ); + } + const limit = readOptionalLimit(args.limit, "skills.search"); + if (Predicate.isTagged(limit, "ExecutionToolError")) return Effect.fail(limit); + const offset = readOptionalOffset(args.offset, "skills.search"); + if (Predicate.isTagged(offset, "ExecutionToolError")) return Effect.fail(offset); + const query = (args.query ?? "").trim().toLocaleLowerCase("en-US"); + return executor.skills.list().pipe( + Effect.map((skills) => { + const eligible = skills + .filter( + (skill) => + skill.delivery.kind === "enabled" && skill.delivery.invocation === "model", + ) + .filter((skill) => + query === "" + ? true + : `${skill.name ?? ""}\n${skill.description ?? ""}` + .toLocaleLowerCase("en-US") + .includes(query), + ) + .sort((left, right) => { + const byName = (left.name ?? "").localeCompare(right.name ?? ""); + if (byName !== 0) return byName; + if (left.owner !== right.owner) return left.owner === "user" ? -1 : 1; + return String(left.id).localeCompare(String(right.id)); + }); + const selected = eligible.slice(offset, offset + Math.min(limit, 50)); + const nextOffset = + offset + selected.length < eligible.length ? offset + selected.length : null; + return { + items: selected.map((skill) => ({ + ref: skill.id, + name: skill.name, + description: skill.description, + owner: skill.owner, + invocation: "model" as const, + revision: skill.activeRevisionId, + })), + total: eligible.length, + hasMore: nextOffset !== null, + nextOffset, + diagnostics: [], + }; + }), + ); + } + if (path === "skills.get") { + if (!isRecord(args)) { + return Effect.fail( + new ExecutionToolError({ + message: "skills.get expects an object with exactly one of ref or name", + }), + ); + } + const ref = args.ref; + const name = args.name; + const owner = args.owner; + const filePath = args.path ?? "SKILL.md"; + if ( + (typeof ref !== "string" && typeof name !== "string") || + (ref !== undefined && name !== undefined) || + (owner !== undefined && owner !== "user" && owner !== "org") || + typeof filePath !== "string" + ) { + return Effect.fail( + new ExecutionToolError({ + message: "skills.get expects { ref, path? } or { name, owner?, path? }", + }), + ); + } + return Effect.gen(function* () { + const skills = yield* executor.skills.list(); + const selected = skills + .filter((skill) => skill.delivery.kind === "enabled") + .filter((skill) => + typeof ref === "string" + ? String(skill.id) === ref + : skill.name === name && (owner === undefined || skill.owner === owner), + ) + .sort((left, right) => { + if (left.owner !== right.owner) return left.owner === "user" ? -1 : 1; + return String(left.id).localeCompare(String(right.id)); + })[0]; + if (!selected) { + return yield* new ExecutionToolError({ message: "No enabled managed skill matched" }); + } + const detail = yield* executor.skills.get({ skillId: selected.id }); + const revision = detail.revisions.find((item) => item.id === detail.activeRevisionId); + const manifest = revision?.files.find((file) => file.path === filePath); + if (!revision || !manifest) { + return yield* new ExecutionToolError({ + message: `The active skill package has no file named "${filePath}"`, + }); + } + const file = yield* executor.skills.readFile({ + skillId: detail.id, + revisionId: revision.id, + path: filePath, + }); + const textual = + manifest.mediaType.startsWith("text/") || + manifest.mediaType.includes("json") || + manifest.mediaType.includes("yaml") || + manifest.mediaType.includes("xml") || + manifest.mediaType.includes("javascript"); + return textual + ? new TextDecoder().decode(file.bytes) + : { + ref: detail.id, + revision: revision.packageDigest, + path: filePath, + mediaType: manifest.mediaType, + encoding: "base64" as const, + bytes: Encoding.encodeBase64(file.bytes), + }; + }); + } if (path === "search") { if (!isRecord(args)) { return Effect.fail( diff --git a/packages/core/execution/src/skills.test.ts b/packages/core/execution/src/skills.test.ts index 49a1229cef..7a7e592b2e 100644 --- a/packages/core/execution/src/skills.test.ts +++ b/packages/core/execution/src/skills.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { EXECUTE_SKILL, SKILLS, findSkill, renderSkillsIndex, skillCatalogFor } from "./skills"; -describe("skills registry", () => { +describe("guides registry", () => { it("includes the execute skill with the full how-to body", () => { expect(SKILLS).toContain(EXECUTE_SKILL); // The workflow + rules that the execute description used to inline now live diff --git a/packages/core/execution/src/skills.ts b/packages/core/execution/src/skills.ts index 9bf8f43ef0..9ded04d7fb 100644 --- a/packages/core/execution/src/skills.ts +++ b/packages/core/execution/src/skills.ts @@ -39,9 +39,14 @@ const EXECUTE_SKILL_BODY = [ "5. For live saved-connection inventory, call `tools.executor.coreTools.connections.list({})`; after checking `result.ok`, read `result.data.connections`.", "6. Call the tool: `const result = await tools.(input);`", "", + "## Agent Skills", + "", + "Use `skills.search({ query, limit, offset })` to discover managed instructions that explicitly allow model selection. Use `skills.get({ ref })` to load their SKILL.md, or `skills.get({ ref, path })` for a bundled file. When the user names a manual skill, read it directly with its ref or qualified name instead of searching for it.", + "", "## Rules", "", "- `tools.search()` returns paginated, ranked matches: `{ items, total, hasMore, nextOffset }`. Best-first. Use short intent phrases like `github issues`, `repo details`, or `create calendar event`.", + "- `skills` is a separate lazy proxy for instructions. It is not enumerable, and managed skills never appear in `tools.search()`.", '- When you already know the namespace, narrow with `tools.search({ namespace: "github", query: "issues" })`.', "- `tools.executor.coreTools.connections.list({})` returns saved connections with `{ address, integration, owner, name, ... }`. The `address` field includes the leading `tools.` root.", "- Tool calls return a value union: `{ ok: true, data }` for success or `{ ok: false, error: { code, message, status?, details?, retryable? } }` for expected tool/domain failures. Branch on `result.ok`.", @@ -686,7 +691,7 @@ export const findSkill = (name: string, catalog: readonly Skill[] = SKILLS): Ski export const renderSkillsIndex = (catalog: readonly Skill[] = SKILLS): string => [ "How-to docs for Executor's own tools — this is the complete list, and there is nothing else to fetch.", - 'Fetch one with `skills({ name: "" })`. Names outside this list, file paths, and skills belonging to your harness or the user are not served here.', + 'Fetch one with `skills({ name: "" })`. Built-in names take precedence over managed skills with the same name.', "", ...catalog.map((skill) => `- \`${skill.name}\` — ${skill.summary}`), ].join("\n"); diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index a2aa222d17..6050c4c994 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -711,6 +711,58 @@ describe("tool discovery", () => { }), ); + it.effect("discovers model skills lazily and reads manual skills by exact reference", () => + Effect.gen(function* () { + const executor = yield* makeSearchExecutor(); + const manual = yield* executor.skills.create({ + owner: "user", + package: { + files: [ + { + path: "SKILL.md", + bytes: new TextEncoder().encode( + "---\nname: manual-skill\ndescription: User-selected instructions.\ndisable-model-invocation: true\n---\n\n# Manual\n", + ), + }, + ], + }, + }); + yield* executor.skills.create({ + owner: "org", + package: { + files: [ + { + path: "SKILL.md", + bytes: new TextEncoder().encode( + "---\nname: model-skill\ndescription: Automatically discoverable instructions.\n---\n\n# Model\n", + ), + }, + ], + }, + delivery: { kind: "enabled", invocation: "model" }, + }); + const engine = createExecutionEngine({ executor, codeExecutor }); + + const search = yield* engine.execute('return await skills.search({ query: "skill" });', { + onElicitation: acceptAll, + }); + expect(search.error).toBeUndefined(); + expect(search.result).toEqual( + expect.objectContaining({ + items: [expect.objectContaining({ name: "model-skill" })], + total: 1, + }), + ); + + const read = yield* engine.execute( + `return await skills.get({ ref: ${JSON.stringify(String(manual.id))} });`, + { onElicitation: acceptAll }, + ); + expect(read.error).toBeUndefined(); + expect(read.result).toContain("# Manual"); + }), + ); + it.effect("lets execution hosts provide custom tool discovery", () => Effect.gen(function* () { const executor = yield* makeSearchExecutor(); diff --git a/packages/core/sdk/package.json b/packages/core/sdk/package.json index bf4374d6e7..54868cf4f8 100644 --- a/packages/core/sdk/package.json +++ b/packages/core/sdk/package.json @@ -104,7 +104,8 @@ "@standard-schema/spec": "^1.1.0", "fractional-indexing": "^3.2.0", "oauth4webapi": "^3.8.5", - "tldts": "^7.0.28" + "tldts": "^7.0.28", + "yaml": "^2.8.3" }, "devDependencies": { "@effect/atom-react": "catalog:", diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 8014584695..549c4dfd5f 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -128,6 +128,56 @@ const ownedExecutorTable = ( const defineTables = >(tables: TTables): TTables => tables; +const skillTable = ownedExecutorTable( + "skill", + { + id: keyColumn("id"), + name: nullableKeyColumn("name"), + description: nullableTextColumn("description"), + active_revision_id: keyColumn("active_revision_id"), + delivery: jsonColumn("delivery"), + source: jsonColumn("source"), + requirements: nullableJsonColumn("requirements"), + created_at: dateColumn("created_at"), + updated_at: dateColumn("updated_at"), + }, + ["tenant", "owner", "subject", "id"], +); +skillTable.unique("skill_name_uidx", ["tenant", "owner", "subject", "name"]); + +const skillRevisionTable = ownedExecutorTable( + "skill_revision", + { + id: keyColumn("id"), + skill_id: keyColumn("skill_id"), + package_digest: keyColumn("package_digest"), + name: nullableKeyColumn("name"), + description: nullableTextColumn("description"), + frontmatter: nullableJsonColumn("frontmatter"), + files: jsonColumn("files"), + diagnostics: jsonColumn("diagnostics"), + created_at: dateColumn("created_at"), + }, + ["tenant", "owner", "subject", "id"], +); + +const skillCandidateTable = ownedExecutorTable( + "skill_candidate", + { + id: keyColumn("id"), + source: jsonColumn("source"), + package_digest: keyColumn("package_digest"), + name: nullableKeyColumn("name"), + description: nullableTextColumn("description"), + frontmatter: nullableJsonColumn("frontmatter"), + files: jsonColumn("files"), + diagnostics: jsonColumn("diagnostics"), + created_at: dateColumn("created_at"), + expires_at: dateColumn("expires_at"), + }, + ["tenant", "owner", "subject", "id"], +); + export const coreTables = defineTables({ // The catalog — tenant-shared integration definitions. `config` is the owning // plugin's opaque blob (openapi auth templates + spec; mcp url). Core never @@ -416,6 +466,12 @@ export const coreTables = defineTables({ ["tenant", "owner", "subject", "id"], ), + skill: skillTable, + + skill_revision: skillRevisionTable, + + skill_candidate: skillCandidateTable, + // Host-owned plugin storage (shared `plugin_storage` table, owner-scoped). plugin_storage: ownedExecutorTable( "plugin_storage", @@ -492,6 +548,22 @@ export const ARTIFACT_SUMMARY_COLUMNS = [ ] as const satisfies readonly (keyof ArtifactRow)[]; /** The artifact-row projection {@link ARTIFACT_SUMMARY_COLUMNS} selects. */ export type ArtifactSummaryRow = Pick; +export type SkillRow = FumaRow; +export type SkillRevisionRow = FumaRow; +export type SkillCandidateRow = FumaRow; +export const SKILL_SUMMARY_COLUMNS = [ + "owner", + "id", + "name", + "description", + "active_revision_id", + "delivery", + "source", + "requirements", + "created_at", + "updated_at", +] as const satisfies readonly (keyof SkillRow)[]; +export type SkillSummaryRow = Pick; export type PluginStorageRow = FumaRow; export type BlobRow = FumaRow; diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index d0f16e7d12..4bb02b1cb0 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -6,10 +6,14 @@ import { ArtifactId, ConnectionName, IntegrationSlug, + ManagedSkillId, Owner, ProviderKey, + SkillCandidateId, + SkillRevisionId, ToolAddress, } from "./ids"; +import { SkillDiagnostic } from "./skill-package"; export interface UserActionableError { readonly __executorUserActionable: true; @@ -279,6 +283,95 @@ export class ArtifactNotFoundError extends Schema.TaggedErrorClass()( + "ManagedSkillNotFoundError", + { skillId: ManagedSkillId }, + { httpApiStatus: 404 }, +) { + override get message(): string { + return `Managed skill not found: ${this.skillId}`; + } +} + +export class SkillRevisionNotFoundError extends Schema.TaggedErrorClass()( + "SkillRevisionNotFoundError", + { skillId: ManagedSkillId, revisionId: SkillRevisionId }, + { httpApiStatus: 404 }, +) {} + +export class SkillCandidateNotFoundError extends Schema.TaggedErrorClass()( + "SkillCandidateNotFoundError", + { candidateId: SkillCandidateId }, + { httpApiStatus: 404 }, +) {} + +export class SkillCandidateExpiredError extends Schema.TaggedErrorClass()( + "SkillCandidateExpiredError", + { candidateId: SkillCandidateId, expiredAt: Schema.String }, + { httpApiStatus: 410 }, +) {} + +export class SkillSourceUnavailableError extends Schema.TaggedErrorClass()( + "SkillSourceUnavailableError", + { message: Schema.String }, + { httpApiStatus: 502 }, +) {} + +export class SkillCandidateMismatchError extends Schema.TaggedErrorClass()( + "SkillCandidateMismatchError", + { skillId: ManagedSkillId, candidateId: SkillCandidateId, reason: Schema.String }, + { httpApiStatus: 409 }, +) {} + +export class SkillUpdateConflictError extends Schema.TaggedErrorClass()( + "SkillUpdateConflictError", + { skillId: ManagedSkillId, paths: Schema.Array(Schema.String) }, + { httpApiStatus: 409 }, +) {} + +export class SkillPackageRejectedError + extends Schema.TaggedErrorClass()( + "SkillPackageRejectedError", + { diagnostics: Schema.Array(SkillDiagnostic) }, + { httpApiStatus: 400 }, + ) + implements UserActionableError +{ + readonly __executorUserActionable = true; + readonly code = "skill_package_rejected"; + get userMessage(): string { + return this.diagnostics[0]?.message ?? "The skill package was rejected."; + } +} + +export class SkillRevisionConflictError extends Schema.TaggedErrorClass()( + "SkillRevisionConflictError", + { + skillId: ManagedSkillId, + expectedRevisionId: SkillRevisionId, + actualRevisionId: SkillRevisionId, + }, + { httpApiStatus: 409 }, +) {} + +export class SkillNameConflictError extends Schema.TaggedErrorClass()( + "SkillNameConflictError", + { owner: Owner, name: Schema.String }, + { httpApiStatus: 409 }, +) {} + +export class SkillInvalidTransitionError extends Schema.TaggedErrorClass()( + "SkillInvalidTransitionError", + { skillId: ManagedSkillId, reason: Schema.String }, + { httpApiStatus: 409 }, +) {} + +export class PortableSkillExportRejectedError extends Schema.TaggedErrorClass()( + "PortableSkillExportRejectedError", + { skillId: ManagedSkillId, diagnostics: Schema.Array(SkillDiagnostic) }, + { httpApiStatus: 400 }, +) {} + // --------------------------------------------------------------------------- // Union — the failure channel of `execute`. // --------------------------------------------------------------------------- @@ -302,4 +395,16 @@ export type ExecuteError = export type ExecutorError = | ExecuteError | IntegrationRemovalNotAllowedError - | ArtifactNotFoundError; + | ArtifactNotFoundError + | ManagedSkillNotFoundError + | SkillRevisionNotFoundError + | SkillCandidateNotFoundError + | SkillCandidateExpiredError + | SkillSourceUnavailableError + | SkillCandidateMismatchError + | SkillUpdateConflictError + | SkillPackageRejectedError + | SkillRevisionConflictError + | SkillNameConflictError + | SkillInvalidTransitionError + | PortableSkillExportRejectedError; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index fdbc9b7671..35ddae22ff 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -55,6 +55,7 @@ import { ARTIFACT_SUMMARY_COLUMNS, coreSchema, isToolPolicyAction, + SKILL_SUMMARY_COLUMNS, TOOL_INVOCATION_COLUMNS, type ConnectionRow, type CoreSchema, @@ -96,6 +97,40 @@ import { type SaveArtifactInput, type SetArtifactPreviewInput, } from "./artifact"; +import { + managedSkillSummaryFromRow, + skillCandidateFromRow, + skillRevisionFromRow, + type CreateManagedSkillInput, + type ImportSkillCandidateInput, + type ApplySkillCandidateInput, + type EditManagedSkillInput, + type ExportManagedSkillInput, + type ManagedSkill, + type ManagedSkillExport, + type ManagedSkillFile, + type ManagedSkillSummary, + type SkillCandidate, + type SkillUpdateReview, + type ReviewSkillCandidateInput, + type ReadManagedSkillFileInput, + type RemoveManagedSkillInput, + type RestoreManagedSkillRevisionInput, + type SetManagedSkillDeliveryInput, + type SetManagedSkillSourceInput, + type SetManagedSkillRequirementsInput, + type SkillRequirementStatus, + type StageSkillCandidateInput, + type SkillDelivery, + type SkillSource, + type SkillRevision, +} from "./managed-skill"; +import { + defaultSkillInvocation, + prepareSkillPackage, + type PreparedSkillRevision, +} from "./skill-package"; +import { makeSkillPackageRepository } from "./skill-package-repository"; import { ArtifactNotFoundError, ConnectionAlreadyExistsError, @@ -105,9 +140,19 @@ import { IntegrationNotFoundError, InvalidConnectionInputError, IntegrationRemovalNotAllowedError, + ManagedSkillNotFoundError, NoHandlerError, OrgWriteDeniedError, PluginNotLoadedError, + PortableSkillExportRejectedError, + SkillInvalidTransitionError, + SkillCandidateExpiredError, + SkillCandidateNotFoundError, + SkillCandidateMismatchError, + SkillPackageRejectedError, + SkillUpdateConflictError, + SkillRevisionConflictError, + SkillRevisionNotFoundError, ToolBlockedError, ToolInvocationError, ToolNotFoundError, @@ -119,6 +164,7 @@ import { ConnectionAddress, ConnectionName, IntegrationSlug, + ManagedSkillId, NO_AUTH_TEMPLATE, OAuthClientSlug, Owner, @@ -126,6 +172,8 @@ import { ProviderItemId, ProviderKey, Subject, + SkillRevisionId, + SkillCandidateId, Tenant, ToolAddress, ToolName, @@ -184,6 +232,7 @@ import type { PreparedToolPolicy, ToolPolicyProvider, ToolPolicyProviderRule, + SkillCatalogProvider, ToolInvocationCredential, } from "./plugin"; import { @@ -521,6 +570,114 @@ export type Executor = { ) => Effect.Effect; }; + readonly skills: { + readonly list: () => Effect.Effect; + readonly get: (input: { + readonly skillId: ManagedSkillId; + }) => Effect.Effect; + readonly readFile: ( + input: ReadManagedSkillFileInput, + ) => Effect.Effect< + ManagedSkillFile, + ManagedSkillNotFoundError | SkillRevisionNotFoundError | StorageFailure + >; + readonly create: ( + input: CreateManagedSkillInput, + ) => Effect.Effect< + ManagedSkill, + SkillPackageRejectedError | OrgWriteDeniedError | StorageFailure + >; + readonly stageCandidate: ( + input: StageSkillCandidateInput, + ) => Effect.Effect< + SkillCandidate, + SkillPackageRejectedError | OrgWriteDeniedError | StorageFailure + >; + readonly importCandidate: ( + input: ImportSkillCandidateInput, + ) => Effect.Effect< + ManagedSkill, + | SkillCandidateNotFoundError + | SkillCandidateExpiredError + | OrgWriteDeniedError + | StorageFailure + >; + readonly reviewCandidate: ( + input: ReviewSkillCandidateInput, + ) => Effect.Effect< + SkillUpdateReview, + | ManagedSkillNotFoundError + | SkillCandidateNotFoundError + | SkillCandidateExpiredError + | SkillCandidateMismatchError + | StorageFailure + >; + readonly applyCandidate: ( + input: ApplySkillCandidateInput, + ) => Effect.Effect< + ManagedSkill, + | ManagedSkillNotFoundError + | SkillCandidateNotFoundError + | SkillCandidateExpiredError + | SkillCandidateMismatchError + | SkillRevisionConflictError + | SkillUpdateConflictError + | SkillPackageRejectedError + | OrgWriteDeniedError + | StorageFailure + >; + readonly edit: ( + input: EditManagedSkillInput, + ) => Effect.Effect< + ManagedSkill, + | ManagedSkillNotFoundError + | SkillPackageRejectedError + | SkillRevisionConflictError + | OrgWriteDeniedError + | StorageFailure + >; + readonly restoreRevision: ( + input: RestoreManagedSkillRevisionInput, + ) => Effect.Effect< + ManagedSkill, + | ManagedSkillNotFoundError + | SkillRevisionNotFoundError + | SkillRevisionConflictError + | OrgWriteDeniedError + | StorageFailure + >; + readonly remove: ( + input: RemoveManagedSkillInput, + ) => Effect.Effect; + readonly setDelivery: ( + input: SetManagedSkillDeliveryInput, + ) => Effect.Effect< + ManagedSkill, + ManagedSkillNotFoundError | SkillInvalidTransitionError | OrgWriteDeniedError | StorageFailure + >; + readonly setSource: ( + input: SetManagedSkillSourceInput, + ) => Effect.Effect< + ManagedSkill, + ManagedSkillNotFoundError | SkillInvalidTransitionError | OrgWriteDeniedError | StorageFailure + >; + readonly setRequirements: ( + input: SetManagedSkillRequirementsInput, + ) => Effect.Effect< + ManagedSkill, + ManagedSkillNotFoundError | OrgWriteDeniedError | StorageFailure + >; + readonly export: ( + input: ExportManagedSkillInput, + ) => Effect.Effect< + ManagedSkillExport, + | ManagedSkillNotFoundError + | SkillRevisionNotFoundError + | PortableSkillExportRejectedError + | StorageFailure + >; + }; + /** * Approvals recorded for artifact-originated calls that paused on a human. * @@ -2038,6 +2195,11 @@ export const createExecutor = (effect: Effect.Effect) => fuma.transaction(effect); // Runtime-observed output shapes ("muscle memory"): learned on the @@ -2055,6 +2217,7 @@ export const createExecutor = (); const runtimes = new Map(); let activeToolPolicyProvider: ToolPolicyProvider | null = null; + let activeSkillCatalogProvider: SkillCatalogProvider | null = null; // Credential providers keyed by `provider.key`, in registration order. const credentialProviders = new Map(); const credentialProviderOrder: string[] = []; @@ -6332,6 +6495,1093 @@ export const createExecutor = => core.deleteMany("artifact", { where: artifactById(input.id) }); + // ------------------------------------------------------------------ + // Managed Agent Skills + // ------------------------------------------------------------------ + + const skillById = + (skillId: ManagedSkillId): CoreWhere => + (b: AnyCb) => + b("id", "=", String(skillId)); + + const skillRevisionById = + (skillId: ManagedSkillId, revisionId: SkillRevisionId): CoreWhere => + (b: AnyCb) => + b.and(b("skill_id", "=", String(skillId)), b("id", "=", String(revisionId))); + + const skillOwnerPartition = (owner: Owner): Effect.Effect => { + if (owner === "org") return Effect.succeed(blobPartitions.org); + return blobPartitions.user === null + ? Effect.fail( + new StorageError({ + message: 'Cannot read or write an owner "user" skill without a subject.', + cause: undefined, + }), + ) + : Effect.succeed(blobPartitions.user); + }; + + const decodeSkillSummary = ( + row: CoreRow<"skill"> | CoreProjectedRow<"skill", typeof SKILL_SUMMARY_COLUMNS>, + ): Effect.Effect => + Option.match(managedSkillSummaryFromRow(row), { + onNone: () => + Effect.fail( + new StorageError({ + message: `Managed skill row ${row.id} is corrupt.`, + cause: undefined, + }), + ), + onSome: Effect.succeed, + }); + + const decodeSkillRevision = ( + row: CoreRow<"skill_revision">, + ): Effect.Effect => + Option.match(skillRevisionFromRow(row), { + onNone: () => + Effect.fail( + new StorageError({ + message: `Managed skill revision ${row.id} is corrupt.`, + cause: undefined, + }), + ), + onSome: Effect.succeed, + }); + + const skillsListUnfiltered = (): Effect.Effect< + readonly ManagedSkillSummary[], + StorageFailure + > => + Effect.gen(function* () { + const rows = yield* core.findMany("skill", { + orderBy: [ + ["updated_at", "desc"], + ["id", "desc"], + ], + select: SKILL_SUMMARY_COLUMNS, + }); + return yield* Effect.forEach(rows, decodeSkillSummary); + }); + + const resolveSkillRequirements = ( + skills: readonly ManagedSkillSummary[], + ): Effect.Effect => + Effect.gen(function* () { + if (skills.every((skill) => skill.requirements.length === 0)) return skills; + const integrations = yield* integrationsList(); + const connections = yield* connectionsList(); + const tools = yield* toolsList(); + const allowed = + activeSkillCatalogProvider === null + ? null + : yield* activeSkillCatalogProvider.listAllowedSkillIds(); + const statusFor = (skill: ManagedSkillSummary): readonly SkillRequirementStatus[] => + skill.requirements.map((requirement): SkillRequirementStatus => { + if (requirement.kind === "runtime") { + return { requirement, status: "unknown", evidence: null }; + } + if (requirement.kind === "skill") { + const dependency = skills.find( + (candidate) => + candidate.name === requirement.name && + (requirement.owner === null || candidate.owner === requirement.owner), + ); + if (!dependency) return { requirement, status: "missing", evidence: null }; + if ( + dependency.delivery.kind === "blocked" || + dependency.delivery.kind === "disabled" || + (allowed !== null && !allowed.has(dependency.id)) + ) { + return { + requirement, + status: "blocked", + evidence: `${dependency.owner}/${dependency.name ?? dependency.id}`, + }; + } + return { + requirement, + status: "satisfied", + evidence: `${dependency.owner}/${dependency.name ?? dependency.id}`, + }; + } + const integration = integrations.find( + (candidate) => String(candidate.slug) === requirement.integration, + ); + if (!integration) return { requirement, status: "missing", evidence: null }; + if (requirement.kind === "mcp") { + return integration.kind === "mcp" + ? { requirement, status: "satisfied", evidence: String(integration.slug) } + : { requirement, status: "missing", evidence: null }; + } + if (requirement.kind === "integration") { + const patternsSatisfied = requirement.toolPatterns.every((pattern) => + tools.some((tool) => { + const address = String(tool.address); + return ( + matchPattern(pattern, address) || + matchPattern(pattern, address.replace(/^tools\./, "")) + ); + }), + ); + return requirement.toolPatterns.length === 0 || patternsSatisfied + ? { requirement, status: "satisfied", evidence: String(integration.slug) } + : { requirement, status: "blocked", evidence: String(integration.slug) }; + } + const visibleConnections = connections.filter( + (connection) => + String(connection.integration) === requirement.integration && + (skill.owner === "user" || connection.owner === "org"), + ); + if (visibleConnections.length === 0) { + return { requirement, status: "needs-user-action", evidence: null }; + } + const compatible = visibleConnections.find((connection) => { + if ( + requirement.authMethod !== null && + String(connection.template) !== requirement.authMethod + ) { + return false; + } + const scopes = new Set((connection.oauthScope ?? "").split(/\s+/).filter(Boolean)); + return requirement.oauthScopes.every((scope) => scopes.has(scope)); + }); + if (!compatible) { + return { requirement, status: "needs-user-action", evidence: null }; + } + return compatible.lastHealth?.status === "expired" || + compatible.lastHealth?.status === "misconfigured" + ? { + requirement, + status: "needs-user-action", + evidence: String(compatible.address), + } + : { + requirement, + status: "satisfied", + evidence: String(compatible.address), + }; + }); + return skills.map((skill) => ({ ...skill, requirementStatuses: statusFor(skill) })); + }); + + const skillsList = (): Effect.Effect => + Effect.gen(function* () { + const skills = yield* resolveSkillRequirements(yield* skillsListUnfiltered()); + if (activeSkillCatalogProvider === null) return skills; + const allowed = yield* activeSkillCatalogProvider.listAllowedSkillIds(); + return skills.filter((skill) => allowed.has(skill.id)); + }); + + const skillRow = ( + skillId: ManagedSkillId, + ): Effect.Effect, ManagedSkillNotFoundError | StorageFailure> => + Effect.gen(function* () { + const row = yield* core.findFirst("skill", { where: skillById(skillId) }); + return row ?? (yield* new ManagedSkillNotFoundError({ skillId })); + }); + + const skillsGet = (input: { + readonly skillId: ManagedSkillId; + }): Effect.Effect => + Effect.gen(function* () { + const row = yield* skillRow(input.skillId); + const decodedSummary = yield* decodeSkillSummary(row); + const summary = + decodedSummary.requirements.length === 0 + ? decodedSummary + : ((yield* resolveSkillRequirements(yield* skillsListUnfiltered())).find( + (candidate) => candidate.id === decodedSummary.id, + ) ?? decodedSummary); + const revisionRows = yield* core.findMany("skill_revision", { + where: (b: AnyCb) => b("skill_id", "=", String(input.skillId)), + orderBy: [ + ["created_at", "asc"], + ["id", "asc"], + ], + }); + const revisions = yield* Effect.forEach(revisionRows, decodeSkillRevision); + return { ...summary, revisions }; + }); + + const manifestFor = (revision: PreparedSkillRevision) => + revision.files.map(({ path, size, digest, mediaType, encoding }) => ({ + path, + size, + digest, + mediaType, + encoding, + })); + + const revisionId = (): SkillRevisionId => + SkillRevisionId.make(`skr_${crypto.randomUUID().replaceAll("-", "")}`); + + const candidateId = (): SkillCandidateId => + SkillCandidateId.make(`skc_${crypto.randomUUID().replaceAll("-", "")}`); + + const skillId = (): ManagedSkillId => + ManagedSkillId.make(`skl_${crypto.randomUUID().replaceAll("-", "")}`); + + const deliveryForCreate = ( + prepared: PreparedSkillRevision, + requested: CreateManagedSkillInput["delivery"], + ): SkillDelivery => + prepared.diagnostics.some(({ severity }) => severity === "blocking") + ? { kind: "blocked", diagnostics: prepared.diagnostics } + : (requested ?? { + kind: "enabled", + invocation: defaultSkillInvocation(prepared.frontmatter), + }); + + const deliveryForRevision = ( + prepared: PreparedSkillRevision | SkillRevision, + previous: SkillDelivery, + ): SkillDelivery => { + if (prepared.diagnostics.some(({ severity }) => severity === "blocking")) { + return { kind: "blocked", diagnostics: prepared.diagnostics }; + } + if (previous.kind === "disabled") return previous; + if (previous.kind === "enabled") return previous; + return { + kind: "enabled", + invocation: defaultSkillInvocation(prepared.frontmatter), + }; + }; + + const revisionRow = (input: { + readonly keys: OwnedKeys; + readonly skillId: ManagedSkillId; + readonly revisionId: SkillRevisionId; + readonly revision: PreparedSkillRevision; + readonly createdAt: Date; + }): Record => ({ + tenant: input.keys.tenant, + owner: input.keys.owner, + subject: input.keys.subject, + id: String(input.revisionId), + skill_id: String(input.skillId), + package_digest: String(input.revision.packageDigest), + name: input.revision.name === null ? null : String(input.revision.name), + description: input.revision.description, + frontmatter: input.revision.frontmatter, + files: manifestFor(input.revision), + diagnostics: input.revision.diagnostics, + created_at: input.createdAt, + }); + + const prepareManagedSkillPackage = ( + files: CreateManagedSkillInput["package"]["files"], + ): Effect.Effect => + Effect.gen(function* () { + const prepared = yield* prepareSkillPackage(files); + if (prepared.kind === "rejected") { + return yield* new SkillPackageRejectedError({ diagnostics: prepared.diagnostics }); + } + return prepared.revision; + }); + + const decodeSkillCandidate = ( + row: CoreRow<"skill_candidate">, + ): Effect.Effect => + Option.match(skillCandidateFromRow(row), { + onNone: () => + Effect.fail( + new StorageError({ + message: `Managed skill candidate ${row.id} is corrupt.`, + cause: undefined, + }), + ), + onSome: Effect.succeed, + }); + + const skillsStageCandidate = ( + input: StageSkillCandidateInput, + ): Effect.Effect< + SkillCandidate, + SkillPackageRejectedError | OrgWriteDeniedError | StorageFailure + > => + Effect.gen(function* () { + yield* guardOrgWrite(input.owner); + yield* requireUserSubject(input.owner); + const keys = yield* Effect.try({ + try: () => ownedKeys(input.owner), + catch: (cause) => storageFailureFromUnknown("invalid skill candidate owner", cause), + }); + const prepared = yield* prepareManagedSkillPackage(input.package.files); + const partition = yield* skillOwnerPartition(input.owner); + yield* skillPackages.put(partition, prepared); + const id = candidateId(); + const createdAt = new Date(); + const expiresAt = new Date(createdAt.getTime() + 30 * 60 * 1000); + const row = yield* core.create("skill_candidate", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + id: String(id), + source: input.source, + package_digest: String(prepared.packageDigest), + name: prepared.name === null ? null : String(prepared.name), + description: prepared.description, + frontmatter: prepared.frontmatter, + files: manifestFor(prepared), + diagnostics: prepared.diagnostics, + created_at: createdAt, + expires_at: expiresAt, + }); + return yield* decodeSkillCandidate(row); + }); + + const skillsImportCandidate = ( + input: ImportSkillCandidateInput, + ): Effect.Effect< + ManagedSkill, + | SkillCandidateNotFoundError + | SkillCandidateExpiredError + | OrgWriteDeniedError + | StorageFailure + > => + Effect.gen(function* () { + const row = yield* core.findFirst("skill_candidate", { + where: (b: AnyCb) => b("id", "=", String(input.candidateId)), + }); + if (row === null) { + return yield* new SkillCandidateNotFoundError({ candidateId: input.candidateId }); + } + const candidate = yield* decodeSkillCandidate(row); + yield* guardOrgWrite(candidate.owner); + if (candidate.expiresAt.getTime() <= Date.now()) { + return yield* new SkillCandidateExpiredError({ + candidateId: candidate.id, + expiredAt: candidate.expiresAt.toISOString(), + }); + } + const keys = yield* Effect.try({ + try: () => ownedKeys(candidate.owner), + catch: (cause) => storageFailureFromUnknown("invalid skill candidate owner", cause), + }); + const newSkillId = skillId(); + const newRevisionId = revisionId(); + const now = new Date(); + const revision = candidate.revision; + const delivery: SkillDelivery = revision.diagnostics.some( + ({ severity }) => severity === "blocking", + ) + ? { kind: "blocked", diagnostics: revision.diagnostics } + : (input.delivery ?? { + kind: "enabled", + invocation: defaultSkillInvocation(revision.frontmatter), + }); + yield* transaction( + Effect.gen(function* () { + yield* core.create("skill_revision", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + id: String(newRevisionId), + skill_id: String(newSkillId), + package_digest: String(revision.packageDigest), + name: revision.name === null ? null : String(revision.name), + description: revision.description, + frontmatter: revision.frontmatter, + files: revision.files, + diagnostics: revision.diagnostics, + created_at: now, + }); + yield* core.create("skill", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + id: String(newSkillId), + name: revision.name === null ? null : String(revision.name), + description: revision.description, + active_revision_id: String(newRevisionId), + delivery, + source: { + kind: "imported", + locator: candidate.source.locator, + tracking: candidate.source.tracking, + baselineRevisionId: newRevisionId, + }, + requirements: [], + created_at: now, + updated_at: now, + }); + yield* core.deleteMany("skill_candidate", { + where: (b: AnyCb) => b("id", "=", String(candidate.id)), + }); + }), + ); + return yield* skillsGet({ skillId: newSkillId }).pipe( + Effect.catchTag("ManagedSkillNotFoundError", (cause) => + Effect.fail( + new StorageError({ + message: `Managed skill ${newSkillId} disappeared after candidate import.`, + cause, + }), + ), + ), + ); + }); + + const candidateForUpdate = ( + skill: ManagedSkill, + candidateId: SkillCandidateId, + ): Effect.Effect< + SkillCandidate, + | SkillCandidateNotFoundError + | SkillCandidateExpiredError + | SkillCandidateMismatchError + | StorageFailure + > => + Effect.gen(function* () { + const row = yield* core.findFirst("skill_candidate", { + where: (b: AnyCb) => b("id", "=", String(candidateId)), + }); + if (row === null) return yield* new SkillCandidateNotFoundError({ candidateId }); + const candidate = yield* decodeSkillCandidate(row); + if (candidate.expiresAt.getTime() <= Date.now()) { + return yield* new SkillCandidateExpiredError({ + candidateId, + expiredAt: candidate.expiresAt.toISOString(), + }); + } + if (candidate.owner !== skill.owner || skill.source.kind !== "imported") { + return yield* new SkillCandidateMismatchError({ + skillId: skill.id, + candidateId, + reason: "The candidate does not belong to this imported skill.", + }); + } + const current = skill.source.locator; + const next = candidate.source.locator; + const matches = + current.kind === next.kind && + (current.kind === "github" && next.kind === "github" + ? current.repository === next.repository && + current.directory === next.directory && + current.requestedRef === next.requestedRef + : current.kind === "wellKnown" && next.kind === "wellKnown" + ? current.indexUrl === next.indexUrl && current.entryId === next.entryId + : current.kind === "mcp" && next.kind === "mcp" + ? current.connection === next.connection && current.uri === next.uri + : current.kind === "local" && next.kind === "local" + ? current.path === next.path + : false); + if (!matches) { + return yield* new SkillCandidateMismatchError({ + skillId: skill.id, + candidateId, + reason: "The candidate was fetched from a different source.", + }); + } + return candidate; + }); + + const digestAt = ( + files: readonly { readonly path: string; readonly digest: string }[], + path: string, + ): string | null => files.find((file) => file.path === path)?.digest ?? null; + + const skillsReviewCandidate = ( + input: ReviewSkillCandidateInput, + ): Effect.Effect< + SkillUpdateReview, + | ManagedSkillNotFoundError + | SkillCandidateNotFoundError + | SkillCandidateExpiredError + | SkillCandidateMismatchError + | StorageFailure + > => + Effect.gen(function* () { + const skill = yield* skillsGet({ skillId: input.skillId }); + const candidate = yield* candidateForUpdate(skill, input.candidateId); + if (skill.source.kind !== "imported") { + return yield* new SkillCandidateMismatchError({ + skillId: skill.id, + candidateId: candidate.id, + reason: "An authored skill has no source baseline.", + }); + } + const source = skill.source; + const baseline = skill.revisions.find( + (revision) => revision.id === source.baselineRevisionId, + ); + const active = skill.revisions.find((revision) => revision.id === skill.activeRevisionId); + if (baseline === undefined || active === undefined) { + return yield* new StorageError({ + message: `Managed skill ${skill.id} has a missing source revision.`, + cause: undefined, + }); + } + const paths = new Set([ + ...baseline.files.map((file) => file.path), + ...active.files.map((file) => file.path), + ...candidate.revision.files.map((file) => file.path), + ]); + const changes = [...paths].sort().flatMap((path) => { + const baselineDigest = digestAt(baseline.files, path); + const activeDigest = digestAt(active.files, path); + const candidateDigest = digestAt(candidate.revision.files, path); + if (candidateDigest === baselineDigest) return []; + return [ + { + path, + kind: + baselineDigest === null + ? ("added" as const) + : candidateDigest === null + ? ("removed" as const) + : ("changed" as const), + conflict: activeDigest !== baselineDigest && activeDigest !== candidateDigest, + baselineDigest, + activeDigest, + candidateDigest, + }, + ]; + }); + return { + skillId: skill.id, + candidateId: candidate.id, + expectedActiveRevisionId: skill.activeRevisionId, + expectedBaselineRevisionId: source.baselineRevisionId, + changes, + conflicts: changes.filter((change) => change.conflict).map((change) => change.path), + }; + }); + + const skillsApplyCandidate = ( + input: ApplySkillCandidateInput, + ): Effect.Effect< + ManagedSkill, + | ManagedSkillNotFoundError + | SkillCandidateNotFoundError + | SkillCandidateExpiredError + | SkillCandidateMismatchError + | SkillRevisionConflictError + | SkillUpdateConflictError + | SkillPackageRejectedError + | OrgWriteDeniedError + | StorageFailure + > => + Effect.gen(function* () { + const skill = yield* skillsGet({ skillId: input.skillId }); + yield* guardOrgWrite(skill.owner); + if (skill.activeRevisionId !== input.expectedActiveRevisionId) { + return yield* new SkillRevisionConflictError({ + skillId: skill.id, + expectedRevisionId: input.expectedActiveRevisionId, + actualRevisionId: skill.activeRevisionId, + }); + } + if ( + skill.source.kind !== "imported" || + skill.source.baselineRevisionId !== input.expectedBaselineRevisionId + ) { + return yield* new SkillCandidateMismatchError({ + skillId: skill.id, + candidateId: input.candidateId, + reason: "The source baseline changed after this review opened.", + }); + } + const candidate = yield* candidateForUpdate(skill, input.candidateId); + const review = yield* skillsReviewCandidate({ + skillId: skill.id, + candidateId: candidate.id, + }); + const resolutions = new Map( + input.resolutions.map((resolution) => [resolution.path, resolution]), + ); + const unresolved = review.conflicts.filter((path) => !resolutions.has(path)); + if (unresolved.length > 0) { + return yield* new SkillUpdateConflictError({ skillId: skill.id, paths: unresolved }); + } + const baseline = skill.revisions.find( + (revision) => revision.id === input.expectedBaselineRevisionId, + ); + const active = skill.revisions.find( + (revision) => revision.id === input.expectedActiveRevisionId, + ); + if (baseline === undefined || active === undefined) { + return yield* new StorageError({ + message: `Managed skill ${skill.id} has a missing update revision.`, + cause: undefined, + }); + } + const partition = yield* skillOwnerPartition(skill.owner); + const paths = new Set([ + ...baseline.files.map((file) => file.path), + ...active.files.map((file) => file.path), + ...candidate.revision.files.map((file) => file.path), + ]); + const files = yield* Effect.forEach([...paths].sort(), (path) => + Effect.gen(function* () { + const baselineFile = baseline.files.find((file) => file.path === path); + const activeFile = active.files.find((file) => file.path === path); + const candidateFile = candidate.revision.files.find((file) => file.path === path); + const localChanged = activeFile?.digest !== baselineFile?.digest; + const upstreamChanged = candidateFile?.digest !== baselineFile?.digest; + const conflict = + localChanged && upstreamChanged && activeFile?.digest !== candidateFile?.digest; + const resolution = conflict ? resolutions.get(path) : undefined; + if (resolution?.choice === "custom") { + return { path, bytes: resolution.bytes, mediaType: resolution.mediaType }; + } + const selected = + resolution?.choice === "local" + ? activeFile + : resolution?.choice === "upstream" || upstreamChanged + ? candidateFile + : activeFile; + if (selected === undefined) return null; + const bytes = yield* skillPackages.read(partition, selected); + return { path, bytes, mediaType: selected.mediaType }; + }), + ); + const prepared = yield* prepareManagedSkillPackage(files.filter(Predicate.isNotNull)); + yield* skillPackages.put(partition, prepared); + const keys = yield* Effect.try({ + try: () => ownedKeys(skill.owner), + catch: (cause) => storageFailureFromUnknown("invalid skill owner", cause), + }); + const baselineRevisionId = revisionId(); + const activeRevisionId = + prepared.packageDigest === candidate.revision.packageDigest + ? baselineRevisionId + : revisionId(); + const now = new Date(); + const delivery = deliveryForRevision(prepared, skill.delivery); + yield* transaction( + Effect.gen(function* () { + const current = yield* skillRow(skill.id); + yield* assertActiveRevision(current, input.expectedActiveRevisionId); + const currentSummary = yield* decodeSkillSummary(current); + if ( + currentSummary.source.kind !== "imported" || + currentSummary.source.baselineRevisionId !== input.expectedBaselineRevisionId + ) { + return yield* new SkillCandidateMismatchError({ + skillId: skill.id, + candidateId: candidate.id, + reason: "The source baseline changed while applying the update.", + }); + } + yield* core.create("skill_revision", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + id: String(baselineRevisionId), + skill_id: String(skill.id), + package_digest: String(candidate.revision.packageDigest), + name: candidate.revision.name === null ? null : String(candidate.revision.name), + description: candidate.revision.description, + frontmatter: candidate.revision.frontmatter, + files: candidate.revision.files, + diagnostics: candidate.revision.diagnostics, + created_at: now, + }); + if (activeRevisionId !== baselineRevisionId) { + yield* core.create( + "skill_revision", + revisionRow({ + keys, + skillId: skill.id, + revisionId: activeRevisionId, + revision: prepared, + createdAt: now, + }), + ); + } + yield* core.updateMany("skill", { + where: skillById(skill.id), + set: { + name: prepared.name === null ? null : String(prepared.name), + description: prepared.description, + active_revision_id: String(activeRevisionId), + delivery, + source: { + kind: "imported", + locator: candidate.source.locator, + tracking: candidate.source.tracking, + baselineRevisionId, + }, + updated_at: now, + }, + }); + yield* core.deleteMany("skill_candidate", { + where: (b: AnyCb) => b("id", "=", String(candidate.id)), + }); + }), + ); + return yield* skillsGet({ skillId: skill.id }); + }); + + const skillsCreate = ( + input: CreateManagedSkillInput, + ): Effect.Effect< + ManagedSkill, + SkillPackageRejectedError | OrgWriteDeniedError | StorageFailure + > => + Effect.gen(function* () { + yield* guardOrgWrite(input.owner); + yield* requireUserSubject(input.owner); + const keys = yield* Effect.try({ + try: () => ownedKeys(input.owner), + catch: (cause) => storageFailureFromUnknown("invalid skill owner", cause), + }); + const prepared = yield* prepareManagedSkillPackage(input.package.files); + const partition = yield* skillOwnerPartition(input.owner); + yield* skillPackages.put(partition, prepared); + + const newSkillId = skillId(); + const newRevisionId = revisionId(); + const now = new Date(); + const delivery = deliveryForCreate(prepared, input.delivery); + yield* transaction( + Effect.gen(function* () { + yield* core.create( + "skill_revision", + revisionRow({ + keys, + skillId: newSkillId, + revisionId: newRevisionId, + revision: prepared, + createdAt: now, + }), + ); + yield* core.create("skill", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + id: String(newSkillId), + name: prepared.name === null ? null : String(prepared.name), + description: prepared.description, + active_revision_id: String(newRevisionId), + delivery, + source: { kind: "authored" }, + requirements: input.requirements ?? [], + created_at: now, + updated_at: now, + }); + }), + ); + return yield* skillsGet({ skillId: newSkillId }).pipe( + Effect.catchTag("ManagedSkillNotFoundError", (cause) => + Effect.fail( + new StorageError({ + message: `Managed skill ${newSkillId} disappeared after creation.`, + cause, + }), + ), + ), + ); + }); + + const assertActiveRevision = ( + row: CoreRow<"skill">, + expectedRevisionId: SkillRevisionId, + ): Effect.Effect => + row.active_revision_id === String(expectedRevisionId) + ? Effect.void + : Effect.fail( + new SkillRevisionConflictError({ + skillId: ManagedSkillId.make(row.id), + expectedRevisionId, + actualRevisionId: SkillRevisionId.make(row.active_revision_id), + }), + ); + + const skillsEdit = ( + input: EditManagedSkillInput, + ): Effect.Effect< + ManagedSkill, + | ManagedSkillNotFoundError + | SkillPackageRejectedError + | SkillRevisionConflictError + | OrgWriteDeniedError + | StorageFailure + > => + Effect.gen(function* () { + const existing = yield* skillRow(input.skillId); + const existingSummary = yield* decodeSkillSummary(existing); + yield* guardOrgWrite(existingSummary.owner); + yield* assertActiveRevision(existing, input.expectedActiveRevisionId); + const prepared = yield* prepareManagedSkillPackage(input.package.files); + const partition = yield* skillOwnerPartition(existingSummary.owner); + yield* skillPackages.put(partition, prepared); + const keys = yield* Effect.try({ + try: () => ownedKeys(existingSummary.owner), + catch: (cause) => storageFailureFromUnknown("invalid skill owner", cause), + }); + const newRevisionId = revisionId(); + const now = new Date(); + const delivery = deliveryForRevision(prepared, existingSummary.delivery); + yield* transaction( + Effect.gen(function* () { + const current = yield* skillRow(input.skillId); + yield* assertActiveRevision(current, input.expectedActiveRevisionId); + yield* core.create( + "skill_revision", + revisionRow({ + keys, + skillId: input.skillId, + revisionId: newRevisionId, + revision: prepared, + createdAt: now, + }), + ); + yield* core.updateMany("skill", { + where: skillById(input.skillId), + set: { + name: prepared.name === null ? null : String(prepared.name), + description: prepared.description, + active_revision_id: String(newRevisionId), + delivery, + updated_at: now, + }, + }); + }), + ); + return yield* skillsGet({ skillId: input.skillId }); + }); + + const skillsReadFile = ( + input: ReadManagedSkillFileInput, + ): Effect.Effect< + ManagedSkillFile, + ManagedSkillNotFoundError | SkillRevisionNotFoundError | StorageFailure + > => + Effect.gen(function* () { + const skill = yield* skillsGet({ skillId: input.skillId }); + const wantedRevisionId = input.revisionId ?? skill.activeRevisionId; + const revision = skill.revisions.find(({ id }) => id === wantedRevisionId); + if (revision === undefined) { + return yield* new SkillRevisionNotFoundError({ + skillId: input.skillId, + revisionId: wantedRevisionId, + }); + } + const manifest = revision.files.find(({ path }) => path === input.path); + if (manifest === undefined) { + return yield* new StorageError({ + message: `Managed skill file not found: ${input.path}`, + cause: undefined, + }); + } + const partition = yield* skillOwnerPartition(skill.owner); + const bytes = yield* skillPackages.read(partition, manifest); + return { manifest, bytes }; + }); + + const skillsRestoreRevision = ( + input: RestoreManagedSkillRevisionInput, + ): Effect.Effect< + ManagedSkill, + | ManagedSkillNotFoundError + | SkillRevisionNotFoundError + | SkillRevisionConflictError + | OrgWriteDeniedError + | StorageFailure + > => + Effect.gen(function* () { + const existing = yield* skillRow(input.skillId); + const summary = yield* decodeSkillSummary(existing); + yield* guardOrgWrite(summary.owner); + yield* assertActiveRevision(existing, input.expectedActiveRevisionId); + const targetRow = yield* core.findFirst("skill_revision", { + where: skillRevisionById(input.skillId, input.revisionId), + }); + if (targetRow === null) { + return yield* new SkillRevisionNotFoundError({ + skillId: input.skillId, + revisionId: input.revisionId, + }); + } + const target = yield* decodeSkillRevision(targetRow); + const keys = yield* Effect.try({ + try: () => ownedKeys(summary.owner), + catch: (cause) => storageFailureFromUnknown("invalid skill owner", cause), + }); + const newRevisionId = revisionId(); + const now = new Date(); + const delivery = deliveryForRevision(target, summary.delivery); + yield* transaction( + Effect.gen(function* () { + const current = yield* skillRow(input.skillId); + yield* assertActiveRevision(current, input.expectedActiveRevisionId); + yield* core.create("skill_revision", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + id: String(newRevisionId), + skill_id: String(input.skillId), + package_digest: String(target.packageDigest), + name: target.name === null ? null : String(target.name), + description: target.description, + frontmatter: target.frontmatter, + files: target.files, + diagnostics: target.diagnostics, + created_at: now, + }); + yield* core.updateMany("skill", { + where: skillById(input.skillId), + set: { + name: target.name === null ? null : String(target.name), + description: target.description, + active_revision_id: String(newRevisionId), + delivery, + updated_at: now, + }, + }); + }), + ); + return yield* skillsGet({ skillId: input.skillId }); + }); + + const skillsRemove = ( + input: RemoveManagedSkillInput, + ): Effect.Effect => + Effect.gen(function* () { + const existing = yield* skillRow(input.skillId); + const summary = yield* decodeSkillSummary(existing); + yield* guardOrgWrite(summary.owner); + yield* transaction( + Effect.gen(function* () { + yield* core.deleteMany("skill_revision", { + where: (b: AnyCb) => b("skill_id", "=", String(input.skillId)), + }); + yield* core.deleteMany("skill", { where: skillById(input.skillId) }); + }), + ); + }); + + const skillsSetDelivery = ( + input: SetManagedSkillDeliveryInput, + ): Effect.Effect< + ManagedSkill, + ManagedSkillNotFoundError | SkillInvalidTransitionError | OrgWriteDeniedError | StorageFailure + > => + Effect.gen(function* () { + const existing = yield* skillRow(input.skillId); + const summary = yield* decodeSkillSummary(existing); + yield* guardOrgWrite(summary.owner); + if (summary.delivery.kind === "blocked") { + return yield* new SkillInvalidTransitionError({ + skillId: input.skillId, + reason: "A blocked skill must be repaired before its delivery can change.", + }); + } + yield* core.updateMany("skill", { + where: skillById(input.skillId), + set: { delivery: input.delivery, updated_at: new Date() }, + }); + return yield* skillsGet({ skillId: input.skillId }); + }); + + const skillsSetSource = ( + input: SetManagedSkillSourceInput, + ): Effect.Effect< + ManagedSkill, + ManagedSkillNotFoundError | SkillInvalidTransitionError | OrgWriteDeniedError | StorageFailure + > => + Effect.gen(function* () { + const existing = yield* skillRow(input.skillId); + const summary = yield* decodeSkillSummary(existing); + yield* guardOrgWrite(summary.owner); + const source: SkillSource | null = + input.change.kind === "detach" + ? { kind: "authored" } + : summary.source.kind === "authored" + ? null + : { ...summary.source, tracking: input.change.tracking }; + if (source === null) { + return yield* new SkillInvalidTransitionError({ + skillId: input.skillId, + reason: "An authored skill has no source to pin or follow.", + }); + } + yield* core.updateMany("skill", { + where: skillById(input.skillId), + set: { source, updated_at: new Date() }, + }); + return yield* skillsGet({ skillId: input.skillId }); + }); + + const skillsSetRequirements = ( + input: SetManagedSkillRequirementsInput, + ): Effect.Effect< + ManagedSkill, + ManagedSkillNotFoundError | OrgWriteDeniedError | StorageFailure + > => + Effect.gen(function* () { + const existing = yield* skillRow(input.skillId); + const summary = yield* decodeSkillSummary(existing); + yield* guardOrgWrite(summary.owner); + yield* core.updateMany("skill", { + where: skillById(input.skillId), + set: { requirements: input.requirements, updated_at: new Date() }, + }); + return yield* skillsGet({ skillId: input.skillId }); + }); + + const skillsExport = ( + input: ExportManagedSkillInput, + ): Effect.Effect< + ManagedSkillExport, + | ManagedSkillNotFoundError + | SkillRevisionNotFoundError + | PortableSkillExportRejectedError + | StorageFailure + > => + Effect.gen(function* () { + const skill = yield* skillsGet({ skillId: input.skillId }); + const wantedRevisionId = input.revisionId ?? skill.activeRevisionId; + const revision = skill.revisions.find(({ id }) => id === wantedRevisionId); + if (revision === undefined) { + return yield* new SkillRevisionNotFoundError({ + skillId: input.skillId, + revisionId: wantedRevisionId, + }); + } + if ( + input.kind === "portable" && + (revision.name === null || + revision.description === null || + revision.diagnostics.some(({ severity }) => severity === "blocking")) + ) { + return yield* new PortableSkillExportRejectedError({ + skillId: input.skillId, + diagnostics: revision.diagnostics, + }); + } + const files = yield* Effect.forEach(revision.files, (manifest) => + skillsReadFile({ + skillId: input.skillId, + revisionId: wantedRevisionId, + path: manifest.path, + }).pipe( + Effect.map(({ bytes }) => ({ + path: manifest.path, + mediaType: manifest.mediaType, + bytes, + })), + ), + ); + if (input.kind === "backup") return { kind: "backup", skill, revision, files }; + if (revision.name === null) { + return yield* new PortableSkillExportRejectedError({ + skillId: input.skillId, + diagnostics: revision.diagnostics, + }); + } + return { + kind: "portable", + revisionId: wantedRevisionId, + packageDigest: revision.packageDigest, + name: revision.name, + files, + }; + }); + // ------------------------------------------------------------------ // Elicitation // ------------------------------------------------------------------ @@ -6888,11 +8138,6 @@ export const createExecutor = policiesUpdate(input), remove: (input) => policiesRemove(input), }, + skills: { + list: () => skillsListUnfiltered(), + get: (skillId) => + skillsGet({ skillId }).pipe( + Effect.catchTag("ManagedSkillNotFoundError", () => Effect.succeed(null)), + ), + }, }, connections: { create: (input) => connectionsCreate(input), @@ -7009,6 +8261,20 @@ export const createExecutor = > | null; + readonly files: readonly SkillPackageManifestFile[]; + readonly diagnostics: readonly SkillDiagnostic[]; + readonly createdAt: Date; +} + +export interface ManagedSkillSummary { + readonly id: ManagedSkillId; + readonly owner: Owner; + readonly name: SkillName | null; + readonly description: string | null; + readonly activeRevisionId: SkillRevisionId; + readonly delivery: SkillDelivery; + readonly source: SkillSource; + readonly requirements: readonly SkillRequirement[]; + readonly requirementStatuses: readonly SkillRequirementStatus[]; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface ManagedSkill extends ManagedSkillSummary { + readonly revisions: readonly SkillRevision[]; +} + +export interface CreateManagedSkillInput { + readonly owner: Owner; + readonly package: { readonly files: readonly SkillPackageFileInput[] }; + readonly delivery?: + | { readonly kind: "disabled" } + | { readonly kind: "enabled"; readonly invocation: SkillInvocation }; + readonly requirements?: readonly SkillRequirement[]; +} + +export interface StageSkillCandidateInput { + readonly owner: Owner; + readonly package: { readonly files: readonly SkillPackageFileInput[] }; + readonly source: StagedSkillSource; +} + +export interface SkillCandidate { + readonly id: SkillCandidateId; + readonly owner: Owner; + readonly source: StagedSkillSource; + readonly upstreamRevision: string; + readonly revision: Omit; + readonly createdAt: Date; + readonly expiresAt: Date; +} + +export interface ImportSkillCandidateInput { + readonly candidateId: SkillCandidateId; + readonly delivery?: + | { readonly kind: "disabled" } + | { readonly kind: "enabled"; readonly invocation: SkillInvocation }; +} + +export const SkillUpdateFileChange = Schema.Struct({ + path: Schema.String, + kind: Schema.Literals(["added", "removed", "changed"]), + conflict: Schema.Boolean, + baselineDigest: Schema.NullOr(Schema.String), + activeDigest: Schema.NullOr(Schema.String), + candidateDigest: Schema.NullOr(Schema.String), +}); +export type SkillUpdateFileChange = typeof SkillUpdateFileChange.Type; + +export interface SkillUpdateReview { + readonly skillId: ManagedSkillId; + readonly candidateId: SkillCandidateId; + readonly expectedActiveRevisionId: SkillRevisionId; + readonly expectedBaselineRevisionId: SkillRevisionId; + readonly changes: readonly SkillUpdateFileChange[]; + readonly conflicts: readonly string[]; +} + +export interface ReviewSkillCandidateInput { + readonly skillId: ManagedSkillId; + readonly candidateId: SkillCandidateId; +} + +export type SkillUpdateConflictResolution = + | { readonly path: string; readonly choice: "local" | "upstream" } + | { + readonly path: string; + readonly choice: "custom"; + readonly bytes: Uint8Array; + readonly mediaType?: string; + }; + +export interface ApplySkillCandidateInput { + readonly skillId: ManagedSkillId; + readonly candidateId: SkillCandidateId; + readonly expectedActiveRevisionId: SkillRevisionId; + readonly expectedBaselineRevisionId: SkillRevisionId; + readonly resolutions: readonly SkillUpdateConflictResolution[]; +} + +export interface EditManagedSkillInput { + readonly skillId: ManagedSkillId; + readonly expectedActiveRevisionId: SkillRevisionId; + readonly package: { readonly files: readonly SkillPackageFileInput[] }; +} + +export interface ReadManagedSkillFileInput { + readonly skillId: ManagedSkillId; + readonly revisionId?: SkillRevisionId; + readonly path: string; +} + +export interface RestoreManagedSkillRevisionInput { + readonly skillId: ManagedSkillId; + readonly expectedActiveRevisionId: SkillRevisionId; + readonly revisionId: SkillRevisionId; +} + +export interface RemoveManagedSkillInput { + readonly skillId: ManagedSkillId; +} + +export interface SetManagedSkillDeliveryInput { + readonly skillId: ManagedSkillId; + readonly delivery: + | { readonly kind: "disabled" } + | { readonly kind: "enabled"; readonly invocation: SkillInvocation }; +} + +export type ManagedSkillSourceChange = + | { readonly kind: "detach" } + | { readonly kind: "setTracking"; readonly tracking: SkillTracking }; + +export interface SetManagedSkillSourceInput { + readonly skillId: ManagedSkillId; + readonly change: ManagedSkillSourceChange; +} + +export interface SetManagedSkillRequirementsInput { + readonly skillId: ManagedSkillId; + readonly requirements: readonly SkillRequirement[]; +} + +export interface ExportManagedSkillInput { + readonly skillId: ManagedSkillId; + readonly revisionId?: SkillRevisionId; + readonly kind: "portable" | "backup"; +} + +export interface ManagedSkillFile { + readonly manifest: SkillPackageManifestFile; + readonly bytes: Uint8Array; +} + +export interface ManagedSkillExportFile { + readonly path: string; + readonly mediaType: string; + readonly bytes: Uint8Array; +} + +export type ManagedSkillExport = + | { + readonly kind: "portable"; + readonly revisionId: SkillRevisionId; + readonly packageDigest: SkillPackageDigest; + readonly name: SkillName; + readonly files: readonly ManagedSkillExportFile[]; + } + | { + readonly kind: "backup"; + readonly skill: ManagedSkill; + readonly revision: SkillRevision; + readonly files: readonly ManagedSkillExportFile[]; + }; + +const decodeJsonString = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); +const jsonColumn = (value: unknown): Option.Option => + typeof value === "string" ? decodeJsonString(value) : Option.some(value); +const decodeDelivery = Schema.decodeUnknownOption(SkillDelivery); +const decodeSource = Schema.decodeUnknownOption(SkillSource); +const decodeOwner = Schema.decodeUnknownOption(Owner); +const decodeFiles = Schema.decodeUnknownOption(Schema.Array(SkillPackageManifestFile)); +const decodeDiagnostics = Schema.decodeUnknownOption(Schema.Array(SkillDiagnostic)); +const decodeStagedSource = Schema.decodeUnknownOption(StagedSkillSource); +const decodeRequirements = Schema.decodeUnknownOption(Schema.Array(SkillRequirement)); + +const asDate = (value: Date | number | string): Date => + value instanceof Date ? value : new Date(value); + +export const managedSkillSummaryFromRow = ( + row: SkillSummaryRow, +): Option.Option => + Option.gen(function* () { + const owner = yield* decodeOwner(row.owner); + const delivery = yield* Option.flatMap(jsonColumn(row.delivery), decodeDelivery); + const source = yield* Option.flatMap(jsonColumn(row.source), decodeSource); + const requirements = Option.getOrElse( + Option.flatMap(jsonColumn(row.requirements), decodeRequirements), + () => [], + ); + return { + id: ManagedSkillId.make(row.id), + owner, + name: row.name === null ? null : SkillName.make(row.name), + description: row.description, + activeRevisionId: SkillRevisionId.make(row.active_revision_id), + delivery, + source, + requirements, + requirementStatuses: requirements.map((requirement) => ({ + requirement, + status: "unknown" as const, + evidence: null, + })), + createdAt: asDate(row.created_at), + updatedAt: asDate(row.updated_at), + }; + }); + +export const skillRevisionFromRow = (row: SkillRevisionRow): Option.Option => + Option.gen(function* () { + const files = yield* Option.flatMap(jsonColumn(row.files), decodeFiles); + const diagnostics = yield* Option.flatMap(jsonColumn(row.diagnostics), decodeDiagnostics); + const frontmatterValue = yield* jsonColumn(row.frontmatter); + const frontmatter = + typeof frontmatterValue === "object" && + frontmatterValue !== null && + !Array.isArray(frontmatterValue) + ? Object.fromEntries(Object.entries(frontmatterValue)) + : null; + return { + id: SkillRevisionId.make(row.id), + packageDigest: SkillPackageDigest.make(row.package_digest), + name: row.name === null ? null : SkillName.make(row.name), + description: row.description, + frontmatter, + files, + diagnostics, + createdAt: asDate(row.created_at), + }; + }); + +export const skillCandidateFromRow = (row: SkillCandidateRow): Option.Option => + Option.gen(function* () { + const owner = yield* decodeOwner(row.owner); + const source = yield* Option.flatMap(jsonColumn(row.source), decodeStagedSource); + const files = yield* Option.flatMap(jsonColumn(row.files), decodeFiles); + const diagnostics = yield* Option.flatMap(jsonColumn(row.diagnostics), decodeDiagnostics); + const frontmatterValue = yield* jsonColumn(row.frontmatter); + const frontmatter = + typeof frontmatterValue === "object" && + frontmatterValue !== null && + !Array.isArray(frontmatterValue) + ? Object.fromEntries(Object.entries(frontmatterValue)) + : null; + return { + id: SkillCandidateId.make(row.id), + owner, + source, + upstreamRevision: + source.tracking.kind === "pinned" + ? source.tracking.upstreamRevision + : source.tracking.resolvedRevision, + revision: { + packageDigest: SkillPackageDigest.make(row.package_digest), + name: row.name === null ? null : SkillName.make(row.name), + description: row.description, + frontmatter, + files, + diagnostics, + }, + createdAt: asDate(row.created_at), + expiresAt: asDate(row.expires_at), + }; + }); diff --git a/packages/core/sdk/src/managed-skills.test.ts b/packages/core/sdk/src/managed-skills.test.ts new file mode 100644 index 0000000000..d8847922ed --- /dev/null +++ b/packages/core/sdk/src/managed-skills.test.ts @@ -0,0 +1,414 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate, Result } from "effect"; + +import { makeTestExecutor } from "./testing"; + +const encoder = new TextEncoder(); + +const packageFiles = (description = "Extract text from PDFs.") => [ + { + path: "SKILL.md", + bytes: encoder.encode( + `---\nname: pdf-processing\ndescription: ${description}\n---\n\n# PDF processing\n`, + ), + }, + { path: "assets/icon.bin", bytes: Uint8Array.from([0, 255, 4, 8]) }, +]; + +describe("executor.skills", () => { + it.effect("uses frontmatter as the initial invocation preference", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const files = [ + { + path: "SKILL.md", + bytes: encoder.encode( + "---\nname: user-invoked\ndescription: Run only when named.\ndisable-model-invocation: true\n---\n\n# User invoked\n", + ), + }, + ]; + + const manual = yield* executor.skills.create({ + owner: "user", + package: { files }, + }); + expect(manual.delivery).toEqual({ kind: "enabled", invocation: "manual" }); + + const overridden = yield* executor.skills.create({ + owner: "user", + package: { + files: files.map((file) => ({ + ...file, + bytes: encoder.encode( + "---\nname: user-invoked-override\ndescription: Run only when named.\ndisable-model-invocation: true\n---\n\n# User invoked\n", + ), + })), + }, + delivery: { kind: "enabled", invocation: "model" }, + }); + expect(overridden.delivery).toEqual({ kind: "enabled", invocation: "model" }); + }), + ); + + it.effect("creates an immutable revision and keeps package bytes out of list results", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const created = yield* executor.skills.create({ + owner: "user", + package: { files: packageFiles() }, + }); + + expect(created.id).toMatch(/^skl_/); + expect(created.name).toBe("pdf-processing"); + expect(created.delivery).toEqual({ kind: "enabled", invocation: "model" }); + expect(created.revisions).toHaveLength(1); + + const listed = yield* executor.skills.list(); + expect(listed).toHaveLength(1); + expect(listed[0]).not.toHaveProperty("files"); + expect(listed[0]?.activeRevisionId).toBe(created.activeRevisionId); + + const file = yield* executor.skills.readFile({ + skillId: created.id, + path: "assets/icon.bin", + }); + expect(file.bytes).toEqual(Uint8Array.from([0, 255, 4, 8])); + }), + ); + + it.effect("stages trusted source bytes and imports them as an immutable managed copy", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const candidate = yield* executor.skills.stageCandidate({ + owner: "user", + package: { files: packageFiles() }, + source: { + locator: { + kind: "github", + repository: "executor-js/example-skills", + directory: "skills/pdf-processing", + requestedRef: "main", + resolvedCommit: "0123456789abcdef", + }, + tracking: { + kind: "tracked", + symbolicReference: "main", + resolvedRevision: "0123456789abcdef", + }, + }, + }); + + expect(candidate.id).toMatch(/^skc_/); + expect(candidate.revision.name).toBe("pdf-processing"); + expect(candidate.expiresAt.getTime() - candidate.createdAt.getTime()).toBe(30 * 60 * 1000); + + const imported = yield* executor.skills.importCandidate({ candidateId: candidate.id }); + expect(imported.source).toEqual({ + kind: "imported", + locator: candidate.source.locator, + tracking: candidate.source.tracking, + baselineRevisionId: imported.activeRevisionId, + }); + expect(imported.delivery).toEqual({ kind: "enabled", invocation: "model" }); + + const pinned = yield* executor.skills.setSource({ + skillId: imported.id, + change: { + kind: "setTracking", + tracking: { kind: "pinned", upstreamRevision: "0123456789abcdef" }, + }, + }); + expect(pinned.source).toMatchObject({ + kind: "imported", + tracking: { kind: "pinned", upstreamRevision: "0123456789abcdef" }, + }); + const detached = yield* executor.skills.setSource({ + skillId: imported.id, + change: { kind: "detach" }, + }); + expect(detached.source).toEqual({ kind: "authored" }); + + const binary = yield* executor.skills.readFile({ + skillId: imported.id, + path: "assets/icon.bin", + }); + expect(binary.bytes).toEqual(Uint8Array.from([0, 255, 4, 8])); + + const secondImport = yield* executor.skills + .importCandidate({ candidateId: candidate.id }) + .pipe(Effect.result); + expect(Result.isFailure(secondImport)).toBe(true); + expect( + Result.isFailure(secondImport) && + Predicate.isTagged("SkillCandidateNotFoundError")(secondImport.failure), + ).toBe(true); + }), + ); + + it.effect("edits with a revision precondition and preserves history", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const created = yield* executor.skills.create({ + owner: "user", + package: { files: packageFiles() }, + }); + const edited = yield* executor.skills.edit({ + skillId: created.id, + expectedActiveRevisionId: created.activeRevisionId, + package: { files: packageFiles("Extract tables from PDFs.") }, + }); + + expect(edited.id).toBe(created.id); + expect(edited.activeRevisionId).not.toBe(created.activeRevisionId); + expect(edited.revisions).toHaveLength(2); + expect(edited.description).toBe("Extract tables from PDFs."); + + const stale = yield* executor.skills + .edit({ + skillId: created.id, + expectedActiveRevisionId: created.activeRevisionId, + package: { files: packageFiles("A stale edit.") }, + }) + .pipe(Effect.result); + expect(Result.isFailure(stale)).toBe(true); + if (Result.isSuccess(stale)) return; + expect(Predicate.isTagged("SkillRevisionConflictError")(stale.failure)).toBe(true); + }), + ); + + it.effect("reviews source updates against the baseline and requires conflict choices", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const source = { + locator: { + kind: "github" as const, + repository: "executor-js/example-skills", + directory: "skills/pdf-processing", + requestedRef: "main", + resolvedCommit: "commit-one", + }, + tracking: { + kind: "tracked" as const, + symbolicReference: "main", + resolvedRevision: "commit-one", + }, + }; + const initial = yield* executor.skills.stageCandidate({ + owner: "user", + package: { files: packageFiles() }, + source, + }); + const imported = yield* executor.skills.importCandidate({ candidateId: initial.id }); + const edited = yield* executor.skills.edit({ + skillId: imported.id, + expectedActiveRevisionId: imported.activeRevisionId, + package: { files: packageFiles("Keep my local description.") }, + }); + const candidate = yield* executor.skills.stageCandidate({ + owner: "user", + package: { + files: [ + ...packageFiles("Use the upstream description.").slice(0, 1), + { path: "assets/icon.bin", bytes: Uint8Array.from([9, 8, 7]) }, + ], + }, + source: { + locator: { ...source.locator, resolvedCommit: "commit-two" }, + tracking: { ...source.tracking, resolvedRevision: "commit-two" }, + }, + }); + const review = yield* executor.skills.reviewCandidate({ + skillId: imported.id, + candidateId: candidate.id, + }); + expect(review.conflicts).toEqual(["SKILL.md"]); + expect(review.changes.map((change) => change.path)).toEqual(["SKILL.md", "assets/icon.bin"]); + + const unresolved = yield* executor.skills + .applyCandidate({ + skillId: imported.id, + candidateId: candidate.id, + expectedActiveRevisionId: edited.activeRevisionId, + expectedBaselineRevisionId: imported.activeRevisionId, + resolutions: [], + }) + .pipe(Effect.result); + expect( + Result.isFailure(unresolved) && + Predicate.isTagged("SkillUpdateConflictError")(unresolved.failure), + ).toBe(true); + + const applied = yield* executor.skills.applyCandidate({ + skillId: imported.id, + candidateId: candidate.id, + expectedActiveRevisionId: edited.activeRevisionId, + expectedBaselineRevisionId: imported.activeRevisionId, + resolutions: [{ path: "SKILL.md", choice: "local" }], + }); + expect(applied.description).toBe("Keep my local description."); + expect(applied.source).toMatchObject({ + kind: "imported", + locator: { kind: "github", resolvedCommit: "commit-two" }, + }); + const asset = yield* executor.skills.readFile({ + skillId: applied.id, + path: "assets/icon.bin", + }); + expect(asset.bytes).toEqual(Uint8Array.from([9, 8, 7])); + }), + ); + + it.effect("stores safe nonportable content as blocked and lets an edit repair it", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const blocked = yield* executor.skills.create({ + owner: "user", + package: { + files: [{ path: "SKILL.md", bytes: encoder.encode("---\nname: [\n---\nbody") }], + }, + }); + expect(blocked.delivery.kind).toBe("blocked"); + expect(blocked.name).toBeNull(); + + const repaired = yield* executor.skills.edit({ + skillId: blocked.id, + expectedActiveRevisionId: blocked.activeRevisionId, + package: { files: packageFiles() }, + }); + expect(repaired.delivery).toEqual({ kind: "enabled", invocation: "model" }); + expect(repaired.name).toBe("pdf-processing"); + }), + ); + + it.effect("restores by creating a new revision and removes the aggregate", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const created = yield* executor.skills.create({ + owner: "user", + package: { files: packageFiles() }, + }); + const edited = yield* executor.skills.edit({ + skillId: created.id, + expectedActiveRevisionId: created.activeRevisionId, + package: { files: packageFiles("Extract tables from PDFs.") }, + }); + const restored = yield* executor.skills.restoreRevision({ + skillId: created.id, + expectedActiveRevisionId: edited.activeRevisionId, + revisionId: created.activeRevisionId, + }); + + expect(restored.activeRevisionId).not.toBe(created.activeRevisionId); + expect(restored.revisions).toHaveLength(3); + expect(restored.description).toBe("Extract text from PDFs."); + + yield* executor.skills.remove({ skillId: created.id }); + expect(yield* executor.skills.list()).toEqual([]); + }), + ); + + it.effect("changes delivery explicitly and refuses to enable a blocked revision", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const created = yield* executor.skills.create({ + owner: "user", + package: { files: packageFiles() }, + }); + const disabled = yield* executor.skills.setDelivery({ + skillId: created.id, + delivery: { kind: "disabled" }, + }); + expect(disabled.delivery).toEqual({ kind: "disabled" }); + const modelEnabled = yield* executor.skills.setDelivery({ + skillId: created.id, + delivery: { kind: "enabled", invocation: "model" }, + }); + expect(modelEnabled.delivery).toEqual({ kind: "enabled", invocation: "model" }); + + const blocked = yield* executor.skills.create({ + owner: "user", + package: { + files: [{ path: "SKILL.md", bytes: encoder.encode("---\nname: [\n---\nbody") }], + }, + }); + const result = yield* executor.skills + .setDelivery({ + skillId: blocked.id, + delivery: { kind: "enabled", invocation: "manual" }, + }) + .pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isSuccess(result)) return; + expect(Predicate.isTagged("SkillInvalidTransitionError")(result.failure)).toBe(true); + }), + ); + + it.effect("derives requirement status without provisioning dependencies", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + yield* executor.skills.create({ + owner: "user", + package: { + files: [ + { + path: "SKILL.md", + bytes: encoder.encode( + "---\nname: helper-skill\ndescription: A helper skill.\n---\nBody", + ), + }, + ], + }, + }); + const requiring = yield* executor.skills.create({ + owner: "user", + package: { files: packageFiles() }, + requirements: [ + { kind: "skill", name: "helper-skill", owner: null }, + { kind: "runtime", command: "pdftotext", version: null }, + { kind: "integration", integration: "missing-api", toolPatterns: [] }, + ], + }); + const detail = yield* executor.skills.get({ skillId: requiring.id }); + expect(detail.requirementStatuses.map(({ status }) => status)).toEqual([ + "satisfied", + "unknown", + "missing", + ]); + expect(yield* executor.integrations.list()).toEqual([]); + }), + ); + + it.effect("exports valid packages portably and blocked packages only as managed backups", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const valid = yield* executor.skills.create({ + owner: "user", + package: { files: packageFiles() }, + }); + const portable = yield* executor.skills.export({ skillId: valid.id, kind: "portable" }); + expect(portable.kind).toBe("portable"); + expect(portable.files.find(({ path }) => path === "assets/icon.bin")?.bytes).toEqual( + Uint8Array.from([0, 255, 4, 8]), + ); + + const blocked = yield* executor.skills.create({ + owner: "user", + package: { + files: [{ path: "SKILL.md", bytes: encoder.encode("---\nname: [\n---\nbody") }], + }, + }); + const rejected = yield* executor.skills + .export({ skillId: blocked.id, kind: "portable" }) + .pipe(Effect.result); + expect(Result.isFailure(rejected)).toBe(true); + if (Result.isSuccess(rejected)) return; + expect(Predicate.isTagged("PortableSkillExportRejectedError")(rejected.failure)).toBe(true); + + const backup = yield* executor.skills.export({ skillId: blocked.id, kind: "backup" }); + expect(backup.kind).toBe("backup"); + if (backup.kind !== "backup") return; + expect(backup.skill.id).toBe(blocked.id); + expect(backup.files).toHaveLength(1); + }), + ); +}); diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index e079f5ab8a..16b004a12d 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -25,6 +25,7 @@ import type { AuthTemplateSlug, ConnectionName, IntegrationSlug, + ManagedSkillId, Owner, ProviderItemId, ProviderKey, @@ -62,6 +63,7 @@ import type { UpdateToolPolicyInput, } from "./policies"; import type { Tool, ToolAnnotations, ToolDef } from "./tool"; +import type { ManagedSkill, ManagedSkillSummary } from "./managed-skill"; // --------------------------------------------------------------------------- // OwnerBinding — replaces v1's scope stack. The (tenant, subject?) the executor @@ -153,6 +155,10 @@ export interface PreparedToolPolicy { readonly dynamicScope?: readonly DynamicToolScope[]; } +export interface SkillCatalogProvider { + readonly listAllowedSkillIds: () => Effect.Effect, StorageFailure>; +} + // --------------------------------------------------------------------------- // IntegrationRecord — the catalog row a plugin reads back (its own opaque // `config` included). Returned by `ctx.core.integrations.get`. @@ -230,6 +236,10 @@ export interface PluginCtx { input: RemoveToolPolicyInput, ) => Effect.Effect; }; + readonly skills: { + readonly list: () => Effect.Effect; + readonly get: (skillId: ManagedSkillId) => Effect.Effect; + }; }; /** Saved credentials. A connection IS the credential; resolve its value @@ -738,6 +748,10 @@ export interface PluginSpec< ctx: PluginCtx, ) => ToolPolicyProvider | null | Effect.Effect; + readonly skillCatalogProvider?: ( + ctx: PluginCtx, + ) => SkillCatalogProvider | null | Effect.Effect; + /** Produce a connection's tools (and shared $defs). The v2 successor to * registering per-source tools — called by the executor at connection * create / refresh / oauth.complete; the result is stamped with addresses diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 391c12e3fa..db47c67b71 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -11,6 +11,11 @@ // Branded ids + the owner literal. export { ArtifactId, + ManagedSkillId, + SkillCandidateId, + SkillRevisionId, + SkillPackageDigest, + SkillName, AuthTemplateSlug, ConnectionAddress, ConnectionName, @@ -28,6 +33,7 @@ export { ToolName, } from "./ids"; export { connectionIdentifier, isConnectionIdentifier } from "./connection-name-identifier"; +export { parseGitHubSkillInput, type GitHubSkillInput } from "./skill-source-input"; // Domain projections (types only — no runtime cost). export type { @@ -66,6 +72,18 @@ export { CredentialProviderNotRegisteredError, CredentialResolutionError, ArtifactNotFoundError, + ManagedSkillNotFoundError, + SkillRevisionNotFoundError, + SkillCandidateNotFoundError, + SkillCandidateExpiredError, + SkillSourceUnavailableError, + SkillCandidateMismatchError, + SkillUpdateConflictError, + SkillPackageRejectedError, + SkillRevisionConflictError, + SkillNameConflictError, + SkillInvalidTransitionError, + PortableSkillExportRejectedError, isUserActionableError, type ExecuteError, type ExecutorError, @@ -120,6 +138,58 @@ export type { } from "./artifact"; export { ARTIFACT_PREVIEW_MARKUP_LIMIT } from "./artifact-preview"; +export { + SkillInvocation, + SkillDelivery, + SkillSourceLocator, + SkillTracking, + SkillSource, + StagedSkillSource, + SkillUpdateFileChange, + SkillRequirement, + SkillRequirementStatus, + type SkillRevision, + type ManagedSkillSummary, + type ManagedSkill, + type CreateManagedSkillInput, + type StageSkillCandidateInput, + type ImportSkillCandidateInput, + type SkillCandidate, + type SkillUpdateReview, + type ReviewSkillCandidateInput, + type ApplySkillCandidateInput, + type SkillUpdateConflictResolution, + type EditManagedSkillInput, + type ExportManagedSkillInput, + type ReadManagedSkillFileInput, + type RestoreManagedSkillRevisionInput, + type RemoveManagedSkillInput, + type SetManagedSkillDeliveryInput, + type SetManagedSkillSourceInput, + type ManagedSkillSourceChange, + type SetManagedSkillRequirementsInput, + type ManagedSkillFile, + type ManagedSkillExportFile, + type ManagedSkillExport, +} from "./managed-skill"; +export { + SkillDiagnosticSeverity, + SkillDiagnostic, + SkillPackageManifestFile, + prepareSkillPackage, + isValidSkillName, + isSafeSkillFilePath, + SKILL_MD_PATH, + SKILL_MAX_FILES, + SKILL_MAX_FILE_BYTES, + SKILL_MAX_TOTAL_BYTES, + SKILL_MAX_PATH_BYTES, + SKILL_MAX_PATH_SEGMENTS, + type SkillPackageFileInput, + type PreparedSkillRevision, + type PreparedSkillPackage, +} from "./skill-package"; + // Schema-side views + onboarding autodetect. export { ToolSchemaView, IntegrationDetectionResult } from "./types"; diff --git a/packages/core/sdk/src/skill-package-repository.ts b/packages/core/sdk/src/skill-package-repository.ts new file mode 100644 index 0000000000..79e299ee47 --- /dev/null +++ b/packages/core/sdk/src/skill-package-repository.ts @@ -0,0 +1,68 @@ +import { Effect, Encoding, Result } from "effect"; + +import type { BlobStore } from "./blob"; +import { StorageError } from "./fuma-runtime"; +import type { SkillPackageManifestFile, PreparedSkillRevision } from "./skill-package"; + +const namespaceFor = (ownerPartition: string): string => `${ownerPartition}/skills`; + +const digestBytes = (bytes: Uint8Array): Effect.Effect => + Effect.promise(async () => { + const input = new Uint8Array(bytes.byteLength); + input.set(bytes); + const digest = await crypto.subtle.digest("SHA-256", input.buffer); + return `sha256:${Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join("")}`; + }); + +export interface SkillPackageRepository { + readonly put: ( + ownerPartition: string, + revision: PreparedSkillRevision, + ) => Effect.Effect; + readonly read: ( + ownerPartition: string, + file: SkillPackageManifestFile, + ) => Effect.Effect; +} + +export const makeSkillPackageRepository = (store: BlobStore): SkillPackageRepository => ({ + put: (ownerPartition, revision) => + Effect.forEach( + revision.files, + (file) => store.put(namespaceFor(ownerPartition), file.digest, file.encodedBytes), + { concurrency: 8, discard: true }, + ), + read: (ownerPartition, file) => + Effect.gen(function* () { + const encoded = yield* store.get(namespaceFor(ownerPartition), file.digest); + if (encoded === null) { + return yield* new StorageError({ + message: `Managed skill blob is missing for ${file.path}.`, + cause: undefined, + }); + } + const decoded = Encoding.decodeBase64(encoded); + if (Result.isFailure(decoded)) { + return yield* new StorageError({ + message: `Managed skill blob is not valid base64 for ${file.path}.`, + cause: decoded.failure, + }); + } + if (decoded.success.byteLength !== file.size) { + return yield* new StorageError({ + message: `Managed skill blob size does not match for ${file.path}.`, + cause: undefined, + }); + } + const digest = yield* digestBytes(decoded.success); + if (digest !== file.digest) { + return yield* new StorageError({ + message: `Managed skill blob digest does not match for ${file.path}.`, + cause: undefined, + }); + } + return decoded.success; + }), +}); diff --git a/packages/core/sdk/src/skill-package.test.ts b/packages/core/sdk/src/skill-package.test.ts new file mode 100644 index 0000000000..3b546d5d8c --- /dev/null +++ b/packages/core/sdk/src/skill-package.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + SKILL_MAX_FILE_BYTES, + defaultSkillInvocation, + prepareSkillPackage, + readPreparedSkillFile, + type SkillPackageFileInput, +} from "./skill-package"; + +const encoder = new TextEncoder(); + +const skillMarkdown = ( + frontmatter = "name: pdf-processing\ndescription: Extract text from PDFs.", +) => encoder.encode(`---\n${frontmatter}\n---\n\n# PDF processing\n`); + +const validPackage = (): readonly SkillPackageFileInput[] => [ + { path: "SKILL.md", bytes: skillMarkdown() }, + { path: "assets/icon.png", bytes: Uint8Array.from([0, 255, 1, 2]) }, + { path: "scripts/extract.py", bytes: encoder.encode("print('never executed')\n") }, +]; + +describe("prepareSkillPackage", () => { + it.effect("preserves text and binary bytes in a valid portable package", () => + Effect.gen(function* () { + const result = yield* prepareSkillPackage([...validPackage()].reverse()); + + expect(result.kind).toBe("valid"); + if (result.kind !== "valid") return; + expect(result.revision.name).toBe("pdf-processing"); + expect(result.revision.description).toBe("Extract text from PDFs."); + expect(result.revision.files.map((file) => file.path)).toEqual([ + "SKILL.md", + "assets/icon.png", + "scripts/extract.py", + ]); + expect(result.revision.files.every((file) => /^sha256:[0-9a-f]{64}$/.test(file.digest))).toBe( + true, + ); + expect(yield* readPreparedSkillFile(result.revision, "assets/icon.png")).toEqual( + Uint8Array.from([0, 255, 1, 2]), + ); + }), + ); + + it.effect("validates the model invocation preference", () => + Effect.gen(function* () { + for (const disabled of [true, false]) { + const result = yield* prepareSkillPackage([ + { + path: "SKILL.md", + bytes: skillMarkdown( + `name: pdf-processing\ndescription: Extract text from PDFs.\ndisable-model-invocation: ${disabled}`, + ), + }, + ]); + expect(result.kind).toBe("valid"); + if (result.kind !== "valid") continue; + expect(defaultSkillInvocation(result.revision.frontmatter)).toBe( + disabled ? "manual" : "model", + ); + } + + const invalid = yield* prepareSkillPackage([ + { + path: "SKILL.md", + bytes: skillMarkdown( + "name: pdf-processing\ndescription: Extract text from PDFs.\ndisable-model-invocation: sometimes", + ), + }, + ]); + expect(invalid.kind).toBe("blocked"); + if (invalid.kind !== "blocked") return; + expect(invalid.revision.diagnostics.map(({ code }) => code)).toContain( + "disable_model_invocation_invalid", + ); + }), + ); + + it.effect("keeps a safe nonportable package as a blocked revision", () => + Effect.gen(function* () { + const malformed = encoder.encode("---\nname: [\ndescription: nope\n---\nbody"); + const result = yield* prepareSkillPackage([{ path: "SKILL.md", bytes: malformed }]); + + expect(result.kind).toBe("blocked"); + if (result.kind !== "blocked") return; + expect(result.revision.diagnostics.map((diagnostic) => diagnostic.code)).toContain( + "frontmatter_invalid_yaml", + ); + expect(yield* readPreparedSkillFile(result.revision, "SKILL.md")).toEqual(malformed); + }), + ); + + it.effect("rejects paths and limits that are unsafe to store", () => + Effect.gen(function* () { + const cases: readonly (readonly SkillPackageFileInput[])[] = [ + [{ path: "../SKILL.md", bytes: skillMarkdown() }], + [ + { path: "SKILL.md", bytes: skillMarkdown() }, + { path: "SKILL.md", bytes: skillMarkdown() }, + ], + [{ path: "SKILL.md", bytes: new Uint8Array(SKILL_MAX_FILE_BYTES + 1) }], + ]; + + for (const files of cases) { + const result = yield* prepareSkillPackage(files); + expect(result.kind).toBe("rejected"); + } + }), + ); + + it.effect("blocks paths that collide on common native filesystems", () => + Effect.gen(function* () { + const result = yield* prepareSkillPackage([ + { path: "SKILL.md", bytes: skillMarkdown() }, + { path: "References/API.md", bytes: encoder.encode("one") }, + { path: "references/api.md", bytes: encoder.encode("two") }, + ]); + + expect(result.kind).toBe("blocked"); + if (result.kind !== "blocked") return; + expect(result.revision.diagnostics.map((diagnostic) => diagnostic.code)).toContain( + "path_portability_collision", + ); + }), + ); +}); diff --git a/packages/core/sdk/src/skill-package.ts b/packages/core/sdk/src/skill-package.ts new file mode 100644 index 0000000000..82a634560a --- /dev/null +++ b/packages/core/sdk/src/skill-package.ts @@ -0,0 +1,467 @@ +import { Effect, Encoding, Option, Result, Schema } from "effect"; +import { parse as parseYaml } from "yaml"; + +import { SkillPackageDigest, SkillName } from "./ids"; + +export const SKILL_MD_PATH = "SKILL.md"; +export const SKILL_MAX_FILES = 64; +export const SKILL_MAX_FILE_BYTES = 512 * 1024; +export const SKILL_MAX_TOTAL_BYTES = 1024 * 1024; +export const SKILL_MAX_PATH_BYTES = 512; +export const SKILL_MAX_PATH_SEGMENTS = 32; +export const SKILL_NAME_MAX_LENGTH = 64; +export const SKILL_DESCRIPTION_MAX_LENGTH = 1024; +export const SKILL_COMPATIBILITY_MAX_LENGTH = 500; + +const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const DRIVE_PREFIX = /^[A-Za-z]:/; +const URI_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:/; +const encoder = new TextEncoder(); +const strictDecoder = new TextDecoder("utf-8", { fatal: true }); + +const hasControlCharacter = (value: string): boolean => + Array.from(value).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || codePoint === 0x7f; + }); + +export interface SkillPackageFileInput { + readonly path: string; + readonly bytes: Uint8Array; + readonly mediaType?: string; +} + +export const SkillDiagnosticSeverity = Schema.Literals(["warning", "blocking"]); +export type SkillDiagnosticSeverity = typeof SkillDiagnosticSeverity.Type; + +export const SkillDiagnostic = Schema.Struct({ + severity: SkillDiagnosticSeverity, + code: Schema.String, + message: Schema.String, + path: Schema.NullOr(Schema.String), +}); +export type SkillDiagnostic = typeof SkillDiagnostic.Type; + +export const SkillPackageManifestFile = Schema.Struct({ + path: Schema.String, + size: Schema.Number, + digest: Schema.String, + mediaType: Schema.String, + encoding: Schema.Literal("base64"), +}); +export type SkillPackageManifestFile = typeof SkillPackageManifestFile.Type; + +export interface PreparedSkillFile extends SkillPackageManifestFile { + readonly encodedBytes: string; +} + +export interface PreparedSkillRevision { + readonly packageDigest: SkillPackageDigest; + readonly name: SkillName | null; + readonly description: string | null; + readonly frontmatter: Readonly> | null; + readonly files: readonly PreparedSkillFile[]; + readonly diagnostics: readonly SkillDiagnostic[]; +} + +export type PreparedSkillPackage = + | { + readonly kind: "rejected"; + readonly diagnostics: readonly [SkillDiagnostic, ...SkillDiagnostic[]]; + } + | { readonly kind: "blocked"; readonly revision: PreparedSkillRevision } + | { readonly kind: "valid"; readonly revision: PreparedSkillRevision }; + +export class SkillPackageFileNotFoundError extends Schema.TaggedErrorClass()( + "SkillPackageFileNotFoundError", + { path: Schema.String }, +) {} + +export class SkillPackageCorruptError extends Schema.TaggedErrorClass()( + "SkillPackageCorruptError", + { path: Schema.String, reason: Schema.String }, +) {} + +const diagnostic = ( + severity: SkillDiagnosticSeverity, + code: string, + message: string, + path: string | null = null, +): SkillDiagnostic => ({ severity, code, message, path }); + +const rejected = (code: string, message: string, path: string | null = null) => + ({ + kind: "rejected", + diagnostics: [diagnostic("blocking", code, message, path)], + }) satisfies PreparedSkillPackage; + +export const isValidSkillName = (name: string): boolean => + name.length >= 1 && name.length <= SKILL_NAME_MAX_LENGTH && SKILL_NAME_PATTERN.test(name); + +export const defaultSkillInvocation = ( + frontmatter: Readonly> | null, +): "manual" | "model" => (frontmatter?.["disable-model-invocation"] === true ? "manual" : "model"); + +export const isSafeSkillFilePath = (path: string): boolean => { + if (path.length === 0 || encoder.encode(path).byteLength > SKILL_MAX_PATH_BYTES) return false; + if ( + path.startsWith("/") || + path.includes("\\") || + hasControlCharacter(path) || + DRIVE_PREFIX.test(path) || + URI_PREFIX.test(path) + ) { + return false; + } + const segments = path.split("/"); + return ( + segments.length <= SKILL_MAX_PATH_SEGMENTS && + segments.every((segment) => segment !== "" && segment !== "." && segment !== "..") + ); +}; + +const mediaTypeFor = (path: string, supplied: string | undefined): string => { + if (supplied !== undefined && supplied.trim() !== "") return supplied; + const lower = path.toLowerCase(); + if (lower.endsWith(".md")) return "text/markdown; charset=utf-8"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/yaml"; + if (lower.endsWith(".txt")) return "text/plain; charset=utf-8"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + return "application/octet-stream"; +}; + +const digestBytes = (bytes: Uint8Array): Effect.Effect => + Effect.promise(async () => { + const input = new Uint8Array(bytes.byteLength); + input.set(bytes); + const digest = await crypto.subtle.digest("SHA-256", input.buffer); + return `sha256:${Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join("")}`; + }); + +const decodeUtf8 = (bytes: Uint8Array): Result.Result => + Result.try({ + try: () => strictDecoder.decode(bytes), + catch: () => undefined, + }); + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +interface FrontmatterBlock { + readonly yaml: string; +} + +const splitFrontmatter = (markdown: string): Option.Option => { + const text = markdown.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n"); + const lines = text.split("\n"); + if (lines[0]?.trim() !== "---") return Option.none(); + const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---"); + return closing === -1 ? Option.none() : Option.some({ yaml: lines.slice(1, closing).join("\n") }); +}; + +interface FrontmatterProjection { + readonly frontmatter: Readonly> | null; + readonly name: SkillName | null; + readonly description: string | null; + readonly diagnostics: readonly SkillDiagnostic[]; +} + +const parseFrontmatter = (bytes: Uint8Array): Effect.Effect => + Effect.sync(() => { + const decoded = decodeUtf8(bytes); + if (Result.isFailure(decoded)) { + return { + frontmatter: null, + name: null, + description: null, + diagnostics: [ + diagnostic( + "blocking", + "skill_markdown_invalid_utf8", + "SKILL.md must use UTF-8 encoding.", + SKILL_MD_PATH, + ), + ], + }; + } + + const split = splitFrontmatter(decoded.success); + if (Option.isNone(split)) { + return { + frontmatter: null, + name: null, + description: null, + diagnostics: [ + diagnostic( + "blocking", + "frontmatter_missing", + "SKILL.md must start with YAML frontmatter between `---` lines.", + SKILL_MD_PATH, + ), + ], + }; + } + + const parsedResult = Result.try({ + try: (): unknown => + parseYaml(split.value.yaml, { + maxAliasCount: 50, + schema: "core", + uniqueKeys: true, + }), + catch: () => undefined, + }); + if (Result.isFailure(parsedResult) || !isRecord(parsedResult.success)) { + return { + frontmatter: null, + name: null, + description: null, + diagnostics: [ + diagnostic( + "blocking", + "frontmatter_invalid_yaml", + "SKILL.md frontmatter must be a valid YAML mapping.", + SKILL_MD_PATH, + ), + ], + }; + } + const parsed = parsedResult.success; + + const diagnostics: SkillDiagnostic[] = []; + const rawName = parsed.name; + const name = + typeof rawName === "string" && isValidSkillName(rawName) ? SkillName.make(rawName) : null; + if (name === null) { + diagnostics.push( + diagnostic( + "blocking", + "name_invalid", + "Frontmatter `name` must contain 1 to 64 lowercase letters, digits, or single hyphens.", + SKILL_MD_PATH, + ), + ); + } + + const rawDescription = parsed.description; + const description = + typeof rawDescription === "string" && + rawDescription.trim() !== "" && + rawDescription.length <= SKILL_DESCRIPTION_MAX_LENGTH + ? rawDescription.trim() + : null; + if (description === null) { + diagnostics.push( + diagnostic( + "blocking", + "description_invalid", + `Frontmatter \`description\` must contain 1 to ${SKILL_DESCRIPTION_MAX_LENGTH} characters.`, + SKILL_MD_PATH, + ), + ); + } + + if ( + "compatibility" in parsed && + (typeof parsed.compatibility !== "string" || + parsed.compatibility.length === 0 || + parsed.compatibility.length > SKILL_COMPATIBILITY_MAX_LENGTH) + ) { + diagnostics.push( + diagnostic( + "blocking", + "compatibility_invalid", + `Frontmatter \`compatibility\` must contain 1 to ${SKILL_COMPATIBILITY_MAX_LENGTH} characters.`, + SKILL_MD_PATH, + ), + ); + } + if ("license" in parsed && typeof parsed.license !== "string") { + diagnostics.push( + diagnostic( + "blocking", + "license_invalid", + "Frontmatter `license` must be a string.", + SKILL_MD_PATH, + ), + ); + } + if ("allowed-tools" in parsed && typeof parsed["allowed-tools"] !== "string") { + diagnostics.push( + diagnostic( + "blocking", + "allowed_tools_invalid", + "Frontmatter `allowed-tools` must be a string.", + SKILL_MD_PATH, + ), + ); + } + if ( + "disable-model-invocation" in parsed && + typeof parsed["disable-model-invocation"] !== "boolean" + ) { + diagnostics.push( + diagnostic( + "blocking", + "disable_model_invocation_invalid", + "Frontmatter `disable-model-invocation` must be a boolean.", + SKILL_MD_PATH, + ), + ); + } + if ( + "metadata" in parsed && + (!isRecord(parsed.metadata) || + Object.values(parsed.metadata).some((value) => typeof value !== "string")) + ) { + diagnostics.push( + diagnostic( + "blocking", + "metadata_invalid", + "Frontmatter `metadata` must map string keys to string values.", + SKILL_MD_PATH, + ), + ); + } + + return { frontmatter: parsed, name, description, diagnostics }; + }); + +const comparePaths = (left: SkillPackageFileInput, right: SkillPackageFileInput): number => + left.path === SKILL_MD_PATH + ? -1 + : right.path === SKILL_MD_PATH + ? 1 + : left.path.localeCompare(right.path); + +const portablePathKey = (path: string): string => path.normalize("NFC").toLocaleLowerCase("en-US"); + +export const prepareSkillPackage = ( + inputs: readonly SkillPackageFileInput[], +): Effect.Effect => + Effect.gen(function* () { + if (inputs.length === 0) return rejected("package_empty", "A skill package has no files."); + if (inputs.length > SKILL_MAX_FILES) { + return rejected( + "package_too_many_files", + `A skill package can contain at most ${SKILL_MAX_FILES} files.`, + ); + } + + const exactPaths = new Set(); + const portablePaths = new Map(); + const diagnostics: SkillDiagnostic[] = []; + let totalBytes = 0; + + for (const input of inputs) { + if (!isSafeSkillFilePath(input.path)) { + return rejected( + "path_unsafe", + `File path "${input.path}" is not a safe relative POSIX path.`, + input.path, + ); + } + if (exactPaths.has(input.path)) { + return rejected( + "path_duplicate", + `File path "${input.path}" appears more than once.`, + input.path, + ); + } + exactPaths.add(input.path); + if (input.bytes.byteLength > SKILL_MAX_FILE_BYTES) { + return rejected( + "file_too_large", + `File "${input.path}" exceeds the ${SKILL_MAX_FILE_BYTES} byte limit.`, + input.path, + ); + } + totalBytes += input.bytes.byteLength; + if (totalBytes > SKILL_MAX_TOTAL_BYTES) { + return rejected( + "package_too_large", + `The package exceeds the ${SKILL_MAX_TOTAL_BYTES} byte limit.`, + ); + } + + const portableKey = portablePathKey(input.path); + const priorPath = portablePaths.get(portableKey); + if (priorPath !== undefined && priorPath !== input.path) { + diagnostics.push( + diagnostic( + "blocking", + "path_portability_collision", + `Paths "${priorPath}" and "${input.path}" collide on common native filesystems.`, + input.path, + ), + ); + } else { + portablePaths.set(portableKey, input.path); + } + } + + const skillMarkdown = inputs.find((input) => input.path === SKILL_MD_PATH); + if (skillMarkdown === undefined) { + return rejected("skill_markdown_missing", "A skill package must contain root SKILL.md."); + } + + const frontmatter = yield* parseFrontmatter(skillMarkdown.bytes); + diagnostics.push(...frontmatter.diagnostics); + + const files: PreparedSkillFile[] = []; + for (const input of [...inputs].sort(comparePaths)) { + files.push({ + path: input.path, + size: input.bytes.byteLength, + digest: yield* digestBytes(input.bytes), + mediaType: mediaTypeFor(input.path, input.mediaType), + encoding: "base64", + encodedBytes: Encoding.encodeBase64(input.bytes), + }); + } + + const packageDigest = SkillPackageDigest.make( + yield* digestBytes( + encoder.encode( + JSON.stringify(files.map(({ path, size, digest }) => ({ path, size, digest }))), + ), + ), + ); + const revision: PreparedSkillRevision = { + packageDigest, + name: frontmatter.name, + description: frontmatter.description, + frontmatter: frontmatter.frontmatter, + files, + diagnostics, + }; + return diagnostics.some(({ severity }) => severity === "blocking") + ? { kind: "blocked", revision } + : { kind: "valid", revision }; + }); + +export const readPreparedSkillFile = ( + revision: PreparedSkillRevision, + path: string, +): Effect.Effect => + Effect.gen(function* () { + const file = revision.files.find((candidate) => candidate.path === path); + if (file === undefined) return yield* new SkillPackageFileNotFoundError({ path }); + const decoded = Encoding.decodeBase64(file.encodedBytes); + if (Result.isFailure(decoded)) { + return yield* new SkillPackageCorruptError({ path, reason: "Stored base64 is invalid." }); + } + if (decoded.success.byteLength !== file.size) { + return yield* new SkillPackageCorruptError({ path, reason: "Stored size does not match." }); + } + const digest = yield* digestBytes(decoded.success); + if (digest !== file.digest) { + return yield* new SkillPackageCorruptError({ path, reason: "Stored digest does not match." }); + } + return decoded.success; + }); diff --git a/packages/core/sdk/src/skill-source-input.test.ts b/packages/core/sdk/src/skill-source-input.test.ts new file mode 100644 index 0000000000..c1ebb0b4d6 --- /dev/null +++ b/packages/core/sdk/src/skill-source-input.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Option } from "effect"; + +import { parseGitHubSkillInput } from "./skill-source-input"; + +describe("parseGitHubSkillInput", () => { + it("accepts the install forms used by GitHub and skills.sh", () => { + expect(Option.getOrNull(parseGitHubSkillInput("owner/repo/skills/pdf"))).toEqual({ + owner: "owner", + repository: "repo", + requestedRef: null, + directory: "skills/pdf", + selectedSkills: [], + }); + expect( + Option.getOrNull( + parseGitHubSkillInput("npx skills add https://skills.sh/owner/repo --skill pdf,csv"), + ), + ).toMatchObject({ owner: "owner", repository: "repo", selectedSkills: ["pdf", "csv"] }); + expect( + Option.getOrNull( + parseGitHubSkillInput("https://github.com/owner/repo/blob/v1/skills/pdf/SKILL.md"), + ), + ).toMatchObject({ requestedRef: "v1", directory: "skills/pdf" }); + }); + + it("rejects unsupported hosts and parent traversal", () => { + expect(Option.isNone(parseGitHubSkillInput("https://example.com/owner/repo"))).toBe(true); + expect(Option.isNone(parseGitHubSkillInput("owner/repo/../secret"))).toBe(true); + }); +}); diff --git a/packages/core/sdk/src/skill-source-input.ts b/packages/core/sdk/src/skill-source-input.ts new file mode 100644 index 0000000000..45868f4fcb --- /dev/null +++ b/packages/core/sdk/src/skill-source-input.ts @@ -0,0 +1,112 @@ +import { Option } from "effect"; + +export interface GitHubSkillInput { + readonly owner: string; + readonly repository: string; + readonly requestedRef: string | null; + readonly directory: string; + readonly selectedSkills: readonly string[]; +} + +const segmentPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +const validSegment = (value: string | undefined): value is string => + value !== undefined && value !== "." && value !== ".." && segmentPattern.test(value); + +const commandLocation = ( + input: string, +): { readonly location: string; readonly selectedSkills: readonly string[] } => { + const ignored = new Set([ + "npx", + "bunx", + "pnpx", + "pnpm", + "yarn", + "bun", + "npm", + "dlx", + "x", + "skills", + "skill", + "skillshare", + "gh", + "add", + "install", + "i", + "-y", + "--yes", + ]); + const tokens = input.split(/\s+/).filter(Boolean); + const selectedSkills: string[] = []; + let location = ""; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] ?? ""; + if (token === "--skill" || token === "--skills" || token === "-s") { + const value = tokens[index + 1]; + if (value !== undefined && !value.startsWith("-")) { + selectedSkills.push(...value.split(",")); + index += 1; + } + continue; + } + const inline = /^--skills?=(.+)$/.exec(token)?.[1]; + if (inline !== undefined) { + selectedSkills.push(...inline.split(",")); + continue; + } + if (token.startsWith("-") || ignored.has(token.toLowerCase())) continue; + if (location === "") location = token; + } + return { + location: location.replace(/^['"]|['"]$/g, ""), + selectedSkills: selectedSkills.map((name) => name.trim()).filter(Boolean), + }; +}; + +export const parseGitHubSkillInput = (input: string): Option.Option => { + const parsed = commandLocation(input.trim()); + if (parsed.location === "") return Option.none(); + let segments: string[]; + let hosted = false; + const first = parsed.location.split("/")[0]?.toLowerCase() ?? ""; + const shorthand = + /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+(\/|$)/.test(parsed.location) && + !parsed.location.includes("://") && + !first.includes("."); + if (shorthand) { + segments = parsed.location.split("?")[0]?.split("/") ?? []; + } else { + const withScheme = /^[a-z]+:\/\//i.test(parsed.location) + ? parsed.location + : `https://${parsed.location}`; + const result = Option.liftThrowable((value: string) => new URL(value))(withScheme); + if (Option.isNone(result)) return Option.none(); + const host = result.value.hostname.toLowerCase().replace(/^www\./, ""); + if (host !== "github.com" && host !== "skills.sh") return Option.none(); + hosted = true; + segments = result.value.pathname.split("/").slice(1); + } + + const [owner, rawRepository, ...rest] = segments; + const repository = rawRepository?.replace(/\.git$/, ""); + if (!validSegment(owner) || !validSegment(repository)) return Option.none(); + let requestedRef: string | null = null; + let directorySegments = rest; + if (hosted && (rest[0] === "tree" || rest[0] === "blob") && rest.length >= 2) { + requestedRef = rest[1] ?? null; + directorySegments = rest.slice(2); + if (rest[0] === "blob" && directorySegments.at(-1) === "SKILL.md") { + directorySegments = directorySegments.slice(0, -1); + } + } + if (directorySegments.some((segment) => segment === "." || segment === "..")) { + return Option.none(); + } + return Option.some({ + owner, + repository, + requestedRef, + directory: directorySegments.filter(Boolean).join("/"), + selectedSkills: parsed.selectedSkills, + }); +}; diff --git a/packages/hosts/mcp/src/artifacts-tools.test.ts b/packages/hosts/mcp/src/artifacts-tools.test.ts index a761f12bb8..b92e9662c7 100644 --- a/packages/hosts/mcp/src/artifacts-tools.test.ts +++ b/packages/hosts/mcp/src/artifacts-tools.test.ts @@ -533,7 +533,7 @@ describe("MCP host — artifact tool visibility", () => { ); }); - it("drops the artifact skills from an opted-out session's inventory", async () => { + it("drops the artifact guides from an opted-out session's inventory", async () => { const store = makeArtifactStore(); await withClient( makeStubEngine({}), diff --git a/packages/hosts/mcp/src/passthrough-tools.test.ts b/packages/hosts/mcp/src/passthrough-tools.test.ts index 491a623740..6bf56af2fd 100644 --- a/packages/hosts/mcp/src/passthrough-tools.test.ts +++ b/packages/hosts/mcp/src/passthrough-tools.test.ts @@ -454,7 +454,7 @@ describe("passthrough mode server", () => { "search", "skills", ]); - expect(JSON.stringify(listed).length).toBeLessThan(4000); + expect(JSON.stringify(listed).length).toBeLessThan(5000); expect(lists).toEqual([]); expect(schemaReads).toEqual([]); const result = await client.callTool({ diff --git a/packages/hosts/mcp/src/stdio-integration.test.ts b/packages/hosts/mcp/src/stdio-integration.test.ts index 2f28914d59..d8acf72f3d 100644 --- a/packages/hosts/mcp/src/stdio-integration.test.ts +++ b/packages/hosts/mcp/src/stdio-integration.test.ts @@ -5,8 +5,8 @@ import { LATEST_PROTOCOL_VERSION, type JSONRPCMessage, } from "@modelcontextprotocol/sdk/types.js"; -import { Effect, Option, Schema } from "effect"; -import { spawnSync } from "node:child_process"; +import { Data, Effect, Option, Schema } from "effect"; +import { spawn } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -15,6 +15,11 @@ const repoRoot = resolve(import.meta.dirname, "../../../.."); const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts"); const testScope = resolve(repoRoot, "apps/local"); +class TestDaemonStartError extends Data.TaggedError("TestDaemonStartError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + const decodeServerManifest = Schema.decodeUnknownOption( Schema.fromJsonString(Schema.Struct({ pid: Schema.optional(Schema.Number) })), ); @@ -43,15 +48,81 @@ const manifestPid = (dataDir: string): number | undefined => * back to a free port if this one is taken, and writes the port it chose into * the manifest that `executor mcp` reads, so the exact number is not load-bearing. */ -const startDaemon = (dataDir: string): void => { - const port = 20_000 + Math.floor(Math.random() * 20_000); - const result = spawnSync( - "bun", - ["run", cliEntry, "daemon", "run", "--port", String(port), "--hostname", "127.0.0.1"], - { env: { ...process.env, EXECUTOR_DATA_DIR: dataDir, EXECUTOR_SCOPE_DIR: testScope } }, - ); - expect(result.status, `daemon run failed: ${result.stderr?.toString() ?? ""}`).toBe(0); -}; +const startDaemon = (dataDir: string): Effect.Effect => + Effect.callback((resume) => { + const port = 20_000 + Math.floor(Math.random() * 20_000); + const child = spawn( + "bun", + [ + "run", + cliEntry, + "daemon", + "run", + "--foreground", + "--port", + String(port), + "--hostname", + "127.0.0.1", + ], + { + detached: true, + stdio: "ignore", + env: { ...process.env, EXECUTOR_DATA_DIR: dataDir, EXECUTOR_SCOPE_DIR: testScope }, + }, + ); + child.unref(); + + let completed = false; + let poll: ReturnType | null = null; + let timeout: ReturnType | null = null; + const cleanup = () => { + if (poll) clearInterval(poll); + if (timeout) clearTimeout(timeout); + child.off("error", failedToSpawn); + child.off("exit", exited); + }; + const finish = (effect: Effect.Effect) => { + if (completed) return; + completed = true; + cleanup(); + resume(effect); + }; + const failedToSpawn = (cause: Error) => + finish( + Effect.fail(new TestDaemonStartError({ message: "daemon process failed to start", cause })), + ); + const exited = (code: number | null, signal: NodeJS.Signals | null) => + finish( + Effect.fail( + new TestDaemonStartError({ + message: `daemon exited before readiness (code ${code}, signal ${signal})`, + }), + ), + ); + + poll = setInterval(() => { + const pid = child.pid; + if (pid !== undefined && manifestPid(dataDir) === pid) finish(Effect.void); + }, 50); + timeout = setTimeout(() => { + child.kill("SIGTERM"); + finish( + Effect.fail( + new TestDaemonStartError({ + message: "daemon did not publish its local server manifest within 30000ms", + }), + ), + ); + }, 30_000); + + child.once("error", failedToSpawn); + child.once("exit", exited); + + return Effect.sync(() => { + cleanup(); + child.kill("SIGTERM"); + }); + }); /** Stop the daemon started above; the manifest carries its pid. */ const stopDaemon = (dataDir: string): Effect.Effect => @@ -67,10 +138,12 @@ const stopDaemon = (dataDir: string): Effect.Effect => ); const withDaemon = Effect.acquireRelease( - Effect.sync(() => { + Effect.gen(function* () { const dataDir = mkdtempSync(join(tmpdir(), "executor-mcp-discover-test-")); - startDaemon(dataDir); - return dataDir; + return yield* startDaemon(dataDir).pipe( + Effect.as(dataDir), + Effect.tapError(() => Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))), + ); }), (dataDir) => stopDaemon(dataDir).pipe( diff --git a/packages/hosts/mcp/src/tool-server.test.ts b/packages/hosts/mcp/src/tool-server.test.ts index 04fd4fca28..a1559c60cd 100644 --- a/packages/hosts/mcp/src/tool-server.test.ts +++ b/packages/hosts/mcp/src/tool-server.test.ts @@ -6,6 +6,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; import type * as Cause from "effect/Cause"; +import * as z from "zod/v4"; import { ElicitationId, @@ -13,7 +14,9 @@ import { ToolAddress, ToolResult, UrlElicitation, + createExecutor, } from "@executor-js/sdk"; +import { makeTestConfig } from "@executor-js/sdk/testing"; import type { ToolFileValue } from "@executor-js/sdk"; import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; @@ -68,6 +71,7 @@ type TestServerConfig = Pick< | "pausedExecutionHooks" | "pausedExecutionLeaseMs" | "resumeFallback" + | "skills" >; /** Connect a real MCP Client to our executor MCP server over in-memory transports. */ @@ -1916,12 +1920,12 @@ describe("MCP host server — skills tool", () => { // `executor_skills` as the general skill reader they are missing, so the // description has to scope itself to this server before a model tries to // read a SKILL.md through it. - it("scopes the skills tool description to this server's own docs", async () => { + it("describes built-in guides and managed skills", async () => { await withClient(makeStubEngine({}), NO_CAPS, async (client) => { const { tools } = await client.listTools(); const description = tools.find((t) => t.name === "skills")?.description ?? ""; - expect(description).toContain("Not a general skill reader"); - expect(description).toContain("SKILL.md"); + expect(description).toContain("built-in"); + expect(description).toContain("managed Agent Skills"); }); }); @@ -1993,6 +1997,240 @@ describe("MCP host server — skills tool", () => { }); }); +describe("MCP host server — managed skills tool", () => { + it("advertises and serves the final MCP Skills extension contract", async () => { + const executor = await Effect.runPromise(createExecutor(makeTestConfig())); + const automatic = await Effect.runPromise( + executor.skills.create({ + owner: "org", + package: { + files: [ + { + path: "SKILL.md", + bytes: new TextEncoder().encode( + "---\nname: public-guide\ndescription: Discoverable instructions.\nlicense: MIT\n---\n\n# Public guide\n", + ), + }, + { path: "references/example.txt", bytes: new TextEncoder().encode("example") }, + ], + }, + delivery: { kind: "enabled", invocation: "model" }, + }), + ); + + const SkillResult = z.object({ + resultType: z.literal("complete"), + ttlMs: z.number(), + cacheScope: z.literal("private"), + skills: z.array( + z.object({ + uri: z.string(), + frontmatter: z.object({ name: z.string(), description: z.string() }).loose(), + resources: z.array(z.object({ uri: z.string(), digest: z.string(), size: z.number() })), + }), + ), + nextCursor: z.string().optional(), + }); + const GetResult = z.object({ + resultType: z.literal("complete"), + ttlMs: z.number(), + cacheScope: z.literal("private"), + skill: SkillResult.shape.skills.element, + }); + + await withClient( + makeStubEngine({}), + NO_CAPS, + async (client) => { + expect(client.getServerCapabilities()?.extensions).toMatchObject({ + "io.modelcontextprotocol/skills": {}, + }); + const listed = await client.request({ method: "skills/list", params: {} }, SkillResult); + const tools = await client.listTools(); + expect(tools.tools.find(({ name }) => name === "skills")?.description).toContain( + "`public-guide`", + ); + expect(listed.skills).toHaveLength(1); + expect(listed.skills[0]).toMatchObject({ + frontmatter: { + name: "public-guide", + description: "Discoverable instructions.", + license: "MIT", + }, + resources: [ + expect.objectContaining({ digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/) }), + expect.objectContaining({ digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/) }), + ], + }); + const entry = listed.skills[0]; + expectDefined(entry); + expect(entry.uri).toContain(`/${automatic.id}/public-guide/SKILL.md`); + const fetched = await client.request( + { method: "skills/get", params: { uri: entry.uri } }, + GetResult, + ); + expect(fetched.skill).toEqual(entry); + const supporting = entry.resources.find((resource) => resource.uri.endsWith("example.txt")); + expectDefined(supporting); + const resource = await client.readResource({ uri: supporting.uri }); + expect(resource.contents).toEqual([ + expect.objectContaining({ text: "example", mimeType: "text/plain; charset=utf-8" }), + ]); + const legacyResource = await client.readResource({ + uri: "skill://org/public-guide/references/example.txt", + }); + expect(legacyResource.contents).toEqual([ + expect.objectContaining({ text: "example", mimeType: "text/plain; charset=utf-8" }), + ]); + + const instructions = await client.callTool({ + name: "skills", + arguments: { name: "public-guide", owner: "org" }, + }); + expect(textOf(instructions)).toContain("references/example.txt"); + expect(textOf(instructions)).not.toContain("description: Discoverable instructions."); + + const bundledFile = await client.callTool({ + name: "skills", + arguments: { name: "public-guide", owner: "org", file: "references/example.txt" }, + }); + expect(textOf(bundledFile)).toBe("example"); + }, + { skills: executor.skills }, + ); + }); + + it("keeps manual skills out of discovery but permits an exact read", async () => { + const executor = await Effect.runPromise(createExecutor(makeTestConfig())); + const manual = await Effect.runPromise( + executor.skills.create({ + owner: "user", + package: { + files: [ + { + path: "SKILL.md", + bytes: new TextEncoder().encode( + "---\nname: private-guide\ndescription: Only when the user asks.\ndisable-model-invocation: true\n---\n\n# Private guide\n", + ), + }, + ], + }, + }), + ); + const automatic = await Effect.runPromise( + executor.skills.create({ + owner: "org", + package: { + files: [ + { + path: "SKILL.md", + bytes: new TextEncoder().encode( + "---\nname: public-guide\ndescription: Discoverable instructions.\n---\n\n# Public guide\n", + ), + }, + ], + }, + delivery: { kind: "enabled", invocation: "model" }, + }), + ); + + await withClient( + makeStubEngine({}), + NO_CAPS, + async (client) => { + const builtIn = await client.callTool({ + name: "skills", + arguments: { name: "execute" }, + }); + expect(textOf(builtIn)).toContain("## Workflow"); + + const search = await client.callTool({ name: "skills", arguments: {} }); + expect(textOf(search)).toContain("public-guide"); + expect(textOf(search)).not.toContain("private-guide"); + expect(search.structuredContent).toMatchObject({ total: 1, hasMore: false }); + expect(search.structuredContent).toMatchObject({ + items: [ + { + uri: `skill://executor/managed/${automatic.id}/${encodeURIComponent(String(automatic.revisions[0]?.packageDigest))}/SKILL.md`, + }, + ], + }); + + const exact = await client.callTool({ + name: "skills", + arguments: { ref: manual.id }, + }); + expect(textOf(exact)).toContain("# Private guide"); + + const automaticRead = await client.callTool({ + name: "skills", + arguments: { ref: automatic.id }, + }); + expect(textOf(automaticRead)).toContain("# Public guide"); + + const resources = await client.listResources(); + const skillResource = resources.resources.find((resource) => + resource.uri.includes(String(automatic.id)), + ); + expectDefined(skillResource); + const resource = await client.readResource({ uri: skillResource.uri }); + expect(resource.contents).toEqual([ + expect.objectContaining({ text: expect.stringContaining("# Public guide") }), + ]); + + await Effect.runPromise( + executor.skills.setDelivery({ + skillId: manual.id, + delivery: { kind: "enabled", invocation: "model" }, + }), + ); + const searchAfterOverride = await client.callTool({ name: "skills", arguments: {} }); + expect(textOf(searchAfterOverride)).toContain("private-guide"); + expect(searchAfterOverride.structuredContent).toMatchObject({ total: 2 }); + + const extensionAfterOverride = await client.request( + { method: "skills/list", params: {} }, + z + .object({ + skills: z.array( + z.object({ frontmatter: z.object({ name: z.string() }).loose() }).loose(), + ), + }) + .loose(), + ); + expect(extensionAfterOverride.skills.map(({ frontmatter }) => frontmatter.name)).toContain( + "private-guide", + ); + }, + { skills: executor.skills }, + ); + }); + + it("rejects mixed search and read selectors", async () => { + await withClient(makeStubEngine({}), NO_CAPS, async (client) => { + const result = await client.callTool({ + name: "skills", + arguments: { query: "guide", name: "public-guide" }, + }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain("Choose one skills operation"); + }); + }); + + it("rejects path and file together", async () => { + await withClient(makeStubEngine({}), NO_CAPS, async (client) => { + const result = await client.callTool({ + name: "skills", + arguments: { name: "public-guide", path: "SKILL.md", file: "SKILL.md" }, + }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain("Choose one skills operation"); + }); + }); +}); + describe("MCP host server — hang-visibility tracing", () => { it("execute emits a start marker and stamps the JSON-RPC id on execution spans", async () => { const engine = makeStubEngine({}); diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index f7c1a349f7..21256136ba 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -1,9 +1,11 @@ import { reattachDefs } from "@executor-js/sdk/host-internal"; -import { Data, Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect"; +import { Data, Duration, Effect, Encoding, Match, Option, Predicate, Result, Schema } from "effect"; import * as Cause from "effect/Cause"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ContentBlockSchema, + ErrorCode, + McpError, type ClientCapabilities, type ContentBlock, } from "@modelcontextprotocol/sdk/types.js"; @@ -23,6 +25,7 @@ import * as z from "zod/v4"; import { CurrentOrgWriteAccess, + ManagedSkillId, ToolAddress, IntegrationSlug, ConnectionName, @@ -48,6 +51,7 @@ import type { ToolFileValue, Executor, ToolSchemaView, + ManagedSkillSummary, } from "@executor-js/sdk"; import type * as Tracer from "effect/Tracer"; import { @@ -252,6 +256,12 @@ type SharedMcpServerConfig = { readonly connections?: McpConnectionsPort; /** Scoped integration metadata for the search/invoke account inventory. */ readonly integrations?: McpIntegrationsPort; + /** + * Live managed Agent Skills catalog. The port is optional for embedders that + * have not wired persistence yet; the skills tool still registers and + * reports that delivery is unavailable instead of hiding the capability. + */ + readonly skills?: McpSkillsPort; /** * Builds the web-app deep link for a saved artifact. Clients that can't * render MCP Apps get this URL instead of an inline widget. Absent (stdio has @@ -324,6 +334,9 @@ export type McpIntegrationsPort = { /** The same list and schema APIs used by codemode discovery. */ export type McpToolsPort = Pick; +/** The managed skill reads required by MCP delivery. */ +export type McpSkillsPort = Pick; + /** A passthrough session was requested but the host gave the factory no * catalog to serve. A configuration defect, not a runtime condition. */ export class McpPassthroughUnavailableError extends Data.TaggedError( @@ -923,7 +936,7 @@ const fallbackOutcomeResult = ( }; // The `skills` tool serves named, static how-to docs (see the execution -// package's skills registry). No name -> the index; a known name -> that +// package's guides registry). No name -> the index; a known name -> that // skill's body; an unknown name -> the index plus a not-found note so the model // retries with a listed name instead of the same miss. // @@ -943,9 +956,9 @@ const fallbackOutcomeResult = ( // sees what is connected without a second round trip. // // The catalog is per-session: a connection that opted out of artifacts never -// sees the artifact skills, so the index cannot advertise a how-to for tools it +// sees the artifact guides, so the index cannot advertise a how-to for tools it // does not have, and fetching one by name misses like any unknown skill. -const skillsResult = ( +const builtInSkillsResult = ( name: string | undefined, executeInventory: string, catalog: readonly Skill[], @@ -973,6 +986,692 @@ const skillsResult = ( return { content: [{ type: "text", text }] }; }; +interface ManagedSkillsToolInput { + readonly query?: string; + readonly limit?: number; + readonly offset?: number; + readonly ref?: string; + readonly name?: string; + readonly owner?: "user" | "org"; + readonly path?: string; + readonly file?: string; +} + +const managedSkillUri = (input: { + readonly id: string; + readonly packageDigest: string; + readonly path: string; +}): string => + `skill://executor/managed/${encodeURIComponent(input.id)}/${encodeURIComponent(input.packageDigest)}/${encodeURIComponent(input.path)}`; + +const SKILLS_EXTENSION = "io.modelcontextprotocol/skills"; +const SKILLS_PAGE_SIZE = 50; +const SKILLS_CACHE_TTL_MS = 30_000; +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + +const ListSkillsRequestSchema = z.object({ + method: z.literal("skills/list"), + params: z.object({ cursor: z.string().optional() }).loose().optional(), +}); + +const GetSkillRequestSchema = z.object({ + method: z.literal("skills/get"), + params: z.object({ uri: z.string() }).loose(), +}); + +const extensionSkillUri = (input: { + readonly id: string; + readonly name: string; + readonly path: string; +}): string => + `skill://executor/skills/${encodeURIComponent(input.id)}/${encodeURIComponent(input.name)}/${encodeURIComponent(input.path)}`; + +const legacyExtensionSkillUri = (input: { + readonly owner: "user" | "org"; + readonly name: string; + readonly path: string; +}): string => + `skill://${input.owner}/${encodeURIComponent(input.name)}/${encodeURIComponent(input.path)}`; + +type ExtensionSkill = { + readonly uri: string; + readonly frontmatter: Readonly> & { + readonly name: string; + readonly description: string; + }; + readonly resources: ReadonlyArray<{ + readonly uri: string; + readonly digest: string; + readonly size: number; + }>; +}; + +const extensionSkillEntries = ( + skills: McpSkillsPort, + invocation: "model" | "any", +): Effect.Effect => + Effect.gen(function* () { + const summaries = yield* skills.list(); + const eligible = summaries + .filter((skill) => skill.delivery.kind === "enabled") + .filter( + (skill) => + invocation === "any" || + (skill.delivery.kind === "enabled" && skill.delivery.invocation === "model"), + ) + .sort((left, right) => { + const byName = (left.name ?? "").localeCompare(right.name ?? ""); + if (byName !== 0) return byName; + if (left.owner !== right.owner) return left.owner === "user" ? -1 : 1; + return String(left.id).localeCompare(String(right.id)); + }); + return yield* Effect.forEach(eligible, (summary) => + Effect.gen(function* () { + const detail = yield* skills.get({ skillId: summary.id }); + const revision = detail.revisions.find((item) => item.id === detail.activeRevisionId); + if ( + !revision || + revision.name === null || + revision.description === null || + revision.frontmatter === null + ) { + return yield* new McpSkillResourceError({ reason: "Active skill revision is invalid" }); + } + const uriFor = (path: string) => + extensionSkillUri({ id: String(detail.id), name: String(revision.name), path }); + return { + uri: uriFor("SKILL.md"), + frontmatter: { + ...revision.frontmatter, + name: String(revision.name), + description: revision.description, + }, + resources: revision.files.map((file) => ({ + uri: uriFor(file.path), + digest: file.digest, + size: file.size, + })), + } satisfies ExtensionSkill; + }), + ); + }); + +const decodeSkillsCursor = (cursor: string | undefined): Effect.Effect => { + if (cursor === undefined) return Effect.succeed(0); + const offset = Number(cursor); + if (!Number.isSafeInteger(offset) || offset < 0) { + return Effect.fail(new McpError(ErrorCode.InvalidParams, "Invalid skills cursor")); + } + return Effect.succeed(offset); +}; + +const listExtensionSkills = ( + skills: McpSkillsPort, + cursor: string | undefined, +): Effect.Effect< + { + readonly resultType: "complete"; + readonly skills: readonly ExtensionSkill[]; + readonly ttlMs: number; + readonly cacheScope: "private"; + readonly nextCursor?: string; + }, + unknown +> => + Effect.gen(function* () { + const offset = yield* decodeSkillsCursor(cursor); + const entries = yield* extensionSkillEntries(skills, "model"); + const page = entries.slice(offset, offset + SKILLS_PAGE_SIZE); + const nextOffset = offset + page.length; + return { + resultType: "complete", + skills: page, + ttlMs: SKILLS_CACHE_TTL_MS, + cacheScope: "private", + ...(nextOffset < entries.length ? { nextCursor: String(nextOffset) } : {}), + }; + }); + +const getExtensionSkill = ( + skills: McpSkillsPort, + uri: string, +): Effect.Effect< + { + readonly resultType: "complete"; + readonly skill: ExtensionSkill; + readonly ttlMs: number; + readonly cacheScope: "private"; + }, + unknown +> => + Effect.gen(function* () { + const entries = yield* extensionSkillEntries(skills, "any"); + const skill = entries.find((entry) => entry.uri === uri); + if (!skill) { + // oxlint-disable-next-line executor/prefer-yield-tagged-error -- boundary: MCP SDK errors are not Effect yieldable errors + return yield* Effect.fail(new McpError(ErrorCode.InvalidParams, "Unknown managed skill URI")); + } + return { + resultType: "complete", + skill, + ttlMs: SKILLS_CACHE_TTL_MS, + cacheScope: "private", + }; + }); + +const managedSkillsResult = ( + input: ManagedSkillsToolInput, + skills: McpSkillsPort | undefined, +): Effect.Effect => + Effect.gen(function* () { + const requestedPath = input.path ?? input.file; + const hasReadSelector = input.ref !== undefined || input.name !== undefined; + const hasSearchSelector = + input.query !== undefined || input.limit !== undefined || input.offset !== undefined; + if ( + (input.ref !== undefined && input.name !== undefined) || + (hasReadSelector && hasSearchSelector) || + (input.path !== undefined && input.file !== undefined) || + (!hasReadSelector && (input.owner !== undefined || requestedPath !== undefined)) + ) { + return { + isError: true, + content: [ + { + type: "text" as const, + text: "Choose one skills operation: search with query/limit/offset, or read with ref or name and an optional owner/path.", + }, + ], + }; + } + + if (!skills) { + return { + isError: true, + content: [ + { + type: "text" as const, + text: "Managed skills are not available on this Executor host.", + }, + ], + }; + } + + const all = yield* skills.list(); + if (!hasReadSelector) { + const query = input.query?.trim().toLocaleLowerCase("en-US") ?? ""; + const limit = Math.min(50, Math.max(1, input.limit ?? 12)); + const offset = Math.max(0, input.offset ?? 0); + const eligible = all + .filter( + (skill) => skill.delivery.kind === "enabled" && skill.delivery.invocation === "model", + ) + .filter((skill) => { + if (query === "") return true; + return `${skill.name ?? ""}\n${skill.description ?? ""}` + .toLocaleLowerCase("en-US") + .includes(query); + }) + .sort((left, right) => { + const byName = (left.name ?? "").localeCompare(right.name ?? ""); + if (byName !== 0) return byName; + if (left.owner !== right.owner) return left.owner === "user" ? -1 : 1; + return String(left.id).localeCompare(String(right.id)); + }); + const selected = eligible.slice(offset, offset + limit); + const items = yield* Effect.forEach(selected, (skill) => + Effect.gen(function* () { + const detail = yield* skills.get({ skillId: skill.id }); + const revision = detail.revisions.find((item) => item.id === detail.activeRevisionId); + if (!revision) { + return yield* new McpSkillResourceError({ reason: "Active revision missing" }); + } + return { + ref: String(skill.id), + name: skill.name, + description: skill.description, + owner: skill.owner, + invocation: "model" as const, + revision: String(skill.activeRevisionId), + uri: managedSkillUri({ + id: String(skill.id), + packageDigest: String(revision.packageDigest), + path: "SKILL.md", + }), + }; + }), + ); + const nextOffset = + offset + selected.length < eligible.length ? offset + selected.length : null; + const page = { + items, + total: eligible.length, + hasMore: nextOffset !== null, + nextOffset, + diagnostics: [], + }; + const text = + items.length === 0 + ? "No model-invocable managed skills matched." + : items + .map( + (item) => + `${item.name ?? "unnamed"} (${item.owner}, ref ${item.ref}): ${item.description ?? "No description"}`, + ) + .join("\n"); + return { content: [{ type: "text" as const, text }], structuredContent: page }; + } + + const candidates = all + .filter((skill) => skill.delivery.kind === "enabled") + .filter((skill) => + input.ref !== undefined + ? String(skill.id) === input.ref + : skill.name === input.name && (input.owner === undefined || skill.owner === input.owner), + ) + .sort((left, right) => { + if (left.owner !== right.owner) return left.owner === "user" ? -1 : 1; + return String(left.id).localeCompare(String(right.id)); + }); + const selected = candidates[0]; + if (!selected) { + return { + isError: true, + content: [ + { type: "text" as const, text: "No enabled managed skill matched that selector." }, + ], + }; + } + const detail = yield* skills.get({ skillId: ManagedSkillId.make(String(selected.id)) }); + const revision = detail.revisions.find((item) => item.id === detail.activeRevisionId); + const path = requestedPath ?? "SKILL.md"; + const manifest = revision?.files.find((file) => file.path === path); + if (!revision || !manifest) { + return { + isError: true, + content: [ + { + type: "text" as const, + text: `The active skill package has no file named "${path}".`, + }, + ], + }; + } + const file = yield* skills.readFile({ + skillId: detail.id, + revisionId: revision.id, + path, + }); + const uri = managedSkillUri({ + id: String(detail.id), + packageDigest: String(revision.packageDigest), + path, + }); + const textual = + manifest.mediaType.startsWith("text/") || + manifest.mediaType.includes("json") || + manifest.mediaType.includes("yaml") || + manifest.mediaType.includes("xml") || + manifest.mediaType.includes("javascript"); + if (textual) { + const text = new TextDecoder().decode(file.bytes); + if (path !== "SKILL.md") return { content: [{ type: "text" as const, text }] }; + const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n"); + const lines = normalized.split("\n"); + const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---"); + const body = + lines[0]?.trim() === "---" && closing !== -1 + ? lines + .slice(closing + 1) + .join("\n") + .trim() + : normalized; + const resources = revision.files.filter((entry) => entry.path !== "SKILL.md"); + const resourceList = + resources.length === 0 + ? "" + : [ + "", + "", + ...resources.map( + (entry) => + ` ${entry.path.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")}`, + ), + "", + `Read a bundled file with \`skills({ name: "${selected.name}", owner: "${selected.owner}", file: "" })\`. Relative paths in the instructions above are relative to the skill's root.`, + ].join("\n"); + return { + content: [ + { + type: "text" as const, + text: `\n${body}${resourceList}\n`, + }, + ], + }; + } + return { + content: [ + { + type: "resource" as const, + resource: { + uri, + mimeType: manifest.mediaType, + blob: Encoding.encodeBase64(file.bytes), + }, + }, + ], + }; + }).pipe( + Effect.catch(() => + Effect.succeed({ + isError: true, + content: [ + { + type: "text" as const, + text: "Executor could not read the managed skills catalog.", + }, + ], + }), + ), + ); + +const skillsResult = ( + input: ManagedSkillsToolInput, + executeInventory: string, + catalog: readonly Skill[], + skills: McpSkillsPort | undefined, +): Effect.Effect => { + const hasOnlyNameSelector = + input.name !== undefined && + input.ref === undefined && + input.query === undefined && + input.limit === undefined && + input.offset === undefined && + input.owner === undefined && + input.path === undefined && + input.file === undefined; + if (hasOnlyNameSelector && findSkill(input.name?.trim() ?? "", catalog) !== undefined) { + return Effect.succeed(builtInSkillsResult(input.name, executeInventory, catalog)); + } + + const hasNoSelector = Object.values(input).every((value) => value === undefined); + if (skills === undefined) { + return hasNoSelector || hasOnlyNameSelector + ? Effect.succeed(builtInSkillsResult(input.name, executeInventory, catalog)) + : managedSkillsResult(input, skills); + } + if (!hasNoSelector) return managedSkillsResult(input, skills); + + return Effect.map(managedSkillsResult(input, skills), (managed) => { + const managedText = managed.content + .filter( + (content): content is Extract<(typeof managed.content)[number], { type: "text" }> => + content.type === "text", + ) + .map(({ text }) => text) + .join("\n"); + return { + ...managed, + content: [ + { + type: "text" as const, + text: `${renderSkillsIndex(catalog)}\n\n## Managed skills\n\n${managedText}`, + }, + ], + }; + }); +}; + +class McpSkillResourceError extends Data.TaggedError("McpSkillResourceError")<{ + readonly reason: string; +}> {} + +const modelSkillResources = ( + skills: McpSkillsPort, +): Effect.Effect< + { + resources: Array<{ uri: string; name: string; description?: string; mimeType: string }>; + }, + unknown +> => + Effect.gen(function* () { + const summaries = yield* skills.list(); + const eligible = summaries + .filter((skill) => skill.delivery.kind === "enabled" && skill.delivery.invocation === "model") + .sort((left, right) => { + const byName = (left.name ?? "").localeCompare(right.name ?? ""); + return byName !== 0 ? byName : String(left.id).localeCompare(String(right.id)); + }); + const resources = yield* Effect.forEach(eligible, (summary) => + Effect.gen(function* () { + const detail = yield* skills.get({ skillId: summary.id }); + const revision = detail.revisions.find((item) => item.id === detail.activeRevisionId); + if (!revision) + return yield* new McpSkillResourceError({ reason: "Active revision missing" }); + return { + uri: managedSkillUri({ + id: String(detail.id), + packageDigest: String(revision.packageDigest), + path: "SKILL.md", + }), + name: detail.name ?? "unnamed-skill", + ...(detail.description === null ? {} : { description: detail.description }), + mimeType: "text/markdown", + }; + }), + ); + return { resources }; + }); + +const MANAGED_SKILL_DESCRIPTION_LIMIT = 40; + +const managedSkillCatalogDescription = ( + skills: readonly ManagedSkillSummary[], +): readonly string[] => { + const eligible = skills + .filter( + (skill) => + skill.delivery.kind === "enabled" && + skill.delivery.invocation === "model" && + skill.name !== null, + ) + .sort((left, right) => { + const byName = (left.name ?? "").localeCompare(right.name ?? ""); + if (byName !== 0) return byName; + if (left.owner !== right.owner) return left.owner === "user" ? -1 : 1; + return String(left.id).localeCompare(String(right.id)); + }); + if (eligible.length === 0) return ["No managed skills currently allow model selection."]; + const shown = eligible.slice(0, MANAGED_SKILL_DESCRIPTION_LIMIT); + const hidden = eligible.length - shown.length; + return [ + "Managed skills available for model selection:", + ...shown.map( + (skill) => + `- \`${skill.name}\` (${skill.owner === "user" ? "personal" : "workspace"}): ${skill.description ?? "No description"}`, + ), + ...(hidden > 0 + ? [`- ${hidden} more. Call this tool with no arguments for the full index.`] + : []), + ]; +}; + +const readManagedSkillResource = ( + skills: McpSkillsPort, + variables: Readonly>, +): Effect.Effect< + { + contents: Array< + | { uri: string; mimeType: string; text: string } + | { uri: string; mimeType: string; blob: string } + >; + }, + unknown +> => + Effect.gen(function* () { + const scalar = (name: string): Effect.Effect => { + const value = variables[name]; + return typeof value === "string" + ? Effect.succeed(decodeURIComponent(value)) + : Effect.fail(new McpSkillResourceError({ reason: `Invalid ${name}` })); + }; + const skillId = ManagedSkillId.make(yield* scalar("skillId")); + const digest = yield* scalar("digest"); + const path = yield* scalar("path"); + const detail = yield* skills.get({ skillId }); + if (detail.delivery.kind !== "enabled") { + return yield* new McpSkillResourceError({ reason: "Skill delivery is not enabled" }); + } + const revision = detail.revisions.find((item) => String(item.packageDigest) === digest); + if (!revision) { + return yield* new McpSkillResourceError({ reason: "Skill revision not found" }); + } + const manifest = revision.files.find((file) => file.path === path); + if (!manifest) return yield* new McpSkillResourceError({ reason: "Skill file not found" }); + const file = yield* skills.readFile({ skillId, revisionId: revision.id, path }); + const uri = managedSkillUri({ id: String(skillId), packageDigest: digest, path }); + const textual = + manifest.mediaType.startsWith("text/") || + manifest.mediaType.includes("json") || + manifest.mediaType.includes("yaml") || + manifest.mediaType.includes("xml") || + manifest.mediaType.includes("javascript"); + return { + contents: [ + textual + ? { uri, mimeType: manifest.mediaType, text: new TextDecoder().decode(file.bytes) } + : { uri, mimeType: manifest.mediaType, blob: Encoding.encodeBase64(file.bytes) }, + ], + }; + }); + +const readExtensionSkillResource = ( + skills: McpSkillsPort, + variables: Readonly>, +): Effect.Effect< + { + contents: Array< + | { uri: string; mimeType: string; text: string } + | { uri: string; mimeType: string; blob: string } + >; + }, + unknown +> => + Effect.gen(function* () { + const scalar = (name: string): Effect.Effect => { + const value = variables[name]; + return typeof value === "string" + ? Effect.succeed(decodeURIComponent(value)) + : Effect.fail(new McpSkillResourceError({ reason: `Invalid ${name}` })); + }; + const skillId = ManagedSkillId.make(yield* scalar("skillId")); + const expectedName = yield* scalar("name"); + const path = yield* scalar("path"); + return yield* readEnabledSkillResource(skills, { + skillId, + expectedName, + path, + uri: extensionSkillUri({ id: String(skillId), name: expectedName, path }), + }); + }); + +const readEnabledSkillResource = ( + skills: McpSkillsPort, + input: { + readonly skillId: ManagedSkillId; + readonly expectedName: string; + readonly path: string; + readonly uri: string; + }, +): Effect.Effect< + { + contents: Array< + | { uri: string; mimeType: string; text: string } + | { uri: string; mimeType: string; blob: string } + >; + }, + unknown +> => + Effect.gen(function* () { + const detail = yield* skills.get({ skillId: input.skillId }); + if (detail.delivery.kind !== "enabled" || detail.name !== input.expectedName) { + return yield* new McpSkillResourceError({ reason: "Skill resource is unavailable" }); + } + const revision = detail.revisions.find((item) => item.id === detail.activeRevisionId); + const manifest = revision?.files.find((file) => file.path === input.path); + if (!revision || !manifest) { + return yield* new McpSkillResourceError({ reason: "Skill file not found" }); + } + const file = yield* skills.readFile({ + skillId: input.skillId, + revisionId: revision.id, + path: input.path, + }); + const textual = + manifest.mediaType.startsWith("text/") || + manifest.mediaType.includes("json") || + manifest.mediaType.includes("yaml") || + manifest.mediaType.includes("xml") || + manifest.mediaType.includes("javascript"); + return { + contents: [ + textual + ? { + uri: input.uri, + mimeType: manifest.mediaType, + text: new TextDecoder().decode(file.bytes), + } + : { + uri: input.uri, + mimeType: manifest.mediaType, + blob: Encoding.encodeBase64(file.bytes), + }, + ], + }; + }); + +const readLegacyExtensionSkillResource = ( + skills: McpSkillsPort, + variables: Readonly>, +): Effect.Effect< + { + contents: Array< + | { uri: string; mimeType: string; text: string } + | { uri: string; mimeType: string; blob: string } + >; + }, + unknown +> => + Effect.gen(function* () { + const scalar = (name: string): Effect.Effect => { + const value = variables[name]; + return typeof value === "string" + ? Effect.succeed(decodeURIComponent(value)) + : Effect.fail(new McpSkillResourceError({ reason: `Invalid ${name}` })); + }; + const owner = yield* scalar("owner"); + if (owner !== "user" && owner !== "org") { + return yield* new McpSkillResourceError({ reason: "Invalid owner" }); + } + const name = yield* scalar("name"); + const path = yield* scalar("path"); + const summary = (yield* skills.list()).find( + (candidate) => + candidate.owner === owner && + candidate.name === name && + candidate.delivery.kind === "enabled", + ); + if (!summary) { + return yield* new McpSkillResourceError({ reason: "Skill resource is unavailable" }); + } + return yield* readEnabledSkillResource(skills, { + skillId: summary.id, + expectedName: name, + path, + uri: legacyExtensionSkillUri({ owner, name, path }), + }); + }); + /** Pull the live integration inventory block out of the built execute * description (it runs from its header to the end), so the `skills` tool can * re-use it without rebuilding the inventory from the executor. */ @@ -1532,7 +2231,7 @@ export const createExecutorMcpServer = ( const executeInventory = extractInventory(description); // Artifacts are on unless this connection opted out (`?artifacts=false`). // One flag decides the whole surface: the tools, the shell resource, and - // the skills catalog below. + // the guides catalog below. const artifactsEnabled = config.artifactsEnabled ?? true; const skillCatalog: readonly Skill[] = config.mode === "passthrough" @@ -1544,6 +2243,12 @@ export const createExecutorMcpServer = ( }), ] : skillCatalogFor({ artifacts: artifactsEnabled }); + const managedSkillsAtBuild = config.skills + ? yield* config.skills.list().pipe( + Effect.catchCause(() => Effect.succeed([])), + Effect.withSpan("mcp.host.list_managed_skills"), + ) + : []; // Per-integration search tools are off unless this connection opted in // (`?search_tools=true`). const searchToolsEnabled = config.searchToolsEnabled ?? false; @@ -1657,7 +2362,11 @@ export const createExecutorMcpServer = ( // `ui://executor/shell.html`; it stays advertised even when no // shell loader is configured so the capability set doesn't vary // per host. - capabilities: { resources: {}, tools: {} }, + capabilities: { + resources: { listChanged: true }, + tools: {}, + ...(config.skills ? { extensions: { [SKILLS_EXTENSION]: {} } } : {}), + }, jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), ...(passthrough ? { @@ -1668,6 +2377,85 @@ export const createExecutorMcpServer = ( ), ).pipe(Effect.withSpan("mcp.host.create_server")); + const runResourceEffect = (effect: Effect.Effect): Promise => + Effect.runPromiseWith(context)(anchor(effect)); + + yield* Effect.sync(() => { + const configuredSkills = config.skills; + if (configuredSkills) { + server.server.setRequestHandler(ListSkillsRequestSchema, (request) => + runResourceEffect(listExtensionSkills(configuredSkills, request.params?.cursor)), + ); + server.server.setRequestHandler(GetSkillRequestSchema, (request) => + runResourceEffect(getExtensionSkill(configuredSkills, request.params.uri)), + ); + const extensionTemplate = new ResourceTemplate( + "skill://executor/skills/{skillId}/{name}/{path}", + { list: undefined }, + ); + server.registerResource( + "managed-skill-extension-file", + extensionTemplate, + { + title: "Executor managed skill file", + description: "A file exposed through the MCP Skills extension.", + }, + (_uri, variables) => + runResourceEffect(readExtensionSkillResource(configuredSkills, variables)), + ); + const template = new ResourceTemplate( + "skill://executor/managed/{skillId}/{digest}/{path}", + { list: () => runResourceEffect(modelSkillResources(configuredSkills)) }, + ); + server.registerResource( + "managed-skill-file", + template, + { + title: "Executor managed skill file", + description: "An immutable file from an enabled Executor-managed Agent Skill.", + }, + (_uri, variables) => + runResourceEffect(readManagedSkillResource(configuredSkills, variables)), + ); + const legacyExtensionTemplate = new ResourceTemplate("skill://{owner}/{name}/{+path}", { + list: undefined, + }); + server.registerResource( + "managed-skill-extension-file-legacy", + legacyExtensionTemplate, + { + title: "Executor managed skill file compatibility alias", + description: "The owner/name resource form used by the initial Skills extension.", + }, + (_uri, variables) => + runResourceEffect(readLegacyExtensionSkillResource(configuredSkills, variables)), + ); + } + server.registerResource( + "managed-skills-index", + "skill://index.json", + { + title: "Executor managed skills index", + description: "Discovery metadata for model-invocable managed skills.", + mimeType: "application/json", + }, + async (uri) => { + const page = config.skills + ? await runResourceEffect(modelSkillResources(config.skills)) + : { resources: [] }; + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: encodeUnknownJson(page.resources), + }, + ], + }; + }, + ); + }).pipe(Effect.withSpan("mcp.host.register_skill_resources")); + const executeWithNativeElicitation = ( code: string, extra: McpRequestJoinKeys, @@ -2073,25 +2861,30 @@ export const createExecutorMcpServer = ( "skills", { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, - description: passthrough - ? 'Documentation for this server only, not harness or project skills. Call with no name to list guides, or skills({ name: "search-invoke" }) for account discovery, tool search, invocation, and pagination.' - : [ - "Documentation for THIS server's own tools. Not a general skill reader: it serves a short, fixed set of how-to docs about using `execute` and artifacts here, and it cannot reach your harness's skills, a SKILL.md on disk, or any user- or project-authored skill. The argument is a name from its own catalog, never a path or an outside skill's id.", - "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", - 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).', - "Call with no name to list the few docs available.", - ].join("\n"), + description: [ + `Read Executor's built-in ${passthrough ? "search and artifact" : "execute and artifact"} guides by name.`, + "Search Executor-managed Agent Skills or read one managed package file on demand.", + "Managed search returns only skills that allow model invocation. An exact ref or name can also read an enabled manual skill named by the user.", + `Call with no arguments to list the built-in guides and discover managed skills. Call with name: "${passthrough ? "search-invoke" : "execute"}" to read the main built-in guide.`, + "", + ...managedSkillCatalogDescription(managedSkillsAtBuild), + ].join("\n"), inputSchema: { - name: z + query: z.string().optional().describe("Search text. Omit or leave blank to enumerate."), + limit: z.number().int().min(1).max(50).optional(), + offset: z.number().int().min(0).optional(), + ref: z.string().optional().describe("Stable managed skill reference to read."), + name: z.string().optional().describe("Built-in guide or managed skill name to read."), + owner: z.enum(["user", "org"]).optional(), + path: z.string().optional().describe("Package path. Defaults to SKILL.md."), + file: z .string() .optional() - .describe( - `A doc from this server's own catalog, e.g. "${passthrough ? "search-invoke" : "execute"}". Omit to list the catalog.`, - ), + .describe("Bundled package file to read. Alias for path; do not provide both."), }, }, - ({ name }, extra) => - runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog)), extra), + (input, extra) => + runToolEffect(skillsResult(input, executeInventory, skillCatalog, config.skills), extra), ), ).pipe( Effect.withSpan("mcp.host.register_tool", { diff --git a/packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs b/packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs index 00507f71b6..61bee16f18 100644 --- a/packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs +++ b/packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs @@ -186,15 +186,17 @@ const sandboxConsole = { const runUserCode = async (code) => { outputs = []; const tools = createToolsProxy(); + const skills = createToolsProxy(["skills"]); const execute = new Function( "tools", + "skills", "console", "emit", `"use strict"; return (async () => {\n${code}\n})();`, ); - const result = await execute(tools, sandboxConsole, emit); + const result = await execute(tools, skills, sandboxConsole, emit); return { result, output: outputs.length > 0 ? outputs : undefined }; }; diff --git a/packages/kernel/runtime-dynamic-worker/src/module-template.ts b/packages/kernel/runtime-dynamic-worker/src/module-template.ts index bdecdacfd3..d65e024dc0 100644 --- a/packages/kernel/runtime-dynamic-worker/src/module-template.ts +++ b/packages/kernel/runtime-dynamic-worker/src/module-template.ts @@ -213,6 +213,7 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => " },", " });", " const tools = __makeToolsProxy();", + " const skills = __makeToolsProxy(['skills']);", "", " let __watchdogInterval;", " const __watchdog = new Promise((_, reject) => {", diff --git a/packages/kernel/runtime-quickjs/src/index.ts b/packages/kernel/runtime-quickjs/src/index.ts index 90de8461bf..be09b9a93c 100644 --- a/packages/kernel/runtime-quickjs/src/index.ts +++ b/packages/kernel/runtime-quickjs/src/index.ts @@ -249,6 +249,7 @@ const buildExecutionSource = (code: string): string => { " },", "});", "const tools = __makeToolsProxy();", + "const skills = __makeToolsProxy(['skills']);", "const console = {", " log: (...args) => __log('log', __formatLogLine(args)),", " warn: (...args) => __log('warn', __formatLogLine(args)),", diff --git a/packages/kernel/runtime-workerd-subprocess/src/index.ts b/packages/kernel/runtime-workerd-subprocess/src/index.ts index 12121549e3..29f4c73481 100644 --- a/packages/kernel/runtime-workerd-subprocess/src/index.ts +++ b/packages/kernel/runtime-workerd-subprocess/src/index.ts @@ -646,9 +646,9 @@ export default { if (!request.url.endsWith("/run")) return new Response("Not Found", { status: 404 }); logs.length = 0; try { - const fn = env.UNSAFE_EVAL.eval(${JSON.stringify(`(async (tools, console) => { ${body} })`)}); + const fn = env.UNSAFE_EVAL.eval(${JSON.stringify(`(async (tools, skills, console) => { ${body} })`)}); const result = await Promise.race([ - fn(makeToolsProxy(env), sandboxConsole), + fn(makeToolsProxy(env), makeToolsProxy(env, ["skills"]), sandboxConsole), new Promise((_, reject) => setTimeout(() => reject(new Error("Execution timed out after ${timeoutMs}ms")), ${timeoutMs})), ]); return Response.json({ result, logs }); diff --git a/packages/plugins/toolkits/src/page.tsx b/packages/plugins/toolkits/src/page.tsx index 5d1a9f2a3e..5fe4cf0dfa 100644 --- a/packages/plugins/toolkits/src/page.tsx +++ b/packages/plugins/toolkits/src/page.tsx @@ -14,11 +14,16 @@ import { matchPattern, type EffectivePolicy, type Integration, + type ManagedSkillId, type Owner, type ToolAddress, type ToolPolicyAction, } from "@executor-js/sdk/shared"; -import { integrationsOptimisticAtom, toolsAllAtom } from "@executor-js/react/api/atoms"; +import { + integrationsOptimisticAtom, + skillsOptimisticAtom, + toolsAllAtom, +} from "@executor-js/react/api/atoms"; import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; import { useOrganizationSlug } from "@executor-js/react/api/organization-context"; import { @@ -29,6 +34,7 @@ import { import { ownerLabel, useOwnerDisplay } from "@executor-js/react/api/owner-display"; import { Badge } from "@executor-js/react/components/badge"; import { Button } from "@executor-js/react/components/button"; +import { Checkbox } from "@executor-js/react/components/checkbox"; import { CopyButton } from "@executor-js/react/components/copy-button"; import { AlertDialog, @@ -77,6 +83,7 @@ const toolkitWriteKeys = [ ReactivityKey.connections, ReactivityKey.policies, ReactivityKey.tools, + ReactivityKey.skills, ] as const; const toolkitsAtom = ToolkitsClient.query("toolkits", "list", { @@ -100,6 +107,14 @@ const toolkitConnectionsAtom = Atom.family((toolkitId: string) => }), ); +const toolkitSkillsAtom = Atom.family((toolkitId: string) => + ToolkitsClient.query("toolkits", "listSkills", { + params: { toolkitId }, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.skills], + }), +); + const createToolkit = ToolkitsClient.mutation("toolkits", "create"); const removeToolkit = ToolkitsClient.mutation("toolkits", "remove"); const createToolkitPolicy = ToolkitsClient.mutation("toolkits", "createPolicy"); @@ -107,6 +122,7 @@ const updateToolkitPolicy = ToolkitsClient.mutation("toolkits", "updatePolicy"); const removeToolkitPolicy = ToolkitsClient.mutation("toolkits", "removePolicy"); const createToolkitConnection = ToolkitsClient.mutation("toolkits", "createConnection"); const removeToolkitConnection = ToolkitsClient.mutation("toolkits", "removeConnection"); +const setToolkitSkills = ToolkitsClient.mutation("toolkits", "setSkills"); type ToolRow = { readonly address: ToolAddress; @@ -1027,6 +1043,13 @@ function ToolkitHeader(props: { ); } +interface ToolkitSkillChoice { + readonly id: ManagedSkillId; + readonly owner: Owner; + readonly name: string | null; + readonly description: string | null; +} + function ToolkitWorkspace(props: { toolkit: ToolkitResponse; showOwnerLabels: boolean; @@ -1042,9 +1065,16 @@ function ToolkitWorkspace(props: { onRemoveConnection: (connectionId: string) => Promise | void; onSetPolicy: (pattern: string, action: ToolPolicyAction) => Promise | void; onClearPolicy: (pattern: string) => Promise | void; + skills: readonly ToolkitSkillChoice[]; + selectedSkillIds: readonly ManagedSkillId[]; + onSetSkills: (skillIds: readonly ManagedSkillId[]) => Promise | void; }) { const [addOpen, setAddOpen] = useState(false); const [selectedToolId, setSelectedToolId] = useState(null); + const [skillsOpen, setSkillsOpen] = useState(false); + const [draftSkillIds, setDraftSkillIds] = useState( + props.selectedSkillIds, + ); const visibleTools = useMemo( () => props.tools.filter((tool) => toolCanAppearInToolkit(props.toolkit, tool)), [props.toolkit, props.tools], @@ -1130,6 +1160,24 @@ function ToolkitWorkspace(props: { onRemove={props.onRemoveToolkit} /> +
+

+ {props.selectedSkillIds.length} managed{" "} + {props.selectedSkillIds.length === 1 ? "skill" : "skills"} +

+ +
+
+ + + + Managed skills + + Agents using this toolkit can discover only the selected managed skills. + + +
+ {props.skills.length === 0 ? ( +
+

+ Add a managed skill before assigning skills to this toolkit. +

+ +
+ ) : ( + props.skills.map((skill) => { + const checked = draftSkillIds.includes(skill.id); + return ( + + ); + }) + )} +
+ + + {props.skills.length > 0 ? ( + + ) : null} + +
+
); } @@ -1292,13 +1406,30 @@ function ToolkitDetailView(props: { }) { const policies = useAtomValue(toolkitPoliciesAtom(props.toolkit.id)); const connections = useAtomValue(toolkitConnectionsAtom(props.toolkit.id)); - const doCreatePolicy = useAtomSet(createToolkitPolicy, { mode: "promiseExit" }); - const doUpdatePolicy = useAtomSet(updateToolkitPolicy, { mode: "promiseExit" }); - const doRemovePolicy = useAtomSet(removeToolkitPolicy, { mode: "promiseExit" }); - const doCreateConnection = useAtomSet(createToolkitConnection, { mode: "promiseExit" }); - const doRemoveConnection = useAtomSet(removeToolkitConnection, { mode: "promiseExit" }); + const memberships = useAtomValue(toolkitSkillsAtom(props.toolkit.id)); + const managedSkills = useAtomValue(skillsOptimisticAtom); + const doCreatePolicy = useAtomSet(createToolkitPolicy, { + mode: "promiseExit", + }); + const doUpdatePolicy = useAtomSet(updateToolkitPolicy, { + mode: "promiseExit", + }); + const doRemovePolicy = useAtomSet(removeToolkitPolicy, { + mode: "promiseExit", + }); + const doCreateConnection = useAtomSet(createToolkitConnection, { + mode: "promiseExit", + }); + const doRemoveConnection = useAtomSet(removeToolkitConnection, { + mode: "promiseExit", + }); + const doSetSkills = useAtomSet(setToolkitSkills, { mode: "promiseExit" }); const policyRows = AsyncResult.isSuccess(policies) ? policies.value.policies : []; const connectionRows = AsyncResult.isSuccess(connections) ? connections.value.connections : []; + const skillRows = AsyncResult.isSuccess(memberships) ? memberships.value.skills : []; + const skillChoices = AsyncResult.isSuccess(managedSkills) + ? managedSkills.value.filter((skill) => props.toolkit.owner === "user" || skill.owner === "org") + : []; const setPolicyHandler = async (pattern: string, action: ToolPolicyAction) => { const existing = policyRows.find((policy) => policy.pattern === pattern); @@ -1341,10 +1472,28 @@ function ToolkitDetailView(props: { }); }; - if (AsyncResult.isFailure(policies) || AsyncResult.isFailure(connections)) { + const setSkillsHandler = async (skillIds: readonly ManagedSkillId[]) => { + await doSetSkills({ + params: { toolkitId: props.toolkit.id }, + payload: { expectedUpdatedAt: props.toolkit.updatedAt, skillIds }, + reactivityKeys: toolkitWriteKeys, + }); + }; + + if ( + AsyncResult.isFailure(policies) || + AsyncResult.isFailure(connections) || + AsyncResult.isFailure(memberships) || + AsyncResult.isFailure(managedSkills) + ) { return
Failed to load toolkit
; } - if (!AsyncResult.isSuccess(policies) || !AsyncResult.isSuccess(connections)) { + if ( + !AsyncResult.isSuccess(policies) || + !AsyncResult.isSuccess(connections) || + !AsyncResult.isSuccess(memberships) || + !AsyncResult.isSuccess(managedSkills) + ) { return ; } @@ -1364,6 +1513,9 @@ function ToolkitDetailView(props: { onRemoveConnection={removeConnectionHandler} onSetPolicy={setPolicyHandler} onClearPolicy={clearPolicyHandler} + skills={skillChoices} + selectedSkillIds={skillRows.map((membership) => membership.skillId)} + onSetSkills={setSkillsHandler} /> ); } diff --git a/packages/plugins/toolkits/src/server.test.ts b/packages/plugins/toolkits/src/server.test.ts index 3db165f6e6..59f55f4b50 100644 --- a/packages/plugins/toolkits/src/server.test.ts +++ b/packages/plugins/toolkits/src/server.test.ts @@ -5,6 +5,49 @@ import { makeTestExecutor } from "@executor-js/sdk/testing"; import { toolkitsPlugin } from "./server"; describe("toolkitsPlugin", () => { + it.effect("filters managed skill discovery through the active toolkit", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [toolkitsPlugin({ activeToolkitSlug: "focused" })] as const, + }); + const toolkit = yield* executor.toolkits.create({ owner: "user", name: "Focused" }); + const included = yield* executor.skills.create({ + owner: "user", + package: { + files: [ + { + path: "SKILL.md", + bytes: new TextEncoder().encode( + "---\nname: included\ndescription: Included skill.\n---\nBody", + ), + }, + ], + }, + }); + yield* executor.skills.create({ + owner: "org", + package: { + files: [ + { + path: "SKILL.md", + bytes: new TextEncoder().encode( + "---\nname: excluded\ndescription: Excluded skill.\n---\nBody", + ), + }, + ], + }, + }); + + expect(yield* executor.skills.list()).toEqual([]); + const memberships = yield* executor.toolkits.setSkills(toolkit.id, { + expectedUpdatedAt: toolkit.updatedAt, + skillIds: [included.id], + }); + expect(memberships.map((membership) => membership.skillId)).toEqual([included.id]); + expect((yield* executor.skills.list()).map((skill) => skill.id)).toEqual([included.id]); + }), + ); + it.effect("creates toolkits and manages ordered policy rules", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ diff --git a/packages/plugins/toolkits/src/server.ts b/packages/plugins/toolkits/src/server.ts index eef15de797..9a6e471aa3 100644 --- a/packages/plugins/toolkits/src/server.ts +++ b/packages/plugins/toolkits/src/server.ts @@ -7,6 +7,7 @@ import { HttpApiBuilder, isValidPattern, matchPattern, + ManagedSkillId, Schema, type DynamicToolScope, type EffectivePolicy, @@ -55,6 +56,14 @@ const ToolkitConnectionRecord = Schema.Struct({ }); type ToolkitConnectionRecord = typeof ToolkitConnectionRecord.Type; +const ToolkitSkillRecord = Schema.Struct({ + id: Schema.String, + toolkitId: Schema.String, + skillId: ManagedSkillId, + position: Schema.String, +}); +type ToolkitSkillRecord = typeof ToolkitSkillRecord.Type; + const toolkitsCollection = definePluginStorageCollection("toolkits", ToolkitRecord, { indexes: ["slug", "name", "updatedAt"], }); @@ -75,10 +84,15 @@ const toolkitConnectionsCollection = definePluginStorageCollection( }, ); +const toolkitSkillsCollection = definePluginStorageCollection("toolkitSkills", ToolkitSkillRecord, { + indexes: ["toolkitId", "skillId", "position", ["toolkitId", "position"]], +}); + type ToolkitStorage = { readonly toolkits: PluginStorageCollectionFacade; readonly policies: PluginStorageCollectionFacade; readonly connections: PluginStorageCollectionFacade; + readonly skills: PluginStorageCollectionFacade; }; export interface ToolkitsPluginOptions { @@ -267,10 +281,17 @@ const connectionToResponse = (connection: ToolkitConnectionRecord) => ({ updatedAt: connection.updatedAt, }); +const skillToResponse = (skill: ToolkitSkillRecord) => ({ + toolkitId: skill.toolkitId, + skillId: skill.skillId, + position: skill.position, +}); + const makeToolkitStorage = (pluginStorage: PluginStorageFacade): ToolkitStorage => ({ toolkits: pluginStorage.collection(toolkitsCollection), policies: pluginStorage.collection(toolkitPoliciesCollection), connections: pluginStorage.collection(toolkitConnectionsCollection), + skills: pluginStorage.collection(toolkitSkillsCollection), }); const makeToolkitsExtension = (ctx: PluginCtx) => { @@ -326,6 +347,11 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { .query({ where: { toolkitId } }) .pipe(Effect.map((entries) => entries.map((entry) => entry.data).sort(comparePositioned))); + const listSkillsForRecord = (toolkitId: string) => + storage.skills + .query({ where: { toolkitId } }) + .pipe(Effect.map((entries) => entries.map((entry) => entry.data).sort(comparePositioned))); + const requirePolicy = (toolkitId: string, policyId: string, owner: Owner) => storage.policies .getForOwner({ owner, key: policyId }) @@ -391,6 +417,7 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { const toolkit = yield* requireToolkit(toolkitId); const policies = yield* listPoliciesForRecord(toolkitId); const connections = yield* listConnectionsForRecord(toolkitId); + const skills = yield* listSkillsForRecord(toolkitId); yield* ctx.pluginStorage.removeMany({ owner: toolkit.owner, entries: [ @@ -403,6 +430,10 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { collection: toolkitConnectionsCollection.name, key: connection.id, })), + ...skills.map((skill) => ({ + collection: toolkitSkillsCollection.name, + key: skill.id, + })), ], }); }); @@ -522,6 +553,70 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { yield* storage.connections.remove({ owner: toolkit.owner, key: connectionId }); }); + const listSkills = (toolkitId: string) => + requireToolkit(toolkitId).pipe(Effect.flatMap(() => listSkillsForRecord(toolkitId))); + + const setSkills = ( + toolkitId: string, + input: { readonly expectedUpdatedAt: number; readonly skillIds: readonly ManagedSkillId[] }, + ) => + Effect.gen(function* () { + const toolkit = yield* requireToolkit(toolkitId); + if (toolkit.data.updatedAt !== input.expectedUpdatedAt) { + return yield* fail("Toolkit changed in another session. Refresh and try again."); + } + const skillIds = [...new Set(input.skillIds)]; + const managedSkills = yield* Effect.forEach(skillIds, (skillId) => + ctx.core.skills + .get(skillId) + .pipe( + Effect.flatMap((skill) => + skill === null ? fail(`Managed skill not found: ${skillId}`) : Effect.succeed(skill), + ), + ), + ); + if (toolkit.owner === "org" && managedSkills.some((skill) => skill.owner !== "org")) { + return yield* fail("A workspace toolkit can include only workspace-owned skills."); + } + const existing = yield* listSkillsForRecord(toolkitId); + yield* ctx.pluginStorage.removeMany({ + owner: toolkit.owner, + entries: existing.map((skill) => ({ + collection: toolkitSkillsCollection.name, + key: skill.id, + })), + }); + let previousPosition: string | null = null; + const next = yield* Effect.forEach(skillIds, (skillId) => { + const position = generateKeyBetween(previousPosition, null); + previousPosition = position; + const id = newId("tkskill"); + return storage.skills + .put({ + owner: toolkit.owner, + key: id, + data: { id, toolkitId, skillId, position }, + }) + .pipe(Effect.map((entry) => entry.data)); + }); + yield* storage.toolkits.put({ + owner: toolkit.owner, + key: toolkitId, + data: { ...toolkit.data, updatedAt: Date.now() }, + }); + return next; + }); + + const skillIdsForSlug = ( + slug: string, + ): Effect.Effect, StorageFailure> => + Effect.gen(function* () { + const toolkit = yield* getBySlugEntry(slug); + if (!toolkit) return new Set(); + const skills = yield* listSkillsForRecord(toolkit.data.id); + return new Set(skills.map((skill) => skill.skillId)); + }); + const policyRulesForSlug = ( slug: string, ): Effect.Effect => @@ -601,6 +696,13 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { ), createConnection, removeConnection, + listSkills: (toolkitId: string) => + listSkills(toolkitId).pipe(Effect.map((skills) => skills.map(skillToResponse))), + setSkills: ( + toolkitId: string, + input: { readonly expectedUpdatedAt: number; readonly skillIds: readonly ManagedSkillId[] }, + ) => setSkills(toolkitId, input).pipe(Effect.map((skills) => skills.map(skillToResponse))), + skillIdsForSlug, policyRulesForSlug, resolvePolicyForSlug, preparePolicyResolverForSlug, @@ -711,6 +813,22 @@ const ToolkitsHandlers = HttpApiBuilder.group(ExecutorApiWithToolkits, "toolkits return { removed: true }; }), ), + ) + .handle("listSkills", ({ params }) => + capture( + Effect.gen(function* () { + const ext = yield* ToolkitsExtensionService; + return { skills: yield* ext.listSkills(params.toolkitId) }; + }), + ), + ) + .handle("setSkills", ({ params, payload }) => + capture( + Effect.gen(function* () { + const ext = yield* ToolkitsExtensionService; + return { skills: yield* ext.setSkills(params.toolkitId, payload) }; + }), + ), ), ); @@ -738,6 +856,7 @@ export const toolkitsPlugin = definePlugin((options: ToolkitsPluginOptions = {}) toolkits: toolkitsCollection, toolkitPolicies: toolkitPoliciesCollection, toolkitConnections: toolkitConnectionsCollection, + toolkitSkills: toolkitSkillsCollection, }, storage: ({ pluginStorage }) => makeToolkitStorage(pluginStorage), extension: makeToolkitsExtension, @@ -748,6 +867,12 @@ export const toolkitsPlugin = definePlugin((options: ToolkitsPluginOptions = {}) ? { toolPolicyProvider: (ctx: PluginCtx) => makePolicyProvider(makeToolkitsExtension(ctx), activeToolkitSlug), + skillCatalogProvider: (ctx: PluginCtx) => { + const extension = makeToolkitsExtension(ctx); + return { + listAllowedSkillIds: () => extension.skillIdsForSlug(activeToolkitSlug), + }; + }, } : {}), }; diff --git a/packages/plugins/toolkits/src/shared.ts b/packages/plugins/toolkits/src/shared.ts index 55ecb06cb5..85672facea 100644 --- a/packages/plugins/toolkits/src/shared.ts +++ b/packages/plugins/toolkits/src/shared.ts @@ -1,6 +1,11 @@ import { Schema } from "effect"; import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; -import { InternalError, Owner, ToolPolicyActionSchema } from "@executor-js/sdk/shared"; +import { + InternalError, + ManagedSkillId, + Owner, + ToolPolicyActionSchema, +} from "@executor-js/sdk/shared"; export class ToolkitError extends Schema.TaggedErrorClass()( "ToolkitError", @@ -53,6 +58,13 @@ export const ToolkitConnectionResponse = Schema.Struct({ }); export type ToolkitConnectionResponse = typeof ToolkitConnectionResponse.Type; +export const ToolkitSkillResponse = Schema.Struct({ + toolkitId: Schema.String, + skillId: ManagedSkillId, + position: Schema.String, +}); +export type ToolkitSkillResponse = typeof ToolkitSkillResponse.Type; + const CreateToolkitPayload = Schema.Struct({ owner: Owner, name: Schema.String, @@ -163,4 +175,22 @@ export const ToolkitsApi = HttpApiGroup.make("toolkits") success: Schema.Struct({ removed: Schema.Boolean }), error: ToolkitErrors, }), + ) + .add( + HttpApiEndpoint.get("listSkills", "/toolkits/:toolkitId/skills", { + params: ToolkitParams, + success: Schema.Struct({ skills: Schema.Array(ToolkitSkillResponse) }), + error: ToolkitErrors, + }), + ) + .add( + HttpApiEndpoint.put("setSkills", "/toolkits/:toolkitId/skills", { + params: ToolkitParams, + payload: Schema.Struct({ + expectedUpdatedAt: Schema.Number, + skillIds: Schema.Array(ManagedSkillId), + }), + success: Schema.Struct({ skills: Schema.Array(ToolkitSkillResponse) }), + error: ToolkitErrors, + }), ); diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 366cd52994..254a9a15f6 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -7,6 +7,9 @@ import { type Connection, type ConnectionName, type IntegrationSlug, + type ManagedSkillId, + type SkillCandidateId, + type SkillRevisionId, type OAuthClientSlug, type OAuthClientSummary, type OAuthGrant, @@ -174,6 +177,45 @@ export const artifactAtom = Atom.family((artifactId: ArtifactId) => }), ); +export const skillsAtom = ExecutorApiClient.query("skills", "list", { + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.skills], +}); + +export const skillAtom = Atom.family((skillId: ManagedSkillId) => + ExecutorApiClient.query("skills", "get", { + params: { skillId }, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.skills], + }), +); + +export const skillFileAtom = Atom.family( + (key: { + readonly skillId: ManagedSkillId; + readonly path: string; + readonly revisionId?: SkillRevisionId; + }) => + ExecutorApiClient.query("skills", "readFile", { + params: { skillId: key.skillId }, + query: { + path: key.path, + ...(key.revisionId === undefined ? {} : { revisionId: key.revisionId }), + }, + timeToLive: "5 minutes", + reactivityKeys: [ReactivityKey.skills], + }), +); + +export const skillUpdateReviewAtom = Atom.family( + (key: { readonly skillId: ManagedSkillId; readonly candidateId: SkillCandidateId }) => + ExecutorApiClient.query("skills", "reviewUpdate", { + params: key, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.skills], + }), +); + // --------------------------------------------------------------------------- // Mutation atoms — reactivityKeys must be passed at call site (effect-atom // does not accept them at definition time). See `reactivity-keys.tsx` for the @@ -304,6 +346,23 @@ export const renameArtifact = ExecutorApiClient.mutation("artifacts", "rename"); export const removeArtifact = ExecutorApiClient.mutation("artifacts", "remove"); +export const createSkill = ExecutorApiClient.mutation("skills", "create"); +export const discoverSkills = ExecutorApiClient.mutation("skills", "discover"); +export const importSkillCandidate = ExecutorApiClient.mutation("skills", "importCandidate"); + +export const editSkill = ExecutorApiClient.mutation("skills", "edit"); + +export const setSkillDelivery = ExecutorApiClient.mutation("skills", "setDelivery"); +export const setSkillSource = ExecutorApiClient.mutation("skills", "setSource"); +export const checkSkillSource = ExecutorApiClient.mutation("skills", "checkSource"); +export const applySkillUpdate = ExecutorApiClient.mutation("skills", "applyUpdate"); + +export const restoreSkillRevision = ExecutorApiClient.mutation("skills", "restoreRevision"); + +export const removeSkill = ExecutorApiClient.mutation("skills", "remove"); + +export const exportSkill = ExecutorApiClient.mutation("skills", "export"); + /** * Upgrade an artifact's gallery preview to a snapshot of a settled render. * @@ -559,6 +618,44 @@ export const removeArtifactOptimistic = artifactsOptimisticAtom.pipe( }), ); +// --------------------------------------------------------------------------- +// Skills — optimistic delivery changes and removals. +// --------------------------------------------------------------------------- + +export const skillsOptimisticAtom = Atom.optimistic(skillsAtom); + +export const setSkillDeliveryOptimistic = skillsOptimisticAtom.pipe( + Atom.optimisticFn({ + reducer: ( + current, + arg: { + readonly params: { readonly skillId: ManagedSkillId }; + readonly payload: { + readonly delivery: + | { readonly kind: "disabled" } + | { readonly kind: "enabled"; readonly invocation: "manual" | "model" }; + }; + }, + ) => + AsyncResult.map(current, (rows) => + rows.map((row) => + row.id === arg.params.skillId + ? { ...row, delivery: arg.payload.delivery, updatedAt: Date.now() } + : row, + ), + ), + fn: setSkillDelivery, + }), +); + +export const removeSkillOptimistic = skillsOptimisticAtom.pipe( + Atom.optimisticFn({ + reducer: (current, arg: { readonly params: { readonly skillId: ManagedSkillId } }) => + AsyncResult.map(current, (rows) => rows.filter((row) => row.id !== arg.params.skillId)), + fn: removeSkill, + }), +); + // --------------------------------------------------------------------------- // OAuth clients (apps) — optimistic surface. The list reads through // `oauthClientsOptimisticAtom`; the remove mutation drops the matching diff --git a/packages/react/src/api/reactivity-keys.tsx b/packages/react/src/api/reactivity-keys.tsx index 7f9bc27407..14cbcea016 100644 --- a/packages/react/src/api/reactivity-keys.tsx +++ b/packages/react/src/api/reactivity-keys.tsx @@ -28,6 +28,8 @@ export const ReactivityKey = { policies: "policies", /** Saved generative-UI artifacts. */ artifacts: "artifacts", + /** Executor-managed Agent Skills and their immutable revisions. */ + skills: "skills", /** Registered OAuth clients (apps). */ oauthClients: "oauth-clients", /** An integration's declared health check (the operation/identity-field spec). */ @@ -80,6 +82,9 @@ export const policyWriteKeys = [ReactivityKey.policies, ReactivityKey.tools] as * resource — nothing else reads them — so they invalidate only themselves. */ export const artifactWriteKeys = [ReactivityKey.artifacts] as const; +/** Mutations that change managed skills or their delivery policy. */ +export const skillWriteKeys = [ReactivityKey.skills] as const; + /** Cloud-only: org membership mutations. */ export const orgMemberWriteKeys = [ReactivityKey.orgMembers] as const; diff --git a/packages/react/src/components/mcp-install-card.tsx b/packages/react/src/components/mcp-install-card.tsx index eac93d9b1f..129c7b264c 100644 --- a/packages/react/src/components/mcp-install-card.tsx +++ b/packages/react/src/components/mcp-install-card.tsx @@ -313,7 +313,7 @@ export function McpInstallCard(props: { className?: string }) {
Search and invoke
{toolMode === "passthrough" - ? "Discover connected accounts with integrations and read the guide with skills. Find tools with search, then call them with invoke. Your client handles approval." + ? "Discover connected accounts with integrations, then read Executor guides or managed Agent Skills with skills. Find tools with search, then call them with invoke. Your client handles approval." : "Disabled: agents write code against your tools through one execute tool."}
diff --git a/packages/react/src/console-routes.ts b/packages/react/src/console-routes.ts index def5841e58..64b63e41bc 100644 --- a/packages/react/src/console-routes.ts +++ b/packages/react/src/console-routes.ts @@ -46,6 +46,11 @@ export const CONSOLE_ROUTE_PATHS = [ "/toolkits/$toolkitSlug", "/artifacts", "/artifacts/$artifactId", + "/skills", + "/skills/new", + "/skills/$skillId", + "/skills/$skillId/edit", + "/skills/$skillId/updates/$candidateId", "/resume/$executionId", "/plugins/$pluginId/$", ] as const; @@ -91,6 +96,17 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array = [ { to: "/secrets", label: "Providers" }, { to: "/policies", label: "Policies" }, { to: "/toolkits", label: "Toolkits" }, + { to: "/skills", label: "Skills" }, { to: "/artifacts", label: "Artifacts" }, ]; diff --git a/packages/react/src/pages/skill-detail.tsx b/packages/react/src/pages/skill-detail.tsx new file mode 100644 index 0000000000..905d13cce3 --- /dev/null +++ b/packages/react/src/pages/skill-detail.tsx @@ -0,0 +1,583 @@ +import { useAtomMount, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import type { + ManagedSkillId, + ManagedSkillSourceChange, + SkillRequirementStatus, + SkillRevisionId, + SkillSourceLocator, +} from "@executor-js/sdk/shared"; +import { useState } from "react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Exit from "effect/Exit"; +import * as Match from "effect/Match"; +import { toast } from "sonner"; + +import { + removeSkillOptimistic, + checkSkillSource, + restoreSkillRevision, + setSkillDeliveryOptimistic, + setSkillSource, + skillAtom, + skillFileAtom, + skillsOptimisticAtom, +} from "../api/atoms"; +import { skillWriteKeys } from "../api/reactivity-keys"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../components/alert-dialog"; +import { Button } from "../components/button"; +import { ErrorState } from "../components/error-state"; +import { HelpTooltip } from "../components/help-tooltip"; +import { Label } from "../components/label"; +import { PageContainer, PageHeader } from "../components/page"; +import { Switch } from "../components/switch"; +import { isAsyncResultLoading } from "../lib/async-result"; +import { useExecutorDocumentTitle } from "../lib/document-title"; +import { formatRelativeTime } from "../lib/relative-time"; +import { SkillOwnerTag } from "./skills"; + +const decodeText = (encoded: string): string => { + const binary = globalThis.atob(encoded); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + return new TextDecoder().decode(bytes); +}; + +const symbolicReferenceFor = (source: SkillSourceLocator): string => { + return Match.value(source).pipe( + Match.discriminator("kind")("github", (value) => value.requestedRef), + Match.discriminator("kind")("wellKnown", (value) => value.entryId), + Match.discriminator("kind")("mcp", (value) => value.uri), + Match.discriminator("kind")("local", (value) => value.path), + Match.exhaustive, + ); +}; + +const requirementLabel = (status: SkillRequirementStatus): string => + Match.value(status.requirement).pipe( + Match.discriminator("kind")("integration", (value) => `Integration: ${value.integration}`), + Match.discriminator("kind")("connection", (value) => `Connection: ${value.integration}`), + Match.discriminator("kind")("mcp", (value) => `MCP server: ${value.integration}`), + Match.discriminator("kind")( + "runtime", + (value) => `Runtime: ${value.command}${value.version ? ` ${value.version}` : ""}`, + ), + Match.discriminator("kind")("skill", (value) => `Skill: ${value.name}`), + Match.exhaustive, + ); + +const requirementStatusLabel = (status: SkillRequirementStatus["status"]): string => + Match.value(status).pipe( + Match.when("satisfied", () => "Ready"), + Match.when("missing", () => "Missing"), + Match.when("blocked", () => "Blocked"), + Match.when("needs-user-action", () => "Needs setup"), + Match.when("unknown", () => "Not checked"), + Match.exhaustive, + ); + +function SkillFile(props: { + readonly skillId: ManagedSkillId; + readonly revisionId: SkillRevisionId; + readonly path: string; + readonly mediaType: string; +}) { + const file = useAtomValue( + skillFileAtom({ + skillId: props.skillId, + revisionId: props.revisionId, + path: props.path, + }), + ); + const textual = + props.mediaType.startsWith("text/") || + props.mediaType.includes("json") || + props.mediaType.includes("yaml") || + props.mediaType.includes("xml") || + props.mediaType.includes("javascript"); + return ( +
+ + {props.path} + +
+ {AsyncResult.match(file, { + onInitial: () =>

Loading file...

, + onFailure: () =>

Could not load this file.

, + onSuccess: ({ value }) => + textual ? ( +
+                {decodeText(value.bytes)}
+              
+ ) : ( +

+ Binary file, {value.manifest.size.toLocaleString()} bytes. +

+ ), + })} +
+
+ ); +} + +export function SkillDetailPage(props: { readonly skillId: ManagedSkillId }) { + const skill = useAtomValue(skillAtom(props.skillId)); + const refresh = useAtomRefresh(skillAtom(props.skillId)); + useAtomMount(skillsOptimisticAtom); + const setDelivery = useAtomSet(setSkillDeliveryOptimistic, { mode: "promiseExit" }); + const updateSource = useAtomSet(setSkillSource, { mode: "promiseExit" }); + const checkSource = useAtomSet(checkSkillSource, { mode: "promiseExit" }); + const restoreRevision = useAtomSet(restoreSkillRevision, { mode: "promiseExit" }); + const remove = useAtomSet(removeSkillOptimistic, { mode: "promiseExit" }); + const navigate = useNavigate(); + const [confirmModelInvocation, setConfirmModelInvocation] = useState(false); + const [checkingSource, setCheckingSource] = useState(false); + const title = AsyncResult.isSuccess(skill) ? (skill.value.name ?? "Blocked skill") : "Skill"; + useExecutorDocumentTitle(title); + + const changeDelivery = async ( + delivery: + | { readonly kind: "disabled" } + | { readonly kind: "enabled"; readonly invocation: "manual" | "model" }, + ) => { + const exit = await setDelivery({ + params: { skillId: props.skillId }, + payload: { delivery }, + reactivityKeys: skillWriteKeys, + }); + if (Exit.isFailure(exit)) { + toast.error("Could not change skill delivery."); + return; + } + refresh(); + }; + + const restore = async (revisionId: SkillRevisionId, activeRevisionId: SkillRevisionId) => { + const exit = await restoreRevision({ + params: { skillId: props.skillId, revisionId }, + payload: { expectedActiveRevisionId: activeRevisionId }, + reactivityKeys: skillWriteKeys, + }); + if (Exit.isFailure(exit)) { + toast.error("Could not restore that revision. Refresh and try again."); + return; + } + toast.success("Revision restored"); + }; + + const changeSource = async (change: ManagedSkillSourceChange) => { + const exit = await updateSource({ + params: { skillId: props.skillId }, + payload: { change }, + reactivityKeys: skillWriteKeys, + }); + if (Exit.isFailure(exit)) { + toast.error("Could not change skill source tracking."); + return; + } + refresh(); + }; + + const checkForUpdates = async () => { + setCheckingSource(true); + const exit = await checkSource({ + params: { skillId: props.skillId }, + reactivityKeys: skillWriteKeys, + }); + setCheckingSource(false); + if (Exit.isFailure(exit)) { + toast.error("Could not check this skill source."); + return; + } + if (exit.value.kind === "sourceFailure") { + toast.error(exit.value.message); + return; + } + if (exit.value.kind === "noUpdate") { + toast.success("The skill is up to date"); + return; + } + await navigate({ + to: "/{-$orgSlug}/skills/$skillId/updates/$candidateId", + params: { skillId: props.skillId, candidateId: exit.value.candidate.id }, + }); + }; + + const handleRemove = async () => { + const exit = await remove({ + params: { skillId: props.skillId }, + reactivityKeys: skillWriteKeys, + }); + if (Exit.isFailure(exit)) { + toast.error("Could not delete the skill."); + return; + } + await navigate({ to: "/{-$orgSlug}/skills" }); + }; + + if (isAsyncResultLoading(skill)) { + return ( + +

Loading skill...

+
+ ); + } + + return AsyncResult.match(skill, { + onInitial: () => null, + onFailure: () => ( + + + + ), + onSuccess: ({ value }) => { + const active = value.revisions.find((revision) => revision.id === value.activeRevisionId); + const enabled = value.delivery.kind === "enabled"; + const modelInvocation = enabled && value.delivery.invocation === "model"; + const sourceDisablesModelInvocation = + active?.frontmatter?.["disable-model-invocation"] === true; + const importedSource = value.source.kind === "imported" ? value.source : null; + const sourceTracking = importedSource?.tracking ?? null; + return ( + +
+ + Skills + +
+ + + + + + + + + Delete this skill? + + Executor will remove its revision history and stop delivering it to agents. + + + + Cancel + void handleRemove()}> + Delete skill + + + + + + } + /> + +
+ + + {value.source.kind === "authored" ? "Authored in Executor" : "Imported"} + +
+ + {value.delivery.kind === "blocked" ? ( +
+

Package blocked

+
    + {value.delivery.diagnostics.map((diagnostic) => ( +
  • + {diagnostic.path ? `${diagnostic.path}: ` : ""} + {diagnostic.message} +
  • + ))} +
+
+ ) : ( +
+ + + + + + Allow agents to select this skill? + + Executor will include this skill in model discovery. Its instructions can then + influence an agent without the user naming it first. + + + + Keep manual + void changeDelivery({ kind: "enabled", invocation: "model" })} + > + Allow model selection + + + + +
+ )} + + {importedSource !== null ? ( +
+
+
+

Source tracking

+

+ {importedSource.locator.kind} ·{" "} + {importedSource.tracking.kind === "tracked" + ? "Follow for manual update checks" + : "Pinned to one upstream revision"} +

+
+
+ + {sourceTracking?.kind === "tracked" ? ( + + ) : ( + + )} + + + + + + + Detach this source? + + Executor will keep the current package and revision history, but it will + no longer check this source for updates. + + + + Cancel + void changeSource({ kind: "detach" })}> + Detach source + + + + +
+
+
+ ) : null} + + {value.requirementStatuses.length > 0 ? ( +
+
+
+

+ Requirements +

+

+ Executor reports missing dependencies but never installs or connects them for + you. +

+
+
+
    + {value.requirementStatuses.map((status, index) => { + const integration = + status.requirement.kind === "integration" || + status.requirement.kind === "connection" || + status.requirement.kind === "mcp" + ? status.requirement.integration + : null; + return ( +
  • +
    +

    + {integration === null ? ( + requirementLabel(status) + ) : ( + + {requirementLabel(status)} + + )} +

    + {status.evidence ? ( +

    {status.evidence}

    + ) : null} +
    + + {requirementStatusLabel(status.status)} + +
  • + ); + })} +
+
+ ) : null} + + {active ? ( +
+

+ Package files +

+
+ {active.files.map((file) => ( + + ))} +
+
+ ) : null} + +
+

+ Revision history +

+
    + {value.revisions.map((revision) => { + const isActive = revision.id === value.activeRevisionId; + return ( +
  • +
    +

    + {revision.packageDigest.slice(0, 20)}... +

    +

    + {formatRelativeTime(revision.createdAt)} + {isActive ? " · Active" : ""} +

    +
    + {!isActive ? ( + + ) : null} +
  • + ); + })} +
+
+
+ ); + }, + }); +} diff --git a/packages/react/src/pages/skill-editor.tsx b/packages/react/src/pages/skill-editor.tsx new file mode 100644 index 0000000000..6f68a73c44 --- /dev/null +++ b/packages/react/src/pages/skill-editor.tsx @@ -0,0 +1,580 @@ +import { useEffect, useRef, useState } from "react"; +import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import type { + ManagedSkillId, + Owner, + SkillCandidateId, + SkillRevisionId, +} from "@executor-js/sdk/shared"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Exit from "effect/Exit"; + +import { + createSkill, + discoverSkills, + editSkill, + exportSkill, + importSkillCandidate, + skillAtom, + skillsOptimisticAtom, +} from "../api/atoms"; +import { useOrganizationId } from "../api/organization-context"; +import { skillWriteKeys } from "../api/reactivity-keys"; +import { Button } from "../components/button"; +import { Checkbox } from "../components/checkbox"; +import { FieldLabel } from "../components/field"; +import { Input } from "../components/input"; +import { Label } from "../components/label"; +import { PageContainer, PageHeader } from "../components/page"; +import { Textarea } from "../components/textarea"; +import { useExecutorDocumentTitle } from "../lib/document-title"; +import { FormErrorAlert } from "../lib/integration-add"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; +import { + connectionOwnerOptionsForAccess, + ConnectionOwnerDropdown, + defaultConnectionOwnerForHost, + normalizeConnectionOwner, +} from "../plugins/connection-owner"; + +const NEW_SKILL_TEMPLATE = `--- +name: my-skill +description: What this skill does and when an agent should load it. +--- + +# My skill + +Write the instructions an agent should follow here. +`; + +interface FileDraft { + readonly id: number; + readonly path: string; + readonly mediaType: string; + readonly bytes: string; + readonly text: string | null; +} + +interface CandidatePreview { + readonly id: SkillCandidateId; + readonly name: string | null; + readonly description: string | null; + readonly upstreamRevision: string; +} + +function CandidateInstallActions(props: { + readonly candidateIds: readonly SkillCandidateId[]; + readonly selectedCandidateIds: readonly SkillCandidateId[]; + readonly installing: boolean; + readonly onInstall: (candidateIds: readonly SkillCandidateId[]) => void; +}) { + return ( +
+

+ {props.selectedCandidateIds.length} of {props.candidateIds.length} selected +

+
+ + +
+
+ ); +} + +const encodeBytes = (bytes: Uint8Array): string => { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return globalThis.btoa(binary); +}; + +const encodeText = (value: string): string => encodeBytes(new TextEncoder().encode(value)); + +const decodeText = (encoded: string): string => { + const binary = globalThis.atob(encoded); + return new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))); +}; + +const isTextMediaType = (mediaType: string): boolean => + mediaType.startsWith("text/") || + mediaType.includes("json") || + mediaType.includes("yaml") || + mediaType.includes("xml") || + mediaType.includes("javascript"); + +const mediaTypeFor = (file: File): string => + file.type || + (file.name.endsWith(".md") + ? "text/markdown; charset=utf-8" + : file.name.endsWith(".json") + ? "application/json" + : "application/octet-stream"); + +export function SkillEditorPage(props: { readonly skillId?: ManagedSkillId | undefined }) { + return props.skillId === undefined ? ( + + ) : ( + + ); +} + +function SkillEditorLoader(props: { readonly skillId: ManagedSkillId }) { + const skill = useAtomValue(skillAtom(props.skillId)); + const loadPackage = useAtomSet(exportSkill, { mode: "promiseExit" }); + const [files, setFiles] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + + useEffect(() => { + let active = true; + void loadPackage({ + params: { skillId: props.skillId }, + query: { kind: "backup" }, + }).then((exit) => { + if (!active) return; + if (Exit.isFailure(exit)) { + setLoadFailed(true); + return; + } + setFiles( + exit.value.files.map((file, index) => ({ + id: index, + path: file.path, + mediaType: file.mediaType, + bytes: file.bytes, + text: isTextMediaType(file.mediaType) ? decodeText(file.bytes) : null, + })), + ); + }); + return () => { + active = false; + }; + }, [loadPackage, props.skillId]); + + if (!AsyncResult.isSuccess(skill) || files === null) { + return ( + +

+ {loadFailed || AsyncResult.isFailure(skill) + ? "This skill could not be opened for editing." + : "Loading skill package..."} +

+
+ ); + } + + return ( + + ); +} + +function SkillEditorForm(props: { + readonly skillId?: ManagedSkillId | undefined; + readonly owner?: Owner | undefined; + readonly expectedActiveRevisionId?: SkillRevisionId | undefined; + readonly initialFiles?: readonly FileDraft[] | undefined; + readonly title?: string | undefined; +}) { + const editing = props.skillId !== undefined; + useExecutorDocumentTitle(editing ? `Edit ${props.title ?? "skill"}` : "New skill"); + const organizationId = useOrganizationId(); + const canCreateWorkspaceConnections = useCanCreateWorkspaceConnections(); + const ownerOptions = connectionOwnerOptionsForAccess( + organizationId, + canCreateWorkspaceConnections, + ); + const [owner, setOwner] = useState( + props.owner ?? defaultConnectionOwnerForHost(organizationId), + ); + const [files, setFiles] = useState( + props.initialFiles ?? [ + { + id: 0, + path: "SKILL.md", + mediaType: "text/markdown; charset=utf-8", + bytes: encodeText(NEW_SKILL_TEMPLATE), + text: NEW_SKILL_TEMPLATE, + }, + ], + ); + const [saving, setSaving] = useState(false); + const [sourceInput, setSourceInput] = useState(""); + const [followSource, setFollowSource] = useState(true); + const [discovering, setDiscovering] = useState(false); + const [candidates, setCandidates] = useState([]); + const [selectedCandidateIds, setSelectedCandidateIds] = useState([]); + const [error, setError] = useState(null); + const nextId = useRef(files.length); + const folderInput = useRef(null); + const create = useAtomSet(createSkill, { mode: "promiseExit" }); + const discover = useAtomSet(discoverSkills, { mode: "promiseExit" }); + const importCandidate = useAtomSet(importSkillCandidate, { + mode: "promiseExit", + }); + const edit = useAtomSet(editSkill, { mode: "promiseExit" }); + const refreshSkills = useAtomRefresh(skillsOptimisticAtom); + const navigate = useNavigate(); + const activeOwner = normalizeConnectionOwner(owner, ownerOptions); + + useEffect(() => { + folderInput.current?.setAttribute("webkitdirectory", ""); + }, []); + + const updateFile = (id: number, patch: Partial>) => { + setFiles((current) => current.map((file) => (file.id === id ? { ...file, ...patch } : file))); + }; + + const removeFile = (id: number) => { + setFiles((current) => current.filter((file) => file.id !== id)); + }; + + const importFolder = async (picked: FileList) => { + const selected = Array.from(picked); + const relativePaths = selected.map((file) => file.webkitRelativePath || file.name); + const roots = new Set(relativePaths.map((path) => path.split("/")[0])); + const stripRoot = roots.size === 1 && relativePaths.every((path) => path.includes("/")); + const imported = await Promise.all( + selected.map(async (file, index): Promise => { + const rawPath = relativePaths[index] ?? file.name; + const path = stripRoot ? rawPath.split("/").slice(1).join("/") : rawPath; + const bytes = new Uint8Array(await file.arrayBuffer()); + const mediaType = mediaTypeFor(file); + return { + id: nextId.current++, + path, + mediaType, + bytes: encodeBytes(bytes), + text: isTextMediaType(mediaType) ? new TextDecoder().decode(bytes) : null, + }; + }), + ); + setFiles(imported); + }; + + const addTextFile = () => { + setFiles((current) => [ + ...current, + { + id: nextId.current++, + path: "", + mediaType: "text/plain; charset=utf-8", + bytes: "", + text: "", + }, + ]); + }; + + const discoverSource = async () => { + if (discovering || sourceInput.trim() === "") return; + setDiscovering(true); + setError(null); + const exit = await discover({ + payload: { + source: sourceInput.trim(), + owner: activeOwner, + tracking: followSource ? "follow" : "pin", + }, + reactivityKeys: skillWriteKeys, + }); + setDiscovering(false); + if (Exit.isFailure(exit)) { + setError("Executor could not read that skill source."); + return; + } + setCandidates( + exit.value.candidates.map((candidate) => ({ + id: candidate.id, + name: candidate.revision.name, + description: candidate.revision.description, + upstreamRevision: candidate.upstreamRevision, + })), + ); + setSelectedCandidateIds([]); + if (exit.value.candidates.length === 0) { + setError(exit.value.rejected[0]?.reason ?? "No importable skills were found."); + } + }; + + const installCandidates = async (candidateIds: readonly SkillCandidateId[]) => { + if (saving || candidateIds.length === 0) return; + setSaving(true); + setError(null); + const installedIds: SkillCandidateId[] = []; + let failed = false; + for (const candidateId of candidateIds) { + const exit = await importCandidate({ + payload: { candidateId }, + reactivityKeys: skillWriteKeys, + }); + if (Exit.isFailure(exit)) { + failed = true; + } else { + installedIds.push(candidateId); + } + } + setSaving(false); + if (installedIds.length > 0) { + const installed = new Set(installedIds); + setCandidates((current) => current.filter((candidate) => !installed.has(candidate.id))); + setSelectedCandidateIds((current) => current.filter((id) => !installed.has(id))); + refreshSkills(); + } + if (failed) { + setError( + installedIds.length === 0 + ? "The selected skill previews expired or could not be installed." + : `${installedIds.length} skills were installed, but some previews expired or could not be installed.`, + ); + return; + } + await navigate({ + to: "/{-$orgSlug}/skills", + }); + }; + + const submit = async () => { + if (saving) return; + setSaving(true); + setError(null); + const packagePayload = { + files: files.map((file) => ({ + path: file.path.trim(), + mediaType: file.mediaType, + bytes: file.text === null ? file.bytes : encodeText(file.text), + })), + }; + const exit = + props.skillId === undefined || props.expectedActiveRevisionId === undefined + ? await create({ + payload: { owner: activeOwner, package: packagePayload }, + reactivityKeys: skillWriteKeys, + }) + : await edit({ + params: { skillId: props.skillId }, + payload: { + expectedActiveRevisionId: props.expectedActiveRevisionId, + package: packagePayload, + }, + reactivityKeys: skillWriteKeys, + }); + setSaving(false); + if (Exit.isFailure(exit)) { + setError( + editing + ? "The package could not be saved. It may have changed in another session." + : "The package could not be created. Check its paths and size limits.", + ); + return; + } + refreshSkills(); + await navigate({ + to: "/{-$orgSlug}/skills/$skillId", + params: { skillId: exit.value.id }, + }); + }; + + return ( + + + void submit()}> + Save skill + + } + /> +
+ {error ? : null} + {!editing ? ( + <> + +
+
+ Import from GitHub +

+ Paste a repository, GitHub URL, skills.sh URL, or skills install command. +

+
+
+ setSourceInput(event.target.value)} + /> + +
+ + {candidates.length > 0 ? ( +
+ candidate.id)} + selectedCandidateIds={selectedCandidateIds} + installing={saving} + onInstall={(candidateIds) => void installCandidates(candidateIds)} + /> +
    + {candidates.map((candidate) => { + const checked = selectedCandidateIds.includes(candidate.id); + const label = candidate.name ?? "Blocked package"; + return ( +
  • + +
  • + ); + })} +
+ candidate.id)} + selectedCandidateIds={selectedCandidateIds} + installing={saving} + onInstall={(candidateIds) => void installCandidates(candidateIds)} + /> +
+ ) : null} +
+ + ) : null} +
+
+
+ Package files +

+ A root SKILL.md is required. Scripts and binary assets remain inert. +

+
+
+ + + { + if (event.target.files) void importFolder(event.target.files); + event.target.value = ""; + }} + /> +
+
+
    + {files.map((file) => ( +
  • +
    + updateFile(file.id, { path: event.target.value })} + className="font-mono text-xs" + aria-label="File path" + /> + +
    + {file.text === null ? ( +

    + Binary file, {Math.floor((file.bytes.length * 3) / 4).toLocaleString()} bytes. +

    + ) : ( +