feat: add workspace-backed agent skills
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
import { workspaceApiPath, workspaceRoute } from "./http.ts";
|
||||
import {
|
||||
loadWorkspaceSkillCatalog,
|
||||
workspaceApiPath,
|
||||
workspaceRoute,
|
||||
workspaceSkillActivationPath,
|
||||
workspaceSkillCatalogPath,
|
||||
workspaceSkillDetailPath,
|
||||
} from "./http.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => Promise<void> | void): void;
|
||||
@@ -21,7 +28,10 @@ function assert(condition: unknown, message: string): asserts condition {
|
||||
|
||||
Deno.test("workspace route helpers scope browser routes and API by immutable workspace id", () => {
|
||||
assertEquals(workspaceRoute("workspace 1"), "/w/workspace%201");
|
||||
assertEquals(workspaceRoute("workspace 1", "/objectives"), "/w/workspace%201/objectives");
|
||||
assertEquals(
|
||||
workspaceRoute("workspace 1", "/objectives"),
|
||||
"/w/workspace%201/objectives",
|
||||
);
|
||||
assertEquals(
|
||||
workspaceApiPath("workspace 1", "/repositories/repo-a"),
|
||||
"/api/w/workspace%201/repositories/repo-a",
|
||||
@@ -29,7 +39,9 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor
|
||||
});
|
||||
|
||||
Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped workspace data", async () => {
|
||||
const layout = await Deno.readTextFile(new URL("./../../../routes/+layout.ts", import.meta.url));
|
||||
const layout = await Deno.readTextFile(
|
||||
new URL("./../../../routes/+layout.ts", import.meta.url),
|
||||
);
|
||||
assert(
|
||||
layout.includes('loadJson<WorkspaceResponse>(fetch, "/api/workspace")'),
|
||||
"unscoped layout may use only the workspace-id bootstrap endpoint",
|
||||
@@ -39,7 +51,8 @@ Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped
|
||||
"unscoped layout should redirect to the scoped workspace route",
|
||||
);
|
||||
assert(
|
||||
!layout.includes('`/api${path}`') && !layout.includes('"/api/repositories"'),
|
||||
!layout.includes("`/api${path}`") &&
|
||||
!layout.includes('"/api/repositories"'),
|
||||
"layout must not fall back to unscoped workspace-scoped API calls",
|
||||
);
|
||||
|
||||
@@ -53,3 +66,44 @@ Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped
|
||||
"unscoped settings route should remain a thin redirect shim, not a data/control surface",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Skill API paths use workspace backend scoped endpoints", () => {
|
||||
assertEquals(workspaceSkillCatalogPath("ws 1"), "/api/w/ws%201/skills");
|
||||
assertEquals(
|
||||
workspaceSkillDetailPath("ws 1", "triage-errors"),
|
||||
"/api/w/ws%201/skills/triage-errors",
|
||||
);
|
||||
assertEquals(
|
||||
workspaceSkillActivationPath("ws 1", "triage-errors"),
|
||||
"/api/w/ws%201/skills/triage-errors/activate",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("loadWorkspaceSkillCatalog fetches lightweight catalog", async () => {
|
||||
const result = await loadWorkspaceSkillCatalog(
|
||||
((input: RequestInfo | URL) => {
|
||||
assertEquals(String(input), "/api/w/ws-1/skills");
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
authority: "workspace-backend-skills-v0",
|
||||
entries: [{
|
||||
name: "triage-errors",
|
||||
description: "Use when triaging errors.",
|
||||
provenance: { kind: "workspace", id: "workspace:triage-errors" },
|
||||
overrides: [],
|
||||
diagnostics: [],
|
||||
}],
|
||||
diagnostics: [],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch,
|
||||
"ws-1",
|
||||
);
|
||||
|
||||
assertEquals(result.error, null);
|
||||
assertEquals(result.data?.entries[0].name, "triage-errors");
|
||||
assertEquals(JSON.stringify(result.data).includes("SKILL.md body"), false);
|
||||
});
|
||||
|
||||
@@ -3,19 +3,108 @@ export type ApiResult<T> = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type SkillDiagnosticSeverity = "error" | "warning";
|
||||
|
||||
export type SkillDiagnostic = {
|
||||
severity: SkillDiagnosticSeverity;
|
||||
code: string;
|
||||
message: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type SkillProvenance = {
|
||||
kind: "builtin" | "workspace";
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type SkillCatalogEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
provenance: SkillProvenance;
|
||||
overrides: SkillProvenance[];
|
||||
diagnostics: SkillDiagnostic[];
|
||||
};
|
||||
|
||||
export type SkillCatalogResponse = {
|
||||
authority: string;
|
||||
entries: SkillCatalogEntry[];
|
||||
diagnostics: SkillDiagnostic[];
|
||||
};
|
||||
|
||||
export type SkillResourceRef = {
|
||||
kind: string;
|
||||
name: string;
|
||||
supported: boolean;
|
||||
diagnostic?: string;
|
||||
};
|
||||
|
||||
export type SkillDetailResponse = {
|
||||
name: string;
|
||||
description: string;
|
||||
provenance: SkillProvenance;
|
||||
overrides: SkillProvenance[];
|
||||
diagnostics: SkillDiagnostic[];
|
||||
body: string;
|
||||
allowed_tools: string[];
|
||||
allowed_tools_status: string;
|
||||
resources: SkillResourceRef[];
|
||||
};
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
if (!path || path === '/') return '';
|
||||
return path.startsWith('/') ? path : `/${path}`;
|
||||
if (!path || path === "/") return "";
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
|
||||
export function workspaceRoute(workspaceId: string, path = ''): string {
|
||||
export function workspaceRoute(workspaceId: string, path = ""): string {
|
||||
return `/w/${encodeURIComponent(workspaceId)}${normalizePath(path)}`;
|
||||
}
|
||||
|
||||
export function workspaceApiPath(workspaceId: string, path = ''): string {
|
||||
export function workspaceApiPath(workspaceId: string, path = ""): string {
|
||||
return `/api/w/${encodeURIComponent(workspaceId)}${normalizePath(path)}`;
|
||||
}
|
||||
|
||||
export function workspaceSkillCatalogPath(workspaceId: string): string {
|
||||
return workspaceApiPath(workspaceId, "/skills");
|
||||
}
|
||||
|
||||
export function workspaceSkillDetailPath(
|
||||
workspaceId: string,
|
||||
name: string,
|
||||
): string {
|
||||
return workspaceApiPath(workspaceId, `/skills/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export function workspaceSkillActivationPath(
|
||||
workspaceId: string,
|
||||
name: string,
|
||||
): string {
|
||||
return workspaceApiPath(
|
||||
workspaceId,
|
||||
`/skills/${encodeURIComponent(name)}/activate`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadWorkspaceSkillCatalog(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId: string,
|
||||
): Promise<ApiResult<SkillCatalogResponse>> {
|
||||
return loadJson<SkillCatalogResponse>(
|
||||
fetchFn,
|
||||
workspaceSkillCatalogPath(workspaceId),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadWorkspaceSkillDetail(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId: string,
|
||||
name: string,
|
||||
): Promise<ApiResult<SkillDetailResponse>> {
|
||||
return loadJson<SkillDetailResponse>(
|
||||
fetchFn,
|
||||
workspaceSkillDetailPath(workspaceId, name),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadJson<T>(
|
||||
fetchFn: typeof fetch,
|
||||
path: string,
|
||||
@@ -50,7 +139,10 @@ export async function workspaceApiJson<T>(path: string): Promise<T> {
|
||||
return requireJson<T>(await fetch(path), path);
|
||||
}
|
||||
|
||||
export async function workspaceApiJsonWithBody<T>(path: string, init: RequestInit): Promise<T> {
|
||||
export async function workspaceApiJsonWithBody<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
): Promise<T> {
|
||||
return requireJson<T>(
|
||||
await fetch(path, {
|
||||
headers: {
|
||||
|
||||
Reference in New Issue
Block a user