akm-cli 0.9.0-beta.1 → 0.9.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +469 -0
- package/dist/assets/templates/html/default.html +78 -0
- package/dist/assets/templates/html/health.html +732 -0
- package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
- package/dist/cli/shared.js +21 -5
- package/dist/cli.js +47 -5
- package/dist/commands/config-cli.js +0 -10
- package/dist/commands/feedback-cli.js +42 -37
- package/dist/commands/graph/graph.js +75 -71
- package/dist/commands/health/checks.js +48 -0
- package/dist/commands/health/html-report.js +666 -0
- package/dist/commands/health.js +186 -13
- package/dist/commands/improve/consolidate.js +39 -6
- package/dist/commands/improve/distill.js +26 -5
- package/dist/commands/improve/extract-prompt.js +1 -1
- package/dist/commands/improve/extract.js +52 -8
- package/dist/commands/improve/improve-auto-accept.js +33 -1
- package/dist/commands/improve/improve-cli.js +7 -0
- package/dist/commands/improve/improve-profiles.js +4 -0
- package/dist/commands/improve/improve.js +874 -447
- package/dist/commands/improve/proactive-maintenance.js +113 -0
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/improve/reflect.js +31 -0
- package/dist/commands/proposal/drain.js +73 -6
- package/dist/commands/proposal/proposal-cli.js +22 -10
- package/dist/commands/proposal/proposal.js +17 -1
- package/dist/commands/proposal/validators/proposals.js +365 -329
- package/dist/commands/read/curate.js +17 -0
- package/dist/commands/remember.js +6 -2
- package/dist/commands/sources/stash-cli.js +10 -2
- package/dist/commands/tasks/tasks.js +32 -8
- package/dist/core/config/config-schema.js +30 -0
- package/dist/core/logs-db.js +304 -0
- package/dist/core/paths.js +3 -0
- package/dist/core/state-db.js +152 -14
- package/dist/indexer/db/db.js +99 -13
- package/dist/indexer/ensure-index.js +152 -17
- package/dist/indexer/index-writer-lock.js +99 -0
- package/dist/indexer/indexer.js +114 -111
- package/dist/indexer/passes/memory-inference.js +61 -22
- package/dist/integrations/harnesses/claude/session-log.js +17 -5
- package/dist/llm/client.js +38 -4
- package/dist/llm/usage-persist.js +77 -0
- package/dist/llm/usage-telemetry.js +103 -0
- package/dist/output/context.js +3 -2
- package/dist/output/html-render.js +73 -0
- package/dist/output/shapes/helpers.js +17 -1
- package/dist/output/text/helpers.js +69 -1
- package/dist/scripts/migrate-storage.js +153 -25
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +21 -2
- package/dist/sources/providers/tar-utils.js +16 -8
- package/dist/tasks/backends/cron.js +46 -9
- package/dist/tasks/runner.js +99 -16
- package/dist/workflows/db.js +4 -0
- package/package.json +3 -2
- package/dist/commands/config-edit.js +0 -344
|
@@ -54,6 +54,9 @@ function logCurateEvent(query, result) {
|
|
|
54
54
|
try {
|
|
55
55
|
const db = openExistingDatabase();
|
|
56
56
|
try {
|
|
57
|
+
// Summary row (entry_ref = NULL): preserves the query → itemRefs audit
|
|
58
|
+
// trail. Retrieval counting ignores NULL-ref rows, so this row is purely
|
|
59
|
+
// informational.
|
|
57
60
|
insertUsageEvent(db, {
|
|
58
61
|
event_type: "curate",
|
|
59
62
|
query,
|
|
@@ -63,6 +66,20 @@ function logCurateEvent(query, result) {
|
|
|
63
66
|
}),
|
|
64
67
|
source: "user",
|
|
65
68
|
});
|
|
69
|
+
// Per-item rows with entry_ref populated so curation registers as a real
|
|
70
|
+
// retrieval signal in getRetrievalCounts (which counts 'curate' events).
|
|
71
|
+
// Only stash items expose a canonical asset ref; registry hits
|
|
72
|
+
// (`registry:<id>`) have no asset ref and are skipped here.
|
|
73
|
+
for (const item of result.items) {
|
|
74
|
+
if (!("ref" in item) || typeof item.ref !== "string")
|
|
75
|
+
continue;
|
|
76
|
+
insertUsageEvent(db, {
|
|
77
|
+
event_type: "curate",
|
|
78
|
+
query,
|
|
79
|
+
entry_ref: item.ref,
|
|
80
|
+
source: "user",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
66
83
|
}
|
|
67
84
|
finally {
|
|
68
85
|
closeDatabase(db);
|
|
@@ -149,6 +149,10 @@ export async function runLlmEnrich(body) {
|
|
|
149
149
|
return { tags: [] };
|
|
150
150
|
}
|
|
151
151
|
const { chatCompletion, parseEmbeddedJsonResponse: parseJsonResponse } = await import("../llm/client.js");
|
|
152
|
+
// #576: attribute this entry point's LLM call to the `remember` stage. The
|
|
153
|
+
// wrapper is ambient — if a usage sink is active it tags the record; if not,
|
|
154
|
+
// it is a no-op.
|
|
155
|
+
const { withLlmStage } = await import("../llm/usage-telemetry.js");
|
|
152
156
|
const prompt = `You are a memory tagger for a developer knowledge base.
|
|
153
157
|
Given the memory text below, return ONLY a JSON object with these fields:
|
|
154
158
|
- "tags": array of 1-5 short lowercase keyword tags
|
|
@@ -164,10 +168,10 @@ Return ONLY the JSON object, no prose, no markdown fences.`;
|
|
|
164
168
|
const result = await (async () => {
|
|
165
169
|
try {
|
|
166
170
|
return await Promise.race([
|
|
167
|
-
chatCompletion(llmConfig, [
|
|
171
|
+
withLlmStage("remember", () => chatCompletion(llmConfig, [
|
|
168
172
|
{ role: "system", content: "Return only valid JSON. No prose." },
|
|
169
173
|
{ role: "user", content: prompt },
|
|
170
|
-
], { maxTokens: 256, temperature: 0.1 }),
|
|
174
|
+
], { maxTokens: 256, temperature: 0.1 })),
|
|
171
175
|
new Promise((_, reject) => {
|
|
172
176
|
timeoutHandle = setTimeout(() => reject(new Error("LLM enrichment timed out")), LLM_ENRICH_TIMEOUT_MS);
|
|
173
177
|
}),
|
|
@@ -71,6 +71,11 @@ export const indexCommand = defineCommand({
|
|
|
71
71
|
description: "When combined with --clean, report stale entries without deleting them.",
|
|
72
72
|
default: false,
|
|
73
73
|
},
|
|
74
|
+
background: {
|
|
75
|
+
type: "boolean",
|
|
76
|
+
description: "Run as a background process (suppresses interactive output, manages PID file).",
|
|
77
|
+
default: false,
|
|
78
|
+
},
|
|
74
79
|
},
|
|
75
80
|
async run({ args }) {
|
|
76
81
|
await runWithJsonErrors(async () => {
|
|
@@ -80,6 +85,7 @@ export const indexCommand = defineCommand({
|
|
|
80
85
|
if (getHyphenatedBoolean(args, "re-enrich") || parseFlagValue(process.argv, "--re-enrich") !== undefined) {
|
|
81
86
|
throw new UsageError("`akm index --re-enrich` has been removed. Re-enrichment of index-time LLM passes is not exposed in this slice.");
|
|
82
87
|
}
|
|
88
|
+
const isBackground = args.background === true;
|
|
83
89
|
const outputMode = getOutputMode();
|
|
84
90
|
const controller = new AbortController();
|
|
85
91
|
const abort = () => controller.abort(new Error("index interrupted"));
|
|
@@ -88,7 +94,7 @@ export const indexCommand = defineCommand({
|
|
|
88
94
|
const indexLogFile = path.join(getCacheDir(), "logs", "index", `${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
|
|
89
95
|
setLogFile(indexLogFile);
|
|
90
96
|
const verbose = isVerbose();
|
|
91
|
-
const spin = !verbose && outputMode.format === "text" ? p.spinner() : null;
|
|
97
|
+
const spin = !verbose && !isBackground && outputMode.format === "text" ? p.spinner() : null;
|
|
92
98
|
if (spin) {
|
|
93
99
|
spin.start(`Building search index${args.full ? " (full rebuild)" : ""}...`);
|
|
94
100
|
}
|
|
@@ -114,7 +120,9 @@ export const indexCommand = defineCommand({
|
|
|
114
120
|
if (spin) {
|
|
115
121
|
spin.stop(`Indexed ${result.totalEntries} assets.`);
|
|
116
122
|
}
|
|
117
|
-
|
|
123
|
+
if (!isBackground) {
|
|
124
|
+
output("index", result);
|
|
125
|
+
}
|
|
118
126
|
}
|
|
119
127
|
catch (error) {
|
|
120
128
|
if (spin) {
|
|
@@ -218,7 +218,13 @@ export async function akmTasksSetEnabled(id, enabled) {
|
|
|
218
218
|
fs.writeFileSync(filePath, updated, "utf8");
|
|
219
219
|
const sched = selectBackend();
|
|
220
220
|
try {
|
|
221
|
-
|
|
221
|
+
// Reinstall from the (just-updated) definition rather than only toggling
|
|
222
|
+
// the comment. A plain toggle leaves a stale schedule in place if the
|
|
223
|
+
// .yml's `schedule:` changed while the task was disabled — re-enabling
|
|
224
|
+
// would silently keep the old cron line. install() renders the block with
|
|
225
|
+
// both the current schedule and the new enabled state, and is idempotent.
|
|
226
|
+
const task = parseTaskDocument({ yaml: updated, filePath, id: normalised });
|
|
227
|
+
await sched.install(task);
|
|
222
228
|
}
|
|
223
229
|
catch (err) {
|
|
224
230
|
// Roll the file back so the YAML source-of-truth and the OS
|
|
@@ -254,9 +260,12 @@ export async function akmTasksHistory(input) {
|
|
|
254
260
|
* Reconcile the on-disk task files with the OS scheduler.
|
|
255
261
|
* • install missing tasks (after validating them — invalid files are
|
|
256
262
|
* skipped with a per-task reason rather than aborting the whole sync)
|
|
263
|
+
* • reinstall tasks whose schedule or enabled state changed in the .yml
|
|
264
|
+
* (drift detected by comparing the backend's installed signature against
|
|
265
|
+
* the signature the current definition would produce)
|
|
257
266
|
* • remove orphan scheduler entries that no longer have a backing file
|
|
258
267
|
*/
|
|
259
|
-
export async function akmTasksSync() {
|
|
268
|
+
export async function akmTasksSync(deps = {}) {
|
|
260
269
|
const stashDir = resolveStashDir();
|
|
261
270
|
const typeRoot = path.join(stashDir, "tasks");
|
|
262
271
|
if (fs.existsSync(typeRoot))
|
|
@@ -267,10 +276,13 @@ export async function akmTasksSync() {
|
|
|
267
276
|
.filter((f) => f.endsWith(".yml"))
|
|
268
277
|
.map((f) => f.slice(0, -4))
|
|
269
278
|
: [];
|
|
270
|
-
const sched = selectBackend();
|
|
279
|
+
const sched = deps.backend ?? selectBackend();
|
|
271
280
|
const backend = backendNameForPlatform();
|
|
272
|
-
|
|
281
|
+
// Map id → installed signature so sync can detect schedule/enabled drift on
|
|
282
|
+
// tasks that already exist in the scheduler, not just presence/absence.
|
|
283
|
+
const present = new Map((await sched.list()).map((t) => [t.id, t.signature]));
|
|
273
284
|
const installed = [];
|
|
285
|
+
const updated = [];
|
|
274
286
|
const unchanged = [];
|
|
275
287
|
const skipped = [];
|
|
276
288
|
for (const id of fileIds) {
|
|
@@ -290,22 +302,34 @@ export async function akmTasksSync() {
|
|
|
290
302
|
skipped.push({ id, reason: err instanceof Error ? err.message : String(err) });
|
|
291
303
|
continue;
|
|
292
304
|
}
|
|
293
|
-
if (present.has(id)) {
|
|
305
|
+
if (!present.has(id)) {
|
|
306
|
+
await sched.install(task);
|
|
307
|
+
installed.push(id);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
// Already installed — reconcile against the current definition. Compare the
|
|
311
|
+
// installed signature to what this task would render to; reinstall on drift.
|
|
312
|
+
// When the backend can't produce a signature (no expectedSignature, or it
|
|
313
|
+
// didn't record one), reinstall unconditionally — install() is idempotent,
|
|
314
|
+
// so the cost is one crontab write and correctness is guaranteed.
|
|
315
|
+
const installedSig = present.get(id);
|
|
316
|
+
const expectedSig = sched.expectedSignature?.(task);
|
|
317
|
+
if (installedSig !== undefined && expectedSig !== undefined && installedSig === expectedSig) {
|
|
294
318
|
unchanged.push(id);
|
|
295
319
|
}
|
|
296
320
|
else {
|
|
297
321
|
await sched.install(task);
|
|
298
|
-
|
|
322
|
+
updated.push(id);
|
|
299
323
|
}
|
|
300
324
|
}
|
|
301
325
|
const removed = [];
|
|
302
|
-
for (const installedId of present) {
|
|
326
|
+
for (const installedId of present.keys()) {
|
|
303
327
|
if (!fileIds.includes(installedId)) {
|
|
304
328
|
await sched.uninstall(installedId);
|
|
305
329
|
removed.push(installedId);
|
|
306
330
|
}
|
|
307
331
|
}
|
|
308
|
-
return { installed, removed, unchanged, skipped, backend: sched.name };
|
|
332
|
+
return { installed, updated, removed, unchanged, skipped, backend: sched.name };
|
|
309
333
|
}
|
|
310
334
|
export async function akmTasksDoctor() {
|
|
311
335
|
const warnings = [];
|
|
@@ -138,7 +138,35 @@ export const ImproveProcessConfigSchema = z
|
|
|
138
138
|
// Extract process config (only meaningful for extract process)
|
|
139
139
|
defaultSince: z.string().min(1).optional(),
|
|
140
140
|
maxTotalChars: positiveInt.optional(),
|
|
141
|
+
// Extract process: minimum raw session size (pre-filter inputCount) below
|
|
142
|
+
// which the extract LLM call is skipped (#595/#596). 0 disables the gate.
|
|
143
|
+
// Absent = default 10 (skip only truly empty sessions). Only meaningful
|
|
144
|
+
// on the `extract` process.
|
|
145
|
+
minContentChars: z.number().int().min(0).optional(),
|
|
141
146
|
maxChunkSize: z.number().int().min(1).max(50).optional(),
|
|
147
|
+
// Consolidate process: narrow candidate pool to memories modified within
|
|
148
|
+
// this duration window plus their graph neighbours. Only meaningful on
|
|
149
|
+
// the `consolidate` process. Absent = full-pool sweep.
|
|
150
|
+
incrementalSince: z.string().optional(),
|
|
151
|
+
// Consolidate process: hard cap on memories processed per pass.
|
|
152
|
+
// Reflect/distill: max refs processed (same as profile-level `limit`).
|
|
153
|
+
limit: positiveInt.optional(),
|
|
154
|
+
// Consolidate process: graph neighbours per changed memory during
|
|
155
|
+
// incremental consolidation. Default 5. Only meaningful with incrementalSince.
|
|
156
|
+
neighborsPerChanged: z.number().int().min(1).optional(),
|
|
157
|
+
// Distill process: skip distill entirely when reflect produced zero planned refs.
|
|
158
|
+
requirePlannedRefs: z.boolean().optional(),
|
|
159
|
+
// proactiveMaintenance process (Layer 2): staleness gate + rotation cooldown
|
|
160
|
+
// in days (default 30). Only meaningful on `proactiveMaintenance`.
|
|
161
|
+
dueDays: z.number().int().min(0).optional(),
|
|
162
|
+
// proactiveMaintenance process: top-N bound per run (default 25). Alias for
|
|
163
|
+
// `limit`; `maxPerRun` wins when both are set.
|
|
164
|
+
maxPerRun: positiveInt.optional(),
|
|
165
|
+
// proactiveMaintenance process: optional per-type importance overrides,
|
|
166
|
+
// merged over the built-in defaults. Only meaningful on `proactiveMaintenance`.
|
|
167
|
+
importanceWeights: z.record(z.string().min(1), z.number()).optional(),
|
|
168
|
+
// MemoryInference process: minimum pending memory count to run the pass.
|
|
169
|
+
minPendingCount: z.number().int().min(0).optional(),
|
|
142
170
|
// Extract process: minimum number of new (unseen, in-window) candidate
|
|
143
171
|
// sessions below which the extract pass skips entirely (emits an
|
|
144
172
|
// `improve_skipped` event with `reason: "below_min_new_sessions"`). 0
|
|
@@ -176,6 +204,7 @@ const ImproveProfileProcessesSchema = z
|
|
|
176
204
|
graphExtraction: ImproveProcessConfigSchema.optional(),
|
|
177
205
|
validation: ImproveProcessConfigSchema.optional(),
|
|
178
206
|
triage: ImproveProcessConfigSchema.optional(),
|
|
207
|
+
proactiveMaintenance: ImproveProcessConfigSchema.optional(),
|
|
179
208
|
})
|
|
180
209
|
.passthrough()
|
|
181
210
|
.superRefine((val, ctx) => {
|
|
@@ -199,6 +228,7 @@ const ImproveProfileProcessesSchema = z
|
|
|
199
228
|
"validation",
|
|
200
229
|
"extract",
|
|
201
230
|
"triage",
|
|
231
|
+
"proactiveMaintenance",
|
|
202
232
|
]);
|
|
203
233
|
for (const k of Object.keys(raw)) {
|
|
204
234
|
if (!allowed.has(k)) {
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* logs.db — Dedicated SQLite database for task/run log lines (#579).
|
|
6
|
+
*
|
|
7
|
+
* Replaces grep-the-flat-file consumption of `<cacheDir>/tasks/logs/<id>/<ts>.log`
|
|
8
|
+
* with structured, indexed rows: `{ts, task_id, run_id, stream, level, line}`.
|
|
9
|
+
* The strategic direction (stop scattering data across files/folders) means
|
|
10
|
+
* every NEW log consumer queries this database; the per-run text file written
|
|
11
|
+
* by the task runner is retained only as a transitional tail for humans —
|
|
12
|
+
* see docs/technical/logs-audit.md for the full producer audit.
|
|
13
|
+
*
|
|
14
|
+
* ## Why a separate database from state.db
|
|
15
|
+
*
|
|
16
|
+
* Log lines are high-volume, append-only, and freely purgeable; state.db rows
|
|
17
|
+
* (events, proposals, task_history) are durable records. Separating them keeps
|
|
18
|
+
* state.db small and lets log retention be aggressive without touching durable
|
|
19
|
+
* state. Cross-db queries (e.g. "failed task_history row → its log lines") use
|
|
20
|
+
* SQLite ATTACH — see {@link attachStateDatabase}.
|
|
21
|
+
*
|
|
22
|
+
* ## run_id
|
|
23
|
+
*
|
|
24
|
+
* state.db's `task_history` identifies a run by the unique pair
|
|
25
|
+
* `(task_id, started_at)` (see migration 002 in state-db.ts). logs.db encodes
|
|
26
|
+
* that pair as a single string — {@link buildTaskRunId} — so log rows can be
|
|
27
|
+
* joined back to their history row:
|
|
28
|
+
*
|
|
29
|
+
* l.run_id = th.task_id || '@' || th.started_at
|
|
30
|
+
*
|
|
31
|
+
* ## Schema evolution
|
|
32
|
+
*
|
|
33
|
+
* Same migration-safety contract as state.db: append-only `MIGRATIONS` applied
|
|
34
|
+
* through the shared runner in src/storage/engines/sqlite-migrations.ts.
|
|
35
|
+
*
|
|
36
|
+
* @module logs-db
|
|
37
|
+
*/
|
|
38
|
+
import fs from "node:fs";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { openDatabase } from "../storage/database.js";
|
|
41
|
+
import { runMigrations as runSqliteMigrations } from "../storage/engines/sqlite-migrations.js";
|
|
42
|
+
import { getDataDir } from "./paths.js";
|
|
43
|
+
import { getStateDbPath } from "./state-db.js";
|
|
44
|
+
// ── Path helper ──────────────────────────────────────────────────────────────
|
|
45
|
+
/**
|
|
46
|
+
* Default path: `<dataDir>/logs.db` — alongside state.db so cooperating
|
|
47
|
+
* processes sharing a data root automatically share the same logs database
|
|
48
|
+
* (same `AKM_DATA_DIR` / XDG env-isolation as {@link getStateDbPath}).
|
|
49
|
+
*/
|
|
50
|
+
export function getLogsDbPath() {
|
|
51
|
+
return path.join(getDataDir(), "logs.db");
|
|
52
|
+
}
|
|
53
|
+
// ── Database open ────────────────────────────────────────────────────────────
|
|
54
|
+
/**
|
|
55
|
+
* Open (and initialise / migrate) the logs database.
|
|
56
|
+
*
|
|
57
|
+
* @param dbPath - Override the database file path (tests pass a tmpdir path).
|
|
58
|
+
*
|
|
59
|
+
* PRAGMA rationale:
|
|
60
|
+
*
|
|
61
|
+
* journal_mode = WAL
|
|
62
|
+
* Readers never block writers and vice-versa; crashes are safe (the WAL is
|
|
63
|
+
* replayed on next open). Required because the task runner writes log rows
|
|
64
|
+
* while `akm health` may be reading them.
|
|
65
|
+
*
|
|
66
|
+
* busy_timeout = 30000
|
|
67
|
+
* Log writes happen at the end of scheduled task runs, which can pile up
|
|
68
|
+
* (cron fan-out). 30 s of retry absorbs a slow concurrent writer instead of
|
|
69
|
+
* surfacing SQLITE_BUSY and dropping log lines.
|
|
70
|
+
*/
|
|
71
|
+
export function openLogsDatabase(dbPath) {
|
|
72
|
+
const resolvedPath = dbPath ?? getLogsDbPath();
|
|
73
|
+
const dir = path.dirname(resolvedPath);
|
|
74
|
+
if (!fs.existsSync(dir)) {
|
|
75
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
76
|
+
}
|
|
77
|
+
const db = openDatabase(resolvedPath);
|
|
78
|
+
// PRAGMAs must run before any DDL or DML.
|
|
79
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
80
|
+
db.exec("PRAGMA busy_timeout = 30000");
|
|
81
|
+
runMigrations(db);
|
|
82
|
+
return db;
|
|
83
|
+
}
|
|
84
|
+
// ── Migrations ───────────────────────────────────────────────────────────────
|
|
85
|
+
/**
|
|
86
|
+
* All migrations in application order. APPEND only — never insert in the
|
|
87
|
+
* middle or reorder. Same contract as state.db's MIGRATIONS array.
|
|
88
|
+
*/
|
|
89
|
+
const MIGRATIONS = [
|
|
90
|
+
// ── Migration 001 — task_logs ───────────────────────────────────────────────
|
|
91
|
+
//
|
|
92
|
+
// One row per log line emitted by a task run.
|
|
93
|
+
//
|
|
94
|
+
// Indexed (query) columns:
|
|
95
|
+
// ts TEXT — ISO-8601 UTC; range queries ("logs in the last hour").
|
|
96
|
+
// task_id TEXT — task identifier; per-task log views.
|
|
97
|
+
// run_id TEXT — buildTaskRunId(task_id, started_at); per-run log views
|
|
98
|
+
// and the join key back to state.db task_history.
|
|
99
|
+
//
|
|
100
|
+
// Non-indexed columns:
|
|
101
|
+
// stream TEXT — 'stdout' | 'stderr'; which pipe the line came from.
|
|
102
|
+
// level TEXT — 'info' | 'warn' | 'error'; runner-assigned severity
|
|
103
|
+
// ('info' for captured stdout, 'error' for stderr and
|
|
104
|
+
// failure diagnostics).
|
|
105
|
+
// line TEXT — the log line itself (no trailing newline).
|
|
106
|
+
//
|
|
107
|
+
// ADD COLUMN extension points (future migrations):
|
|
108
|
+
// ALTER TABLE task_logs ADD COLUMN seq INTEGER DEFAULT NULL;
|
|
109
|
+
// ALTER TABLE task_logs ADD COLUMN source TEXT DEFAULT NULL;
|
|
110
|
+
//
|
|
111
|
+
// TTL: rows where ts < NOW() - retention can be deleted by purgeOldTaskLogs().
|
|
112
|
+
// No automatic deletion occurs here.
|
|
113
|
+
{
|
|
114
|
+
id: "001-task-logs",
|
|
115
|
+
up: `
|
|
116
|
+
CREATE TABLE IF NOT EXISTS task_logs (
|
|
117
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
118
|
+
ts TEXT NOT NULL,
|
|
119
|
+
task_id TEXT NOT NULL,
|
|
120
|
+
run_id TEXT NOT NULL,
|
|
121
|
+
stream TEXT NOT NULL DEFAULT 'stdout',
|
|
122
|
+
level TEXT NOT NULL DEFAULT 'info',
|
|
123
|
+
line TEXT NOT NULL
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
-- Query patterns:
|
|
127
|
+
-- SELECT … WHERE ts >= ? AND ts <= ? → idx_task_logs_ts (purge, windows)
|
|
128
|
+
-- SELECT … WHERE task_id = ? → idx_task_logs_task_id
|
|
129
|
+
-- SELECT … WHERE run_id = ? → idx_task_logs_run_id (per-run tail)
|
|
130
|
+
CREATE INDEX IF NOT EXISTS idx_task_logs_ts ON task_logs(ts);
|
|
131
|
+
CREATE INDEX IF NOT EXISTS idx_task_logs_task_id ON task_logs(task_id);
|
|
132
|
+
CREATE INDEX IF NOT EXISTS idx_task_logs_run_id ON task_logs(run_id);
|
|
133
|
+
`,
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
/**
|
|
137
|
+
* Apply every pending migration. Called automatically by
|
|
138
|
+
* {@link openLogsDatabase}; exported for the same test seams state-db exposes.
|
|
139
|
+
*/
|
|
140
|
+
export function runMigrations(db) {
|
|
141
|
+
runSqliteMigrations(db, MIGRATIONS);
|
|
142
|
+
}
|
|
143
|
+
// ── run_id ───────────────────────────────────────────────────────────────────
|
|
144
|
+
/**
|
|
145
|
+
* Encode a task run's identity — the unique `(task_id, started_at)` pair from
|
|
146
|
+
* state.db `task_history` — as a single run_id string.
|
|
147
|
+
*
|
|
148
|
+
* The format MUST stay in sync with the SQL expression
|
|
149
|
+
* `task_id || '@' || started_at` used by {@link queryFailedRunLogLines}.
|
|
150
|
+
*/
|
|
151
|
+
export function buildTaskRunId(taskId, startedAtIso) {
|
|
152
|
+
return `${taskId}@${startedAtIso}`;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Insert a batch of log lines for one task run in a single transaction.
|
|
156
|
+
* Returns the number of rows inserted. Lines are stored in array order
|
|
157
|
+
* (ascending rowid), so reading back `ORDER BY id` reproduces emission order.
|
|
158
|
+
*
|
|
159
|
+
* Errors propagate — the task runner wraps this in its own best-effort
|
|
160
|
+
* handling (mirroring `appendHistory`) so an unwritable logs.db never fails
|
|
161
|
+
* a task run.
|
|
162
|
+
*/
|
|
163
|
+
export function insertTaskLogLines(db, input) {
|
|
164
|
+
if (input.lines.length === 0)
|
|
165
|
+
return 0;
|
|
166
|
+
const stmt = db.prepare(`INSERT INTO task_logs (ts, task_id, run_id, stream, level, line)
|
|
167
|
+
VALUES (?, ?, ?, ?, ?, ?)`);
|
|
168
|
+
db.transaction(() => {
|
|
169
|
+
for (const entry of input.lines) {
|
|
170
|
+
stmt.run(input.ts, input.taskId, input.runId, entry.stream ?? "stdout", entry.level ?? "info", entry.line);
|
|
171
|
+
}
|
|
172
|
+
})();
|
|
173
|
+
return input.lines.length;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Read log lines matching the filter, in emission order (ascending id).
|
|
177
|
+
*
|
|
178
|
+
* Connection-lifetime rule (WS5): `.all()` materializes a plain array before
|
|
179
|
+
* returning.
|
|
180
|
+
*/
|
|
181
|
+
export function queryTaskLogs(db, options = {}) {
|
|
182
|
+
const conditions = [];
|
|
183
|
+
const params = [];
|
|
184
|
+
if (options.taskId) {
|
|
185
|
+
conditions.push("task_id = ?");
|
|
186
|
+
params.push(options.taskId);
|
|
187
|
+
}
|
|
188
|
+
if (options.runId) {
|
|
189
|
+
conditions.push("run_id = ?");
|
|
190
|
+
params.push(options.runId);
|
|
191
|
+
}
|
|
192
|
+
if (options.stream) {
|
|
193
|
+
conditions.push("stream = ?");
|
|
194
|
+
params.push(options.stream);
|
|
195
|
+
}
|
|
196
|
+
if (options.since) {
|
|
197
|
+
conditions.push("ts >= ?");
|
|
198
|
+
params.push(options.since);
|
|
199
|
+
}
|
|
200
|
+
if (options.until) {
|
|
201
|
+
conditions.push("ts < ?");
|
|
202
|
+
params.push(options.until);
|
|
203
|
+
}
|
|
204
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
205
|
+
const limit = options.limit !== undefined && options.limit >= 0 ? ` LIMIT ${Math.floor(options.limit)}` : "";
|
|
206
|
+
return db
|
|
207
|
+
.prepare(`SELECT id, ts, task_id, run_id, stream, level, line FROM task_logs ${where} ORDER BY id ASC${limit}`)
|
|
208
|
+
.all(...params);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Bulk membership check: which of `runIds` have at least one log row?
|
|
212
|
+
* Used by `akm health` to compute the log-backing rate from the database
|
|
213
|
+
* instead of `fs.existsSync` over scattered files. Chunked to stay under
|
|
214
|
+
* SQLite's bound-parameter ceiling.
|
|
215
|
+
*/
|
|
216
|
+
export function getLoggedRunIds(db, runIds) {
|
|
217
|
+
const out = new Set();
|
|
218
|
+
if (runIds.length === 0)
|
|
219
|
+
return out;
|
|
220
|
+
const CHUNK = 500;
|
|
221
|
+
for (let i = 0; i < runIds.length; i += CHUNK) {
|
|
222
|
+
const chunk = runIds.slice(i, i + CHUNK);
|
|
223
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
224
|
+
const rows = db
|
|
225
|
+
.prepare(`SELECT DISTINCT run_id FROM task_logs WHERE run_id IN (${placeholders})`)
|
|
226
|
+
.all(...chunk);
|
|
227
|
+
for (const row of rows)
|
|
228
|
+
out.add(row.run_id);
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
// ── Cross-db: ATTACH state.db ────────────────────────────────────────────────
|
|
233
|
+
/**
|
|
234
|
+
* ATTACH state.db to an open logs.db handle under the schema name `state`,
|
|
235
|
+
* enabling cross-db joins like task_history × task_logs.
|
|
236
|
+
*
|
|
237
|
+
* The state.db file must already exist (callers always open state.db first in
|
|
238
|
+
* practice); attaching a non-existent path would silently create an empty,
|
|
239
|
+
* unmigrated database file, so this throws instead.
|
|
240
|
+
*/
|
|
241
|
+
export function attachStateDatabase(db, stateDbPath) {
|
|
242
|
+
const resolved = stateDbPath ?? getStateDbPath();
|
|
243
|
+
if (!fs.existsSync(resolved)) {
|
|
244
|
+
throw new Error(`Cannot ATTACH state.db: file does not exist at ${resolved}`);
|
|
245
|
+
}
|
|
246
|
+
// prepare().run() rather than db.run(): both drivers support parameterised
|
|
247
|
+
// ATTACH through a prepared statement, and no other call site uses db.run().
|
|
248
|
+
db.prepare("ATTACH DATABASE ? AS state").run(resolved);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Convenience: open logs.db with state.db attached as `state`. The returned
|
|
252
|
+
* handle supports cross-db queries such as {@link queryFailedRunLogLines}.
|
|
253
|
+
* Close it like any other handle (DETACH is implicit on close).
|
|
254
|
+
*/
|
|
255
|
+
export function openLogsDatabaseWithState(logsDbPath, stateDbPath) {
|
|
256
|
+
const db = openLogsDatabase(logsDbPath);
|
|
257
|
+
try {
|
|
258
|
+
attachStateDatabase(db, stateDbPath);
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
db.close();
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
return db;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Cross-db join: every log line belonging to a FAILED task_history run whose
|
|
268
|
+
* `started_at` is `>= since` (all failed runs when omitted). Requires a handle
|
|
269
|
+
* opened via {@link openLogsDatabaseWithState}.
|
|
270
|
+
*
|
|
271
|
+
* The join key is the run_id encoding documented on {@link buildTaskRunId}:
|
|
272
|
+
* `task_logs.run_id = task_history.task_id || '@' || task_history.started_at`.
|
|
273
|
+
*/
|
|
274
|
+
export function queryFailedRunLogLines(db, options = {}) {
|
|
275
|
+
const conditions = ["th.status = 'failed'"];
|
|
276
|
+
const params = [];
|
|
277
|
+
if (options.since) {
|
|
278
|
+
conditions.push("th.started_at >= ?");
|
|
279
|
+
params.push(options.since);
|
|
280
|
+
}
|
|
281
|
+
const limit = options.limit !== undefined && options.limit >= 0 ? ` LIMIT ${Math.floor(options.limit)}` : "";
|
|
282
|
+
return db
|
|
283
|
+
.prepare(`SELECT th.task_id, l.run_id, th.started_at, th.status, l.ts, l.stream, l.level, l.line
|
|
284
|
+
FROM state.task_history th
|
|
285
|
+
JOIN task_logs l ON l.run_id = th.task_id || '@' || th.started_at
|
|
286
|
+
WHERE ${conditions.join(" AND ")}
|
|
287
|
+
ORDER BY th.started_at DESC, l.id ASC${limit}`)
|
|
288
|
+
.all(...params);
|
|
289
|
+
}
|
|
290
|
+
// ── Retention ────────────────────────────────────────────────────────────────
|
|
291
|
+
/**
|
|
292
|
+
* Delete task_logs rows older than `retentionDays` (default: 90). Mirrors
|
|
293
|
+
* `purgeOldEvents` / `purgeOldImproveRuns` in state-db.ts — same default, same
|
|
294
|
+
* return shape (rows deleted), same disabled-when-non-positive semantics.
|
|
295
|
+
* Wired into the improve maintenance pass alongside the state.db purges.
|
|
296
|
+
*/
|
|
297
|
+
export function purgeOldTaskLogs(db, retentionDays = 90) {
|
|
298
|
+
if (!Number.isFinite(retentionDays) || retentionDays <= 0)
|
|
299
|
+
return 0;
|
|
300
|
+
const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
|
|
301
|
+
const result = db.prepare("DELETE FROM task_logs WHERE ts < ?").run(cutoff);
|
|
302
|
+
const changes = result.changes ?? 0;
|
|
303
|
+
return typeof changes === "bigint" ? Number(changes) : changes;
|
|
304
|
+
}
|
package/dist/core/paths.js
CHANGED
|
@@ -215,6 +215,9 @@ export function getDataDir(env = process.env, platform = process.platform) {
|
|
|
215
215
|
export function getDbPath() {
|
|
216
216
|
return path.join(getDataDir(), "index.db");
|
|
217
217
|
}
|
|
218
|
+
export function getIndexWriterLockPath() {
|
|
219
|
+
return path.join(getDataDir(), "index.db.write.lock");
|
|
220
|
+
}
|
|
218
221
|
export function getWorkflowDbPath() {
|
|
219
222
|
return path.join(getDataDir(), "workflow.db");
|
|
220
223
|
}
|