Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/skills

src/skill-revert.ts

227 lines · 5.5 KB · TypeScript
import { getBackend } from './backend.ts'
import { isDryRun } from './dry-run.ts'
import {
	ensureSchema,
	getFile,
	getSkillOrThrow,
	getSkillRevision,
	insertSkillRevision,
	nowIso,
	snapshotFile,
	sqlRows,
	sqlRun,
	upsertSkillsIndex,
	type SkillVersionRow,
} from './db.ts'
import {
	commitAndPublish,
	withSkillsRepoSession,
	parseSkillMeta,
	readRepoFile,
	restoreAndPublish,
	serializeSkillMeta,
	skillFilePath,
	skillMetaPath,
	writeRepoFiles,
} from './repo.ts'

export type SkillRevertInput = {
	version_id: number
	dryRun?: boolean
}

export type SkillRevertResult = {
	id: string
	path: string
	version_id: number
	version_saved: boolean
	commit_oid?: string
	dryRun?: true
}

async function revertLegacy(
	versionId: number,
	dryRun: boolean,
): Promise<SkillRevertResult> {
	await ensureSchema()

	const versions = await sqlRows<SkillVersionRow>(
		`SELECT id, skill_id, path, content, replaced_at
		 FROM skill_versions
		 WHERE id = ?`,
		[versionId],
	)
	const version = versions[0]
	if (!version) {
		throw new Error(`skill_versions row not found for version_id=${versionId}.`)
	}

	await getSkillOrThrow(version.skill_id)

	const timestamp = nowIso()
	const previous = await getFile(version.skill_id, version.path)
	if (dryRun) {
		return {
			id: version.skill_id,
			path: version.path,
			version_id: Number(version.id),
			version_saved: previous != null,
			dryRun: true,
		}
	}
	let versionSaved = false
	if (previous) {
		await snapshotFile(
			version.skill_id,
			version.path,
			previous.content,
			timestamp,
		)
		versionSaved = true
		await sqlRun(
			`UPDATE skill_files
			 SET content = ?, updated_at = ?
			 WHERE skill_id = ? AND path = ?`,
			[version.content, timestamp, version.skill_id, version.path],
		)
	} else {
		await sqlRun(
			`INSERT INTO skill_files (skill_id, path, content, updated_at)
			 VALUES (?, ?, ?, ?)`,
			[version.skill_id, version.path, version.content, timestamp],
		)
	}

	await sqlRun(`UPDATE skills SET updated_at = ? WHERE id = ?`, [
		timestamp,
		version.skill_id,
	])

	return {
		id: version.skill_id,
		path: version.path,
		version_id: Number(version.id),
		version_saved: versionSaved,
	}
}

async function revertRepo(
	versionId: number,
	dryRun: boolean,
): Promise<SkillRevertResult> {
	const revision = await getSkillRevision(versionId)
	if (!revision) {
		throw new Error(`skill_revisions row not found for version_id=${versionId}.`)
	}

	await getSkillOrThrow(revision.skill_id)

	if (dryRun) {
		const previousContent = await withSkillsRepoSession(async (session) => {
			return readRepoFile(
				session.id,
				skillFilePath(revision.skill_id, revision.path),
			)
		})
		return {
			id: revision.skill_id,
			path: revision.path,
			version_id: Number(revision.id),
			version_saved: previousContent != null,
			commit_oid: revision.commit_oid,
			dryRun: true,
		}
	}

	const previous = await withSkillsRepoSession(async (session) => {
		const filePath = skillFilePath(revision.skill_id, revision.path)
		const content = await readRepoFile(session.id, filePath)
		const timestamp = nowIso()

		await restoreAndPublish({
			sessionId: session.id,
			paths: [filePath],
			commit: revision.commit_oid,
			message: `skill-revert: ${revision.skill_id} ${revision.path} from ${revision.commit_oid.slice(0, 12)}`,
		})
		return { content, timestamp, baseCommit: session.base_commit }
	})

	// Refresh skill.json (files list + updated_at) in a follow-up session.
	const commitOid = await withSkillsRepoSession(async (metaSession) => {
		const meta = parseSkillMeta(
			await readRepoFile(metaSession.id, skillMetaPath(revision.skill_id)),
		)
		let oid = metaSession.base_commit
		if (meta) {
			const files = meta.files.includes(revision.path)
				? meta.files
				: [...meta.files, revision.path].sort()
			const next = { ...meta, files, updated_at: previous.timestamp }
			await writeRepoFiles(metaSession.id, [
				{
					path: skillMetaPath(revision.skill_id),
					content: serializeSkillMeta(next),
				},
			])
			const published = await commitAndPublish(
				metaSession.id,
				`skill-revert-meta: ${revision.skill_id}`,
			)
			oid = published.oid
			await upsertSkillsIndex({
				id: revision.skill_id,
				name: next.name,
				description: next.description,
				files: next.files,
				created_at: next.created_at,
				updated_at: next.updated_at,
			})
		}
		return oid
	})

	let versionSaved = false
	if (previous.content != null) {
		await insertSkillRevision({
			commit_oid: previous.baseCommit,
			skill_id: revision.skill_id,
			path: revision.path,
			replaced_at: previous.timestamp,
			content_length: previous.content.length,
		})
		versionSaved = true
	}

	return {
		id: revision.skill_id,
		path: revision.path,
		version_id: Number(revision.id),
		version_saved: versionSaved,
		commit_oid: commitOid,
	}
}

/**
 * Restore a prior skill version as the current file content.
 * Repo backend: `repo_restore` from the revision's git commit, then publish.
 * Legacy backend: restore from skill_versions SQLite snapshot.
 *
 * @example
 * import skillRevert from 'kody:@kody/skills/skill-revert'
 * const result = await skillRevert({ version_id: 12, dryRun: true })
 */
export default async function skillRevert(
	params: SkillRevertInput = {} as SkillRevertInput,
): Promise<SkillRevertResult> {
	const versionId = Number(params.version_id)
	if (!Number.isFinite(versionId) || versionId <= 0) {
		throw new Error('skill_revert requires a positive numeric `version_id`.')
	}

	const dryRun = isDryRun(params.dryRun)
	const backend = await getBackend()
	return backend === 'repo'
		? revertRepo(versionId, dryRun)
		: revertLegacy(versionId, dryRun)
}