Files
rea/src/application/SetupSkill.ts
T

714 lines
22 KiB
TypeScript

import { copyFile, lstat, mkdir, readFile } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { dirname, join } from "node:path";
import { err, ok, type Result } from "../domain/result.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import {
NonRegularFileReadError,
RegularFileChangedError,
} from "../filesystem/RegularFile.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import { withRegularFile } from "./RegularFileRead.js";
import { clientSkillDirectories } from "./SupportedClients.js";
import { preserveCurrentRecoveryBackup } from "./SetupRecoveryBackup.js";
import {
SkillFilePublication,
SkillFilePreparationFailure,
type SkillFileSnapshot,
type SkillPublicationCleanupOwner,
} from "./SkillFilePublication.js";
export const MANAGED_SKILL_FILES = [
"SKILL.md",
"references/connection-and-recovery.md",
"references/native-and-artifacts.md",
"references/javascript-applications.md",
"references/android-applications.md",
"references/runtime-observation.md",
"references/evidence-workflows.md",
] as const;
export const isManagedSkillManifest = (content: string): boolean =>
content.startsWith(`---\nname: ${PRODUCT_IDENTITY.skillName}\n`) ||
content.startsWith(`---\r\nname: ${PRODUCT_IDENTITY.skillName}\r\n`);
export const readSkillFile = (
path: string,
resources?: ArtifactResourceScope,
): Promise<string> =>
readSkillFileSnapshot(path, resources).then(({ bytes }) =>
bytes.toString("utf8"),
);
const readSkillFileSnapshot = async (
path: string,
resources?: ArtifactResourceScope,
): Promise<SkillFileSnapshot> =>
withRegularFile(
path,
async (handle, stats) => {
const bytes = await readBoundedFileBytes(handle, stats.size, {
expectedSize: stats.size,
});
if (bytes === undefined || bytes.length !== stats.size)
throw new RegularFileChangedError(path);
return { bytes, mode: stats.mode & 0o7777, state: stats };
},
{
symlinks: "reject",
...(resources === undefined ? {} : { resources }),
},
);
interface CanonicalSkillFile {
readonly content: string;
readonly destination: string;
readonly original: SkillFileSnapshot | undefined;
}
const canonicalSkillContent = (relativePath: string): Promise<string> =>
readFile(
new URL(
`../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`,
import.meta.url,
),
"utf8",
);
export interface SkillInstallFileOperations {
readonly mkdir: (path: string) => Promise<void>;
readonly backup: (source: string, destination: string) => Promise<void>;
readonly prepare: typeof SkillFilePublication.prepare;
}
export interface SkillInstallRollbackFailure {
readonly path: string;
readonly reason: string;
}
/** Expected failure while replacing a managed skill bundle. */
export class SetupSkillInstallError extends Error {
readonly _tag = "SetupSkillInstallError";
readonly path: string;
readonly stage: "prepare" | "backup" | "write" | "readback";
readonly reason: string;
readonly rollbackFailures: readonly SkillInstallRollbackFailure[];
readonly recoveryBackupPaths: readonly string[];
readonly cleanupOwners: readonly SkillPublicationCleanupOwner[];
/** Retry only retained staging cleanup; never reapply a pathname rollback. */
async retryCleanup(): Promise<readonly SkillInstallRollbackFailure[]> {
return closeSkillPublications(this.cleanupOwners);
}
constructor(input: {
readonly path: string;
readonly stage: "prepare" | "backup" | "write" | "readback";
readonly reason: string;
readonly rollbackFailures: readonly SkillInstallRollbackFailure[];
readonly recoveryBackupPaths: readonly string[];
readonly cleanupOwners?: readonly SkillPublicationCleanupOwner[];
readonly cause: unknown;
}) {
const message = [
`REA skill ${input.stage} failed at ${input.path}: ${input.reason}.`,
...(input.rollbackFailures.length === 0
? []
: [
`Rollback also failed for ${input.rollbackFailures.map(({ path, reason }) => `${path} (${reason})`).join(", ")}.`,
]),
...(input.recoveryBackupPaths.length === 0
? []
: [
`Existing-file backups remain at ${input.recoveryBackupPaths.join(", ")}.`,
]),
].join(" ");
super(message, { cause: input.cause });
this.path = input.path;
this.stage = input.stage;
this.reason = input.reason;
this.rollbackFailures = input.rollbackFailures;
this.recoveryBackupPaths = input.recoveryBackupPaths;
this.cleanupOwners = input.cleanupOwners ?? [];
}
get remediation(): string {
return `${this.message} Check permissions and rerun setup.`;
}
}
export type SkillInstallValue = "installed" | "unchanged";
export type SkillInstallResult = Result<
SkillInstallValue,
SetupSkillInstallError
>;
const systemSkillInstallFileOperations: SkillInstallFileOperations = {
mkdir: async (path) => {
await mkdir(path, { recursive: true });
},
backup: (source, destination) =>
copyFile(source, destination, fsConstants.COPYFILE_EXCL),
prepare: SkillFilePublication.prepare,
};
/** Managed skill location selected for a client family. */
export interface SkillDestination {
readonly client: string;
readonly path: string;
}
/** Metadata and byte alignment observed in selected skill installations. */
export interface InstalledSkillIdentity {
readonly version: string | null;
readonly toolCount: number | null;
readonly canonical: boolean;
readonly issues?: readonly SkillPathIssue[];
}
/** Known management blockers and unresolved filesystem observations. */
export interface SkillPathIssue {
readonly path: string;
readonly kind:
| "symbolic_link"
| "not_directory"
| "unreadable"
| "unresolved"
| "stale"
| "missing";
readonly client?: string;
readonly detail: string;
}
/** Keep unresolved selected roots as diagnostics, never as writable destinations. */
export const skillDestinationSelection = (
home: string,
clientIds: readonly string[] | undefined,
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): {
readonly destinations: readonly SkillDestination[];
readonly issues: readonly SkillPathIssue[];
} => {
const selection = clientSkillDirectories(
home,
clientIds,
environment,
platform,
);
return {
destinations: selection.directories.map(({ client, directory }) => ({
client,
path: join(directory, PRODUCT_IDENTITY.skillName),
})),
issues: selection.unresolved.map(({ client, path, detail }) => ({
client,
path,
detail,
kind: "unresolved",
})),
};
};
/** Writable destinations are available only after all selected roots resolve. */
export const skillDestinations = (
home: string,
clientIds: readonly string[] | undefined,
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): readonly SkillDestination[] => {
const selection = skillDestinationSelection(
home,
clientIds,
environment,
platform,
);
if (selection.issues.length > 0)
throw skillDirectoryFailure(selection.issues);
return selection.destinations;
};
/** Resolve existing REA-owned skill roots using setup's canonical destinations. */
export const existingSkillDestinations = async (
home: string,
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
resources?: ArtifactResourceScope,
): Promise<{
readonly destinations: readonly SkillDestination[];
readonly issues: readonly SkillPathIssue[];
}> => {
const selection = skillDestinationSelection(
home,
undefined,
environment,
platform,
);
const issues: SkillPathIssue[] = [...selection.issues];
const existing: SkillDestination[] = [];
for (const destination of selection.destinations) {
try {
const pathIssues = await skillDirectoryIssues([destination]);
if (pathIssues.some(({ kind }) => kind === "unreadable")) {
issues.push(...pathIssues);
continue;
}
if (pathIssues.length > 0) continue;
const manifest = join(destination.path, "SKILL.md");
const content = await readSkillFile(manifest, resources);
if (isManagedSkillManifest(content)) existing.push(destination);
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
continue;
if (
cause instanceof RegularFileCleanupFailure ||
cause instanceof ArtifactReaderFailure
)
throw cause;
if (cause instanceof NonRegularFileReadError) continue;
issues.push({
client: destination.client,
path: destination.path,
kind: "unreadable",
detail: causeReason(cause),
});
}
}
return { destinations: existing, issues };
};
const readOptionalSkillState = async (
path: string,
resources?: ArtifactResourceScope,
): Promise<SkillFileSnapshot | undefined> => {
try {
return await readSkillFileSnapshot(path, resources);
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return undefined;
throw cause;
}
};
const inspectSkillDirectory = async (
path: string,
): Promise<Result<void, SkillPathIssue>> => {
try {
const directory = await lstat(path);
if (directory.isSymbolicLink())
return err({
path,
kind: "symbolic_link",
detail: "Skill directory is a symbolic link.",
});
if (!directory.isDirectory())
return err({
path,
kind: "not_directory",
detail: "Skill path is not a directory.",
});
return ok(undefined);
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return ok(undefined);
return err({
path,
kind: "unreadable",
detail: cause instanceof Error ? cause.message : String(cause),
});
}
};
const skillDirectoryIssues = async (
destinations: readonly SkillDestination[],
): Promise<readonly SkillPathIssue[]> => {
const issues: SkillPathIssue[] = [];
for (const { path } of destinations) {
const root = await inspectSkillDirectory(path);
if (!root.ok) {
issues.push(root.error);
continue;
}
const references = await inspectSkillDirectory(join(path, "references"));
if (!references.ok) issues.push(references.error);
}
return issues;
};
const skillDirectoryFailure = (
issues: readonly SkillPathIssue[],
): SetupSkillInstallError =>
new SetupSkillInstallError({
path: issues[0]?.path ?? "skill bundle",
stage: "prepare",
reason: `${issues.map(({ path, detail }) => `${detail} (${path})`).join(" ")} Use a regular skill directory, or run setup with --skill=false to configure the client without installing the skill`,
rollbackFailures: [],
recoveryBackupPaths: [],
cause: issues,
});
/** Validate only final selected roots; permission to install is separate from freshness. */
export const validateCanonicalSkillDirectories = async (
home: string,
clientIds: readonly string[],
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): Promise<Result<void, SetupSkillInstallError>> => {
const selection = skillDestinationSelection(
home,
clientIds,
environment,
platform,
);
const issues = [
...selection.issues,
...(await skillDirectoryIssues(selection.destinations)),
];
return issues.length === 0
? ok(undefined)
: err(skillDirectoryFailure(issues));
};
const canonicalSkillFiles = async (
home: string,
clientIds: readonly string[],
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
resources?: ArtifactResourceScope,
): Promise<readonly CanonicalSkillFile[]> => {
const destinations = skillDestinations(
home,
clientIds,
environment,
platform,
);
const admission = await validateCanonicalSkillDirectories(
home,
clientIds,
environment,
platform,
);
if (!admission.ok) throw admission.error;
return Promise.all(
destinations.flatMap(({ path: root }) =>
MANAGED_SKILL_FILES.map(async (relativePath) => {
const destination = join(root, relativePath);
return {
destination,
content: await canonicalSkillContent(relativePath),
original: await readOptionalSkillState(destination, resources),
};
}),
),
);
};
interface CanonicalSkillInspection {
readonly canonical: boolean;
readonly manifest: string | undefined;
readonly installedDestination: boolean;
}
interface SkillInspectionFailure {
readonly _tag: "SkillInspectionFailure";
readonly issues: readonly SkillPathIssue[];
readonly cause: unknown;
readonly manifest: string | undefined;
}
const inspectCanonicalSkill = async (
home: string,
clientIds: readonly string[] | undefined,
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
resources?: ArtifactResourceScope,
): Promise<Result<CanonicalSkillInspection, SkillInspectionFailure>> => {
const selection = skillDestinationSelection(
home,
clientIds,
environment,
platform,
);
const destinations = selection.destinations;
const issues: SkillPathIssue[] = [...selection.issues];
let canonical = true;
let manifest: string | undefined;
let failureCause: unknown = issues.length > 0 ? issues : undefined;
let installedDestination = false;
for (const destination of destinations) {
const { path: root } = destination;
const pathIssues = await skillDirectoryIssues([destination]);
if (pathIssues.length > 0) {
issues.push(...pathIssues);
if (failureCause === undefined) failureCause = pathIssues;
installedDestination = true;
canonical = false;
continue;
}
let rootCanonical = true;
let rootHasFiles = false;
const rootIssues: SkillPathIssue[] = [];
for (const relativePath of MANAGED_SKILL_FILES) {
const destination = join(root, relativePath);
let original: SkillFileSnapshot | undefined;
try {
original = await readOptionalSkillState(destination, resources);
} catch (cause: unknown) {
if (
cause instanceof RegularFileCleanupFailure ||
cause instanceof ArtifactReaderFailure
)
throw cause;
rootIssues.push({
path: destination,
kind: "unreadable",
detail: causeReason(cause),
});
if (failureCause === undefined) failureCause = cause;
rootCanonical = false;
rootHasFiles = true;
continue;
}
if (original !== undefined) rootHasFiles = true;
if (relativePath === "SKILL.md" && manifest === undefined)
manifest = original?.bytes.toString("utf8");
// A mismatch settles freshness, but not coverage of later read failures.
if (original === undefined) rootCanonical = false;
else if (rootCanonical) {
const content = await canonicalSkillContent(relativePath);
rootCanonical = original.bytes.equals(Buffer.from(content, "utf8"));
}
}
if (clientIds !== undefined || rootHasFiles || rootIssues.length > 0) {
installedDestination ||= rootHasFiles;
canonical = canonical && rootCanonical;
issues.push(...rootIssues);
if (!rootCanonical && rootIssues.length === 0)
issues.push({
client: destination.client,
path: root,
kind: rootHasFiles ? "stale" : "missing",
detail: `Selected skill copy is ${rootHasFiles ? "stale" : "missing"}`,
});
}
}
return issues.length === 0
? ok({ canonical, manifest, installedDestination })
: err({
_tag: "SkillInspectionFailure",
issues,
cause: failureCause,
manifest,
});
};
/** Report whether setup would change any file in the managed REA skill bundle. */
export const canonicalSkillNeedsInstall = async (
home: string,
clientIds: readonly string[] = [],
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
resources?: ArtifactResourceScope,
): Promise<boolean> => {
try {
const inspection = await inspectCanonicalSkill(
home,
clientIds,
environment,
platform,
resources,
);
return !inspection.ok || !inspection.value.canonical;
} catch (cause: unknown) {
if (
cause instanceof RegularFileCleanupFailure ||
cause instanceof ArtifactReaderFailure
)
throw cause;
// A copy needs attention; selected-root admission decides whether it may be installed.
void cause;
return true;
}
};
const causeReason = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
const causePath = (cause: unknown): string | undefined =>
cause instanceof Error && "path" in cause && typeof cause.path === "string"
? cause.path
: undefined;
const closeSkillPublications = async (
publications: readonly SkillPublicationCleanupOwner[],
resources?: ArtifactResourceScope,
): Promise<readonly SkillInstallRollbackFailure[]> => {
const failures: SkillInstallRollbackFailure[] = [];
for (const publication of publications) {
if (publication.lease.released) continue;
try {
if (resources === undefined) await publication.close();
else {
const released = await resources.release(publication);
if (released.kind === "failed") throw released.cause;
}
} catch (cause: unknown) {
failures.push({
path: publication.resource,
reason: causeReason(cause),
});
}
}
return failures;
};
/** Transactionally install the canonical skill bundle and optional references. */
export const installCanonicalSkill = async (
home: string,
clientIds: readonly string[] = [],
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
operations: SkillInstallFileOperations = systemSkillInstallFileOperations,
resources?: ArtifactResourceScope,
): Promise<SkillInstallResult> => {
const publications: SkillFilePublication[] = [];
const preparedCleanupOwners: SkillPublicationCleanupOwner[] = [];
let verified = false;
let stage: "prepare" | "backup" | "write" | "readback" = "prepare";
let failingPath = "skill bundle";
const recoveryPaths = new Map<string, string>();
try {
const canonical = await canonicalSkillFiles(
home,
clientIds,
environment,
platform,
resources,
);
const changed = canonical.filter(
({ content, original }) =>
original?.bytes.equals(Buffer.from(content, "utf8")) !== true,
);
if (changed.length === 0) return ok("unchanged");
stage = "backup";
for (const { destination, original } of changed) {
failingPath = destination;
await operations.mkdir(dirname(destination));
if (original !== undefined) {
const preserved = await preserveCurrentRecoveryBackup(
`${destination}.rea.backup`,
destination,
original.bytes,
{
copy: operations.backup,
...(resources === undefined ? {} : { resources }),
},
);
if (!preserved.ok) throw new Error(preserved.error.detail);
recoveryPaths.set(destination, preserved.value);
}
}
stage = "write";
for (const file of changed) {
failingPath = file.destination;
const publication = await operations.prepare(
file.destination,
Buffer.from(file.content, "utf8"),
file.original,
);
// Ownership exists before publish can succeed and subsequently reject.
publications.push(publication);
await publication.publish();
}
stage = "readback";
for (const publication of publications) {
failingPath = publication.path;
await publication.assertCurrent();
}
verified = true;
const cleanupFailures = await closeSkillPublications(
publications,
resources,
);
if (cleanupFailures.length > 0)
throw new Error(
`Skill publication cleanup incomplete: ${cleanupFailures.map(({ path, reason }) => `${path} (${reason})`).join(", ")}`,
);
return ok("installed");
} catch (cause: unknown) {
if (stage === "prepare" && cause instanceof SetupSkillInstallError)
return err(cause);
failingPath = causePath(cause) ?? failingPath;
if (cause instanceof SkillFilePreparationFailure)
preparedCleanupOwners.push(cause.publication);
const rollbackFailures: SkillInstallRollbackFailure[] = [];
if (!verified) {
for (const publication of [...publications].reverse()) {
try {
await publication.restore();
} catch (rollbackCause: unknown) {
rollbackFailures.push({
path: publication.path,
reason: causeReason(rollbackCause),
});
}
}
}
const cleanupOwners = [...publications, ...preparedCleanupOwners];
rollbackFailures.push(
...(await closeSkillPublications(cleanupOwners, resources)),
);
return err(
new SetupSkillInstallError({
path: failingPath,
stage,
reason: causeReason(cause),
rollbackFailures,
recoveryBackupPaths: [...recoveryPaths.values()],
cleanupOwners,
cause,
}),
);
}
};
/** Read installed skill metadata and compare every managed location. */
export const readInstalledSkillIdentity = async (
home: string,
clientIds: readonly string[] | undefined,
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
resources?: ArtifactResourceScope,
): Promise<InstalledSkillIdentity | undefined> => {
const inspection = await inspectCanonicalSkill(
home,
clientIds,
environment,
platform,
resources,
);
if (inspection.ok && !inspection.value.installedDestination) return undefined;
const content = inspection.ok
? inspection.value.manifest
: inspection.error.manifest;
const issues = inspection.ok ? [] : inspection.error.issues;
const toolCount =
content === undefined
? undefined
: /^\s{2}tool_count:\s*(\d+)\s*$/mu.exec(content)?.[1];
return {
canonical: inspection.ok && inspection.value.canonical,
version:
content === undefined
? null
: (/^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null),
toolCount: toolCount === undefined ? null : Number.parseInt(toolCount, 10),
...(issues.length === 0 ? {} : { issues }),
};
};