Files
orca/src/shared/skill-delete-contract.ts
T
Jinjing e361da7fb7 Deleting skill (#16357)
* Add skill deletion with cross-platform transaction safety

Implements end-to-end skill removal with placement enumeration, dependency guards, and transactional recovery. Covers native, WSL, and remote hosts; users can delete canonical directories and alias placements (symlinked directories or files) in a single atomic batch. Includes UI selection flow, preview, confirmation, and results band. Block reasons (bundled, plugin, unowned, stale) gate deletions that would fail or contradict user intent.

* Organize IPC handlers into module subdirectories

Move register-core-handlers and skill-delete-ipc-handlers into
dedicated subdirectories for improved code organization and to
reduce the flat structure in src/main/ipc/.

* Make skill deletion recovery transactions idempotent

Defer journal cleanup until both staging removal and receipt cleanup succeed, leaving the journal in place for startup to retry if either operation fails. This ensures the recovery process is safe to run multiple times without leaving partially-deleted skills.

* Consolidate skill-delete files into dedicated module

Reorganize skill deletion functionality into a modular structure under
`src/main/skills/skill-delete/` with simplified file names. Remove the
redundant `skill-delete-` prefix from file names since they now live in
the dedicated directory. Update all import paths throughout the codebase
to reflect the new structure, including imports from IPC handlers and
RPC methods.

* Fix broken import paths and add deletion robustness improvements

Import paths using `..//'` were invalid and broken. Replace with explicit
module names (`skill-discovery-sources`, `skill-install-filesystem`, etc.)
to clarify dependencies.

- Bind WSL filesystem methods to preserve `this` context
- Keep recovery journal when rollback rename fails, so startup can retry
- Skip symlink-based tests on Windows where they cannot run
- Only treat ENOENT/ENOTDIR as empty directories; propagate other errors
- Fix cross-platform path parent calculation to handle drive roots
- Replace shared constant with localized string for user-facing message
- Use `runProcess` for WSL integration test instead of bare `execFile`

* Add batch limit for skill deletion and improve host availability checkin

- Limit concurrent deletions to prevent remote host overload
- Add retry logic for capability probing to handle transient unavailability
- Add reprobe() method to recheck capability after errors or user refresh
- Fix status logic: receipt cleanup is best-effort, completion depends only on content removal
- Improve error message for unreachable hosts
2026-08-25 03:58:48 -07:00

121 lines
3.8 KiB
TypeScript

import { z } from 'zod'
import { SkillDiscoveryTargetSchema } from './skills'
/** Why a closed vocabulary: the result band groups skips by reason, and a remote
* host cannot localize free-text prose for the client that renders it. */
export const SKILL_DELETE_BLOCK_REASONS = [
'bundled',
'plugin',
'unowned',
'missing',
'stale'
] as const
export type SkillDeleteBlockReason = (typeof SKILL_DELETE_BLOCK_REASONS)[number]
export const SKILL_DELETE_PLACEMENT_KINDS = ['canonical', 'alias-dir', 'alias-file'] as const
export type SkillDeletePlacementKind = (typeof SKILL_DELETE_PLACEMENT_KINDS)[number]
export const SKILL_DELETE_STATUSES = ['deleted', 'skipped', 'partial', 'failed', 'busy'] as const
export type SkillDeleteStatus = (typeof SKILL_DELETE_STATUSES)[number]
/** One page of rows; a selection larger than this is not a thing the UI offers. */
export const MAX_SKILL_DELETE_BATCH = 512
const SkillPathSchema = z.string().min(1).max(4096)
const SkillDeleteTargetSkillSchema = z
.object({
/** `DiscoveredSkill.id` — identity, so host and client agree without
* re-deriving it from a path that may have just been renamed. */
id: z.string().min(1).max(128),
directoryPath: SkillPathSchema,
skillFilePath: SkillPathSchema,
name: z.string().min(1).max(256),
/** `stat(skillFilePath).mtimeMs` as displayed; null fails the guard closed. */
updatedAt: z.number().nullable()
})
.strict()
/** Strict on purpose (matching `SkillInstallRequestSchema`): an old host must
* reject a field that would change what gets deleted, not ignore it. */
export const SkillDeleteRequestSchema = z
.object({
operationId: z.string().min(1).max(128),
// Send exactly what the scan sent — usually nothing.
target: SkillDiscoveryTargetSchema.optional(),
skills: z.array(SkillDeleteTargetSkillSchema).min(1).max(MAX_SKILL_DELETE_BATCH)
})
.strict()
export type SkillDeleteRequest = z.infer<typeof SkillDeleteRequestSchema>
export type SkillDeleteTargetSkill = z.infer<typeof SkillDeleteTargetSkillSchema>
export type SkillDeletePlacement = {
path: string
kind: SkillDeletePlacementKind
rootLabel: string
}
export type SkillDeletePlanEntry = {
id: string
name: string
/** realpath of `skillFilePath`. NOT the lock key — see the delete service. */
canonicalPath: string
placements: SkillDeletePlacement[]
blocked?: SkillDeleteBlockReason
}
export type SkillDeletePlan = {
operationId: string
skills: SkillDeletePlanEntry[]
}
export type SkillDeleteResultEntry = {
id: string
name: string
status: SkillDeleteStatus
/** Present when status is 'skipped'; same vocabulary as the plan. */
blocked?: SkillDeleteBlockReason
removedPaths: string[]
/** Present when status is 'partial'. */
stagedPaths?: string[]
}
export type SkillDeleteResult = {
operationId: string
skills: SkillDeleteResultEntry[]
}
const SkillDeletePlacementSchema = z.object({
path: SkillPathSchema,
kind: z.enum(SKILL_DELETE_PLACEMENT_KINDS),
rootLabel: z.string().max(256)
})
export const SkillDeletePlanSchema: z.ZodType<SkillDeletePlan> = z.object({
operationId: z.string().min(1).max(128),
skills: z.array(
z.object({
id: z.string().min(1).max(128),
name: z.string().max(256),
canonicalPath: SkillPathSchema,
placements: z.array(SkillDeletePlacementSchema).max(64),
blocked: z.enum(SKILL_DELETE_BLOCK_REASONS).optional()
})
)
})
export const SkillDeleteResultSchema: z.ZodType<SkillDeleteResult> = z.object({
operationId: z.string().min(1).max(128),
skills: z.array(
z.object({
id: z.string().min(1).max(128),
name: z.string().max(256),
status: z.enum(SKILL_DELETE_STATUSES),
blocked: z.enum(SKILL_DELETE_BLOCK_REASONS).optional(),
removedPaths: z.array(SkillPathSchema).max(64),
stagedPaths: z.array(SkillPathSchema).max(64).optional()
})
)
})