UNPKG

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

232 lines (231 loc) • 11.1 kB
// 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/. /** * Repository for the state.db `task_history` table (per-task run ledger, * replaces the old per-task JSONL files). Extracted verbatim from * core/state-db.ts — queries unchanged, only relocated behind the repository * boundary. Re-exported by core/state-db.ts so existing importers resolve. * * @module task-history-repository */ import { isRecord } from "../../core/common.js"; import { warnOnce } from "../../core/warn.js"; import { escapeLikePattern } from "../like-pattern.js"; function metadataError(message) { throw new Error(`invalid task_history metadata_json: ${message}`); } function validateDetail(value) { if (value === undefined || value === null) return; if (!isRecord(value)) metadataError("detail must be an object or null"); for (const field of ["runId", "reason", "error"]) { if (value[field] !== undefined && typeof value[field] !== "string") metadataError(`detail.${field} must be a string`); } if (value.exitCode !== undefined && value.exitCode !== null && typeof value.exitCode !== "number") { metadataError("detail.exitCode must be a number or null"); } } /** * Decode the task-history metadata shape. * * Read-time compatibility shim (mirrors proposals-repository's * `storedToChanges`): `metadataVersion` was added in a later release. 58% of * a real install's `task_history` rows (8,596/14,801) predate the field * entirely, and 88 more carry additive fields (`profile`, `repairReason`) * written by prior releases. An absent `metadataVersion` is a LEGACY row, not * corruption — decode what's present (defaulting the absent `detail` key to * `null`) instead of throwing. Unknown keys are ignored rather than * hard-rejected, so the next additive field never regresses this again; * neither `profile` nor `repairReason` is read anywhere downstream, so they * are dropped harmlessly rather than round-tripped. A `metadataVersion` that * IS present but not `2` is a genuinely unknown/future shape and still * rejected, as is a non-number `durationMs` or a `detail` that fails * {@link validateDetail} — real corruption, not version skew. */ export function decodeTaskHistoryMetadata(input) { let parsed = input; if (typeof input === "string") { try { parsed = JSON.parse(input); } catch { metadataError("not valid JSON"); } } if (!isRecord(parsed)) metadataError("root must be an object"); if (parsed.metadataVersion !== undefined && parsed.metadataVersion !== 2) { warnOnce(`task-history-metadata-version:${String(parsed.metadataVersion)}`, `task_history row has metadataVersion ${String(parsed.metadataVersion)}, newer than this akm's 2 — ` + "decoding it best-effort as version 2 rather than rejecting the row."); } if (typeof parsed.durationMs !== "number") metadataError("durationMs must be a number"); const detail = "detail" in parsed ? parsed.detail : null; // A malformed `engine` (wrong type) is dropped like any other unrecognized // field rather than rejecting the whole row over a non-essential value. const engine = parsed.engine === null || typeof parsed.engine === "string" ? parsed.engine : undefined; if (parsed.targetVocab !== undefined && parsed.targetVocab !== 2) { warnOnce(`task-history-target-vocab:${String(parsed.targetVocab)}`, `task_history row has targetVocab ${String(parsed.targetVocab)}, newer than this akm's 2 — falling back to ` + "the legacy target_kind mapping rather than rejecting the row."); } validateDetail(detail); const cleanDetail = detail ? { ...(detail.runId !== undefined ? { runId: detail.runId } : {}), ...(detail.reason !== undefined ? { reason: detail.reason } : {}), ...(detail.error !== undefined ? { error: detail.error } : {}), ...(detail.exitCode !== undefined ? { exitCode: detail.exitCode } : {}), } : null; return { metadataVersion: 2, durationMs: parsed.durationMs, detail: cleanDetail, ...(engine !== undefined ? { engine } : {}), ...(parsed.targetVocab === 2 ? { targetVocab: 2 } : {}), }; } /** * Atomically reserve one task-attempt identity. * * The existing unique `(task_id, started_at)` index arbitrates concurrent * processes: exactly one caller receives `true` for a candidate timestamp. * Callers can advance by one millisecond and retry without a schema change. */ export function reserveTaskHistoryAttempt(db, row) { const result = db .prepare(`INSERT OR IGNORE INTO task_history (task_id, status, started_at, completed_at, failed_at, log_path, target_kind, target_ref, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) .run(row.task_id, row.status, row.started_at, row.completed_at ?? null, row.failed_at ?? null, row.log_path ?? null, row.target_kind ?? null, row.target_ref ?? null, row.metadata_json); return Number(result.changes) === 1; } /** Finalize a row previously created by {@link reserveTaskHistoryAttempt}. */ export function finalizeTaskHistoryAttempt(db, row) { const result = db .prepare(`UPDATE task_history SET status = ?, completed_at = ?, failed_at = ?, log_path = ?, target_kind = ?, target_ref = ?, metadata_json = ? WHERE task_id = ? AND started_at = ? AND status = 'active' AND completed_at IS NULL`) .run(row.status, row.completed_at ?? null, row.failed_at ?? null, row.log_path ?? null, row.target_kind ?? null, row.target_ref ?? null, row.metadata_json, row.task_id, row.started_at); return Number(result.changes) === 1; } /** * Upsert a task history row. */ export function upsertTaskHistory(db, row) { // INSERT OR IGNORE: if a run with the same (task_id, started_at) was already // imported (e.g. by the migration script), skip it silently. db.prepare(` INSERT OR IGNORE INTO task_history (task_id, status, started_at, completed_at, failed_at, log_path, target_kind, target_ref, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(row.task_id, row.status, row.started_at, row.completed_at ?? null, row.failed_at ?? null, row.log_path ?? null, row.target_kind ?? null, row.target_ref ?? null, row.metadata_json); } /** * Look up a task history row by task_id. Returns undefined when not found. */ /** * Return the most recent run for a given task_id, or undefined if no runs exist. */ export function getTaskHistory(db, taskId) { return db .prepare(`SELECT id, task_id, status, started_at, completed_at, failed_at, log_path, target_kind, target_ref, metadata_json FROM task_history WHERE task_id = ? ORDER BY started_at DESC, id DESC LIMIT 1`) .get(taskId); } /** * Return all runs for a given task_id, newest first. */ export function getTaskHistoryRuns(db, taskId, limit = 50) { return db .prepare(`SELECT id, task_id, status, started_at, completed_at, failed_at, log_path, target_kind, target_ref, metadata_json FROM task_history WHERE task_id = ? ORDER BY started_at DESC, id DESC LIMIT ?`) .all(taskId, limit); } /** * Query task history rows by started_at range and/or status. */ export function queryTaskHistory(db, options = {}) { const conditions = []; const params = []; if (options.since) { conditions.push("started_at >= ?"); params.push(options.since); } if (options.until) { conditions.push("started_at <= ?"); params.push(options.until); } if (options.status) { conditions.push("status = ?"); params.push(options.status); } if (options.targetKind) { conditions.push("target_kind = ?"); params.push(options.targetKind); } if (options.targetRef) { conditions.push("target_ref = ?"); params.push(options.targetRef); } const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const limit = options.limit !== undefined ? "LIMIT ?" : ""; if (options.limit !== undefined) params.push(options.limit); return db .prepare(`SELECT task_id, status, started_at, completed_at, failed_at, log_path, target_kind, target_ref, metadata_json FROM task_history ${where} ORDER BY started_at DESC, id DESC ${limit}`) .all(...params); } /** * Read COMPLETED `akm-improve` task_history runs whose `started_at` falls in * `[since, until)` (or `started_at >= since` when `until` is omitted), ordered * oldest-first by `started_at`. Only rows with a non-null `completed_at` are * returned (in-flight runs are excluded). The `task_id = 'akm-improve'` * predicate is fixed because the only caller (commands/health.ts * `loadTaskIntervals`) builds wall-time intervals for the improve cron task. * * Owns the SQL formerly inlined in commands/health.ts. Note the bound is * EXCLUSIVE on the upper end (`started_at < ?`) — callers pass an already * widened window; this helper does not widen. * * Connection-lifetime rule (WS5): `.all()` materializes a plain array before * returning. */ export function queryCompletedTaskIntervals(db, since, until) { const sql = until ? "SELECT started_at, completed_at FROM task_history WHERE task_id = 'akm-improve' AND started_at >= ? AND started_at < ? AND completed_at IS NOT NULL ORDER BY started_at" : "SELECT started_at, completed_at FROM task_history WHERE task_id = 'akm-improve' AND started_at >= ? AND completed_at IS NOT NULL ORDER BY started_at"; return (until ? db.prepare(sql).all(since, until) : db.prepare(sql).all(since)); } /** * Preview counterpart of {@link renameTaskHistoryTargetRefs} for `akm bundle * rename --dry-run`: the same `WHERE` predicate, read-only. */ export function countTaskHistoryTargetRefs(db, oldBundleId) { const prefix = `${escapeLikePattern(oldBundleId)}//`; return db.prepare(`SELECT COUNT(*) AS n FROM task_history WHERE target_ref LIKE ? ESCAPE '\\'`).get(`${prefix}%`).n; } /** * Rewrite every `task_history.target_ref` naming `oldBundleId` (a workflow * run's fully-qualified `<bundle>//conceptId` target) to `newBundleId` * (`akm bundle rename`, D6). A row whose `target_ref` is unset or not * bundle-qualified is not matched by the `LIKE` prefix and is left alone. * Returns the number of rows rewritten. */ export function renameTaskHistoryTargetRefs(db, oldBundleId, newBundleId) { const prefix = `${escapeLikePattern(oldBundleId)}//`; return Number(db .prepare(`UPDATE task_history SET target_ref = ? || substr(target_ref, ?) WHERE target_ref LIKE ? ESCAPE '\\'`) .run(newBundleId, oldBundleId.length + 1, `${prefix}%`).changes); }