akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
286 lines (285 loc) • 13 kB
JavaScript
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/**
* `akm bundle rename <old> <new>` (D6) — the one command allowed to change a
* bundle's key. A bundle id is a mass identity prefix: every durable ref this
* tool minted (`entries.item_ref`/`bundle_id`, `proposals.ref`, a pending
* proposal's `proposedTarget.source`, a workflow task_history row's
* `target_ref`, `scheduler.enabled[].ref`) carries it, and a hand-edited
* `bundles` key strands all of it (see `src/indexer/bundle-identity-guard.ts`
* §11.5). This module is the rekey transaction that guard's docstring points
* to.
*
* Rewritten: the config `bundles` key, `defaultBundle`/`defaultWriteTarget`
* when they name the old id, every `scheduler.enabled[].ref` with the old
* `//` prefix, the lockfile entry id, and the index/state rows above.
*
* Reported, never rewritten: refs inside bundle CONTENT (xrefs,
* `supersededBy`, task `uses:`) — rewriting a bundle's own files is
* `akm mv`/editing territory, not a config-identity rename. `--dry-run`
* (and every real run) lists the indexed files that still spell the old
* `<old>//` prefix so the operator can follow up.
*
* Left behind on purpose: `usage_events`/`asset_salience`/`asset_outcome`
* keyed by the old bundle prefix (learned utility/outcome history) — outside
* this item's scope; `warnOnBundleRenameDrift` already documents this class
* of gap and `akm index --full` re-mints under the new id.
*/
import fs from "node:fs";
import { readLockfile, renameLockEntry } from "../integrations/lockfile.js";
import { closeDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from "../storage/repositories/index-connection.js";
import { getFilePathsByBundle, renameEntriesBundleId } from "../storage/repositories/index-entries-repository.js";
import { countProposalsForBundleRename, renameProposalsBundleRef } from "../storage/repositories/proposals-repository.js";
import { countTaskHistoryTargetRefs, renameTaskHistoryTargetRefs, } from "../storage/repositories/task-history-repository.js";
import { selectBackend } from "../tasks/backends/index.js";
import { bundleRefToString, parseBundleRef } from "./asset/asset-ref.js";
import { validateExplicitBundleName } from "./bundle-id.js";
import { loadConfig, mutateConfig } from "./config/config.js";
import { NotFoundError, UsageError } from "./errors.js";
import { getDbPath } from "./paths.js";
import { getStateDbPath, withStateDb } from "./state-db.js";
/** Throws if `newId` cannot become `oldId`'s new key. Re-run under the config lock at apply time. */
function validateRename(config, oldId, newId) {
if (!config.bundles?.[oldId]) {
throw new NotFoundError(`No configured bundle named "${oldId}".`, "SOURCE_NOT_FOUND");
}
if (oldId === newId) {
throw new UsageError(`"${oldId}" and "${newId}" are the same bundle name.`, "INVALID_FLAG_VALUE");
}
const withoutOld = { ...(config.bundles ?? {}) };
delete withoutOld[oldId];
validateExplicitBundleName(withoutOld, newId);
}
function bundleOfRef(ref) {
try {
return parseBundleRef(ref).bundle;
}
catch {
return undefined;
}
}
function schedulerRefsToRewrite(config, oldId) {
return (config.scheduler?.enabled ?? []).filter((ref) => bundleOfRef(ref) === oldId);
}
function renameBundleInConfig(config, oldId, newId) {
validateRename(config, oldId, newId);
const bundles = {};
for (const [key, entry] of Object.entries(config.bundles ?? {})) {
bundles[key === oldId ? newId : key] = entry;
}
const enabled = config.scheduler?.enabled;
const scheduler = enabled === undefined
? config.scheduler
: {
...config.scheduler,
enabled: enabled.map((ref) => bundleOfRef(ref) === oldId ? bundleRefToString({ ...parseBundleRef(ref), bundle: newId }) : ref),
};
return {
...config,
bundles,
...(config.defaultBundle === oldId ? { defaultBundle: newId } : {}),
...(config.defaultWriteTarget === oldId ? { defaultWriteTarget: newId } : {}),
...(scheduler ? { scheduler } : {}),
};
}
/** Indexed files (already read into `filePaths`) whose content still spells `<oldId>//`, best-effort. */
function filesStillMentioning(filePaths, oldId) {
const needle = `${oldId}//`;
const hits = [];
for (const filePath of filePaths) {
try {
if (fs.readFileSync(filePath, "utf8").includes(needle))
hits.push(filePath);
}
catch {
// Best-effort: a since-removed or unreadable file is skipped, not fatal —
// this scan is a report, not a correctness requirement.
}
}
return hits;
}
/** True when an installed binding's invocation names `bundleId` — either as a `task run` entry's `--bundle <id>` argument, or as a `workflow run <id>//…` ref. */
function invocationNamesBundle(invocation, bundleId) {
if (!invocation)
return false;
for (let i = 0; i < invocation.length; i++) {
const token = invocation[i];
if (token === bundleId && invocation[i - 1] === "--bundle")
return true;
if (token?.startsWith(`${bundleId}//`))
return true;
}
return false;
}
/**
* Installed native scheduler rows whose invocation names `oldId`, best-effort
* (a backend whose listing throws reports none — this is a preview, not a
* correctness requirement).
*/
async function nativeSchedulerRowsNamingBundle(sched, oldId) {
try {
return (await sched.list())
.filter((entry) => invocationNamesBundle(entry.invocation, oldId))
.map((entry) => entry.invocation?.join(" ") ?? entry.id);
}
catch {
return [];
}
}
/**
* Build the rename plan — every count and ref this rename would touch — by
* reading, never writing. Shared by `--dry-run` and a real run so the two
* can never disagree about what was reported vs. what happened.
*/
async function buildPlan(config, oldId, newId, sched) {
const schedulerRefs = schedulerRefsToRewrite(config, oldId);
const lockPresent = readLockfile().some((entry) => entry.id === oldId);
const nativeSchedulerRows = await nativeSchedulerRowsNamingBundle(sched, oldId);
let indexEntries = 0;
let contentRefs = [];
const readIndexDb = openReadonlyExistingDatabase(getDbPath());
if (readIndexDb) {
try {
const filePaths = getFilePathsByBundle(readIndexDb, oldId);
indexEntries = filePaths.length;
contentRefs = filesStillMentioning(filePaths, oldId);
}
finally {
closeDatabase(readIndexDb);
}
}
let proposalRefs = 0;
let proposalTargets = 0;
let taskHistoryRefs = 0;
if (fs.existsSync(getStateDbPath())) {
withStateDb((db) => {
const counts = countProposalsForBundleRename(db, oldId);
proposalRefs = counts.refs;
proposalTargets = counts.targets;
taskHistoryRefs = countTaskHistoryTargetRefs(db, oldId);
});
}
return {
oldId,
newId,
config: {
defaultBundleChanges: config.defaultBundle === oldId,
defaultWriteTargetChanges: config.defaultWriteTarget === oldId,
schedulerRefs,
},
lock: { present: lockPresent },
index: { entries: indexEntries },
state: { proposalRefs, proposalTargets, taskHistoryRefs },
contentRefs,
nativeSchedulerRows,
};
}
function taskSyncErrorMessage(cause) {
return cause instanceof Error ? cause.message : String(cause);
}
/**
* Remove every installed native row still naming `oldId`, best-effort,
* before the sync below installs the new-named ones. Without this, a plain
* `akmTasksSync(deps, newId)` alone cannot clean the old rows up: a task
* binding's native id depends only on the task id, so the OLD row and the
* new one it should become collide on that id, and `installedRowScope`
* (scheduler-sync.ts) attributes a row by the bundle NAME it was installed
* under — the physical-path check only confirms a name match, it never
* substitutes for one — so the sync reports the old row as another bundle's
* and leaves the new binding out instead of replacing it. A workflow binding's native id
* depends on its fully-qualified ref instead, so old and new never collide
* at all and the old row would otherwise be left installed forever. Both
* fail the same way this function fixes: uninstall the exact native rows
* `nativeSchedulerRowsNamingBundle` already found, by their native id, so
* the sync that follows starts from a clean slate.
*/
async function removeStaleNativeSchedulerRows(sched, oldId) {
let installed;
try {
installed = await sched.list();
}
catch {
return;
}
for (const entry of installed) {
if (!invocationNamesBundle(entry.invocation, oldId))
continue;
try {
await sched.uninstall(entry.nativeId ?? entry.id);
}
catch {
// Best-effort: a row this process can't remove is left for the
// operator's own `akm task sync` to reconcile, same as every other
// per-item failure the sync itself reports rather than throws.
}
}
}
/**
* Rename a configured bundle's key everywhere akm itself persists it. With
* `dryRun: true`, only {@link buildPlan} runs — nothing is written, matching
* a `applied: false` result the caller renders as the plan. `deps.backend`
* lets a caller (tests) inject a fake scheduler backend instead of the real
* OS one `selectBackend()` would otherwise pick. `deps.syncTasks` is how the
* caller runs the post-rename `akmTasksSync` — `src/core` sits below
* `src/commands` (see `src/core/improve-types.ts`'s note on the same
* direction), so this module never imports `akmTasksSync` itself; the real
* caller (`akm bundle rename`'s command handler) always supplies it, and is
* required so a missing wire-up is a type error, not a silently skipped sync.
*/
export async function renameBundle(oldId, newId, options = {}, deps) {
const config = loadConfig();
validateRename(config, oldId, newId);
const sched = deps.backend ?? selectBackend();
const plan = await buildPlan(config, oldId, newId, sched);
if (options.dryRun)
return { ...plan, applied: false };
// Config: the bundles key, defaultBundle/defaultWriteTarget, and every
// scheduler.enabled[].ref with the old `//` prefix — all under one write
// (validated again inside the config lock).
mutateConfig((current) => renameBundleInConfig(current, oldId, newId));
// Lockfile: the resolved-install entry id (no-op for a bundle with none,
// e.g. a plain filesystem bundle).
await renameLockEntry(oldId, newId);
// Index: bundle_id/item_ref on every entry row (see index-entries-repository's
// renameEntriesBundleId docstring for why no FTS/vector rebuild is needed).
const readIndexDb = openReadonlyExistingDatabase(getDbPath());
if (readIndexDb) {
closeDatabase(readIndexDb);
const writeIndexDb = openIndexDatabase(getDbPath());
try {
renameEntriesBundleId(writeIndexDb, oldId, newId);
}
finally {
closeDatabase(writeIndexDb);
}
}
// State: proposals.ref, a pending proposal's proposedTarget.source, and
// workflow task_history.target_ref.
if (fs.existsSync(getStateDbPath())) {
withStateDb((db) => {
renameProposalsBundleRef(db, oldId, newId);
renameTaskHistoryTargetRefs(db, oldId, newId);
});
}
// Scheduler: remove native rows still naming `<old>//` (see
// removeStaleNativeSchedulerRows for why a plain sync alone can't do
// this), then re-sync so a scheduled run's invocation stops naming the
// old bundle the moment the rename applies, instead of waiting on the
// operator to run `akm task sync` by hand. Reported, never thrown:
// config/index/state above are already renamed by this point, so a sync
// problem must not make the rename itself look like it failed. `ok` is
// false both when the sync call throws and when it comes back with
// `result.failures` — a binding that failed to prepare has already lost
// its old native row above and is not scheduled until a retry.
let taskSync;
try {
await removeStaleNativeSchedulerRows(sched, oldId);
const result = await deps.syncTasks(newId, sched);
taskSync = { ok: result.failures.length === 0, result };
}
catch (cause) {
taskSync = { ok: false, error: taskSyncErrorMessage(cause) };
}
return { ...plan, applied: true, taskSync };
}