Manage orchestration skills per host (#3451)
* feat(skills): manage orchestration skills per host Agent skills execute on the daemon host, so their bundle, selection, and filesystem convergence must share that owner. Preserve the existing install semantics while letting every capable client manage the selected host and migrating the legacy desktop preference once. * fix(client): keep skill management internal Keep host skill management on the low-level daemon client used by the app. Do not expose daemon settings or the desktop-only compatibility migration through the shared Paseo API handed to plugins. * fix(e2e): declare skills fixture dependencies Use Playwright's required destructuring form for the test-scoped daemon sandbox fixture so CI can collect the real-host skills suite. * test(skills): use native managed paths Assert fixed skill targets relative to the daemon user's real home with platform-native path joining so the ownership contract holds on Windows and POSIX hosts.
This commit is contained in:
@@ -1,12 +1,6 @@
|
||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
import {
|
||||
parseSkillsSaveResult,
|
||||
parseSkillsSnapshot,
|
||||
type SkillSelection,
|
||||
type SkillsSaveResult,
|
||||
type SkillsSnapshot,
|
||||
} from "@/desktop/daemon/skills-snapshot";
|
||||
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
||||
export type DesktopDaemonStopReason =
|
||||
@@ -232,38 +226,12 @@ export async function installCli(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
||||
}
|
||||
|
||||
export type {
|
||||
SkillOp,
|
||||
SkillSelection,
|
||||
SkillsSaveResult,
|
||||
SkillsSnapshot,
|
||||
SkillsState,
|
||||
} from "@/desktop/daemon/skills-snapshot";
|
||||
|
||||
export async function getSkillsSnapshot(): Promise<SkillsSnapshot> {
|
||||
return parseSkillsSnapshot(await invokeDesktopCommand("get_skills_status"));
|
||||
// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16.
|
||||
export function readLegacySkillSelection(): Promise<AgentSkillSelection | null> {
|
||||
return invokeDesktopCommand("read_legacy_skill_selection") as Promise<AgentSkillSelection | null>;
|
||||
}
|
||||
|
||||
export async function installSkills(): Promise<SkillsSnapshot> {
|
||||
return parseSkillsSnapshot(await invokeDesktopCommand("install_skills"));
|
||||
}
|
||||
|
||||
export async function updateSkills(): Promise<SkillsSnapshot> {
|
||||
return parseSkillsSnapshot(await invokeDesktopCommand("update_skills"));
|
||||
}
|
||||
|
||||
export async function uninstallSkills(): Promise<SkillsSnapshot> {
|
||||
return parseSkillsSnapshot(await invokeDesktopCommand("uninstall_skills"));
|
||||
}
|
||||
|
||||
export async function saveSkillsSelection(
|
||||
selection: SkillSelection,
|
||||
confirmedRemovals: readonly string[] = [],
|
||||
): Promise<SkillsSaveResult> {
|
||||
return parseSkillsSaveResult(
|
||||
await invokeDesktopCommand("save_skills_selection", {
|
||||
...selection,
|
||||
confirmedRemovals: [...confirmedRemovals],
|
||||
}),
|
||||
);
|
||||
// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16.
|
||||
export async function deleteLegacySkillSelection(): Promise<void> {
|
||||
await invokeDesktopCommand("delete_legacy_skill_selection");
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseSkillsSaveResult, parseSkillsSnapshot } from "./skills-snapshot";
|
||||
|
||||
describe("parseSkillsSnapshot", () => {
|
||||
it("parses a full snapshot from the desktop command surface", () => {
|
||||
expect(
|
||||
parseSkillsSnapshot({
|
||||
state: "drift",
|
||||
ops: [
|
||||
{ kind: "add", name: "paseo-loop" },
|
||||
{ kind: "delete", name: "paseo-chat" },
|
||||
],
|
||||
available: ["paseo", "paseo-loop"],
|
||||
installed: ["paseo"],
|
||||
selection: { mode: "custom", skills: ["paseo", "paseo-loop"] },
|
||||
}),
|
||||
).toEqual({
|
||||
state: "drift",
|
||||
ops: [
|
||||
{ kind: "add", name: "paseo-loop" },
|
||||
{ kind: "delete", name: "paseo-chat" },
|
||||
],
|
||||
available: ["paseo", "paseo-loop"],
|
||||
installed: ["paseo"],
|
||||
selection: { mode: "custom", skills: ["paseo", "paseo-loop"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("parses the installed skills the host reports", () => {
|
||||
expect(
|
||||
parseSkillsSnapshot({
|
||||
state: "drift",
|
||||
ops: [{ kind: "add", name: "paseo-loop" }],
|
||||
available: ["paseo", "paseo-loop"],
|
||||
installed: ["paseo-loop", 7],
|
||||
selection: { mode: "all" },
|
||||
}).installed,
|
||||
).toEqual(["paseo-loop"]);
|
||||
});
|
||||
|
||||
it("assumes the saved selection is installed when the host does not report it", () => {
|
||||
// An older host has no `installed` field. Assuming the selection is on disk
|
||||
// keeps the destructive confirmation firing rather than silently skipping it.
|
||||
expect(
|
||||
parseSkillsSnapshot({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
available: ["paseo", "paseo-advisor", "paseo-loop"],
|
||||
selection: { mode: "custom", skills: ["paseo", "paseo-loop"] },
|
||||
}).installed,
|
||||
).toEqual(["paseo", "paseo-loop"]);
|
||||
});
|
||||
|
||||
it("assumes every bundled skill is installed for an all selection from an older host", () => {
|
||||
expect(
|
||||
parseSkillsSnapshot({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
available: ["paseo", "paseo-loop"],
|
||||
}).installed,
|
||||
).toEqual(["paseo", "paseo-loop"]);
|
||||
});
|
||||
|
||||
it("reads a snapshot with no saved selection as all skills", () => {
|
||||
expect(parseSkillsSnapshot({ state: "up-to-date", ops: [], available: ["paseo"] })).toEqual({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
available: ["paseo"],
|
||||
installed: ["paseo"],
|
||||
selection: { mode: "all" },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops catalog and selection entries that are not skill names", () => {
|
||||
expect(
|
||||
parseSkillsSnapshot({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
available: ["paseo", 7, null, "paseo-loop"],
|
||||
selection: { mode: "custom", skills: ["paseo", 7] },
|
||||
}),
|
||||
).toEqual({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
available: ["paseo", "paseo-loop"],
|
||||
installed: ["paseo"],
|
||||
selection: { mode: "custom", skills: ["paseo"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the removals the host wants confirmed", () => {
|
||||
expect(
|
||||
parseSkillsSaveResult({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
available: ["paseo", "paseo-loop"],
|
||||
installed: ["paseo", "paseo-loop"],
|
||||
selection: { mode: "all" },
|
||||
confirmationRequired: { removals: ["paseo-loop", 7] },
|
||||
}).confirmationRequired,
|
||||
).toEqual({ removals: ["paseo-loop"] });
|
||||
});
|
||||
|
||||
it("treats a save with no confirmation request as applied", () => {
|
||||
expect(
|
||||
parseSkillsSaveResult({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
available: ["paseo"],
|
||||
installed: ["paseo"],
|
||||
selection: { mode: "all" },
|
||||
}).confirmationRequired,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a response that is not an object", () => {
|
||||
expect(() => parseSkillsSnapshot("nope")).toThrow("Unexpected skills status response.");
|
||||
});
|
||||
|
||||
it("rejects an unknown install state", () => {
|
||||
expect(() => parseSkillsSnapshot({ state: "half-installed", ops: [] })).toThrow(
|
||||
"Unexpected skills status state: half-installed",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unknown pending operation kind", () => {
|
||||
expect(() =>
|
||||
parseSkillsSnapshot({ state: "drift", ops: [{ kind: "relocate", name: "paseo" }] }),
|
||||
).toThrow("Unexpected skill op kind: relocate");
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
export type SkillsState = "not-installed" | "up-to-date" | "drift";
|
||||
|
||||
export type SkillOp =
|
||||
| { kind: "add"; name: string }
|
||||
| { kind: "update"; name: string }
|
||||
| { kind: "delete"; name: string };
|
||||
|
||||
/** What the user asked to have installed. `all` follows the bundle as it grows. */
|
||||
export type SkillSelection = { mode: "all" } | { mode: "custom"; skills: string[] };
|
||||
|
||||
export interface SkillsSnapshot {
|
||||
state: SkillsState;
|
||||
ops: SkillOp[];
|
||||
/** Every skill the host currently bundles, sorted. The selectable catalog. */
|
||||
available: string[];
|
||||
/**
|
||||
* Skills with a directory in at least one agent home. An `add` op only means
|
||||
* "missing from at least one target", so it cannot stand in for this.
|
||||
*/
|
||||
installed: string[];
|
||||
selection: SkillSelection;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseSkillsState(value: unknown): SkillsState {
|
||||
switch (value) {
|
||||
case "not-installed":
|
||||
case "up-to-date":
|
||||
case "drift":
|
||||
return value;
|
||||
default:
|
||||
throw new Error(`Unexpected skills status state: ${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSkillOp(raw: unknown): SkillOp {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected skill op response.");
|
||||
}
|
||||
const name = typeof raw.name === "string" ? raw.name.trim() : "";
|
||||
if (!name) throw new Error("Skill op missing name.");
|
||||
switch (raw.kind) {
|
||||
case "add":
|
||||
return { kind: "add", name };
|
||||
case "update":
|
||||
return { kind: "update", name };
|
||||
case "delete":
|
||||
return { kind: "delete", name };
|
||||
default:
|
||||
throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSkillNames(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((entry): entry is string => typeof entry === "string");
|
||||
}
|
||||
|
||||
function parseSkillSelection(value: unknown): SkillSelection {
|
||||
// A host that predates the picker sends no selection; it manages every
|
||||
// bundled skill, which is exactly what `all` means.
|
||||
if (!isRecord(value)) return { mode: "all" };
|
||||
if (value.mode === "custom") return { mode: "custom", skills: parseSkillNames(value.skills) };
|
||||
return { mode: "all" };
|
||||
}
|
||||
|
||||
function selectedSkills(selection: SkillSelection, available: readonly string[]): string[] {
|
||||
if (selection.mode === "all") return [...available];
|
||||
const chosen = new Set(selection.skills);
|
||||
return available.filter((name) => chosen.has(name));
|
||||
}
|
||||
|
||||
export interface SkillsSaveResult extends SkillsSnapshot {
|
||||
/**
|
||||
* Non-null when the host refused the save because these directories would be
|
||||
* deleted. Confirm the names with the user and retry with them.
|
||||
*/
|
||||
confirmationRequired: { removals: string[] } | null;
|
||||
}
|
||||
|
||||
export function parseSkillsSaveResult(raw: unknown): SkillsSaveResult {
|
||||
const snapshot = parseSkillsSnapshot(raw);
|
||||
const required =
|
||||
isRecord(raw) && isRecord(raw.confirmationRequired)
|
||||
? parseSkillNames(raw.confirmationRequired.removals)
|
||||
: [];
|
||||
return {
|
||||
...snapshot,
|
||||
confirmationRequired: required.length > 0 ? { removals: required } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSkillsSnapshot(raw: unknown): SkillsSnapshot {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected skills status response.");
|
||||
}
|
||||
const available = parseSkillNames(raw.available);
|
||||
const selection = parseSkillSelection(raw.selection);
|
||||
return {
|
||||
state: parseSkillsState(raw.state),
|
||||
ops: Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : [],
|
||||
available,
|
||||
// A host that predates this field cannot say what is on disk. Assume the
|
||||
// saved selection is, so removing a skill still asks before deleting it.
|
||||
installed: Array.isArray(raw.installed)
|
||||
? parseSkillNames(raw.installed)
|
||||
: selectedSkills(selection, available),
|
||||
selection,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user