akm-cli 0.7.5 → 0.8.0-rc.3
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/.github/CHANGELOG.md +1 -1
- package/dist/cli/parse-args.js +86 -0
- package/dist/cli.js +1023 -521
- package/dist/commands/agent-dispatch.js +107 -0
- package/dist/commands/agent-support.js +62 -0
- package/dist/commands/config-cli.js +68 -84
- package/dist/commands/consolidate.js +812 -0
- package/dist/commands/distill-promotion-policy.js +658 -0
- package/dist/commands/distill.js +218 -43
- package/dist/commands/eval-cases.js +40 -0
- package/dist/commands/events.js +2 -23
- package/dist/commands/graph.js +222 -0
- package/dist/commands/health.js +376 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +53 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +3 -30
- package/dist/commands/improve.js +1161 -0
- package/dist/commands/info.js +2 -2
- package/dist/commands/init.js +2 -2
- package/dist/commands/install-audit.js +5 -1
- package/dist/commands/installed-stashes.js +118 -138
- package/dist/commands/knowledge.js +133 -0
- package/dist/commands/lint/agent-linter.js +46 -0
- package/dist/commands/lint/base-linter.js +291 -0
- package/dist/commands/lint/command-linter.js +46 -0
- package/dist/commands/lint/default-linter.js +13 -0
- package/dist/commands/lint/index.js +145 -0
- package/dist/commands/lint/knowledge-linter.js +13 -0
- package/dist/commands/lint/memory-linter.js +58 -0
- package/dist/commands/lint/registry.js +33 -0
- package/dist/commands/lint/skill-linter.js +42 -0
- package/dist/commands/lint/task-linter.js +47 -0
- package/dist/commands/lint/types.js +1 -0
- package/dist/commands/lint/vault-key-rules.js +67 -0
- package/dist/commands/lint/workflow-linter.js +53 -0
- package/dist/commands/lint.js +1 -0
- package/dist/commands/proposal.js +8 -7
- package/dist/commands/propose.js +71 -28
- package/dist/commands/reflect.js +135 -35
- package/dist/commands/registry-search.js +2 -2
- package/dist/commands/remember.js +54 -0
- package/dist/commands/schema-repair.js +130 -0
- package/dist/commands/search.js +21 -5
- package/dist/commands/show.js +125 -20
- package/dist/commands/source-add.js +10 -10
- package/dist/commands/source-manage.js +11 -19
- package/dist/commands/tasks.js +385 -0
- package/dist/commands/url-checker.js +39 -0
- package/dist/commands/vault.js +168 -77
- package/dist/core/action-contributors.js +25 -0
- package/dist/core/asset-ref.js +4 -0
- package/dist/core/asset-registry.js +4 -16
- package/dist/core/asset-spec.js +10 -0
- package/dist/core/common.js +100 -0
- package/dist/core/concurrent.js +22 -0
- package/dist/core/config.js +233 -133
- package/dist/core/events.js +73 -126
- package/dist/core/frontmatter.js +0 -6
- package/dist/core/markdown.js +17 -0
- package/dist/core/memory-improve.js +678 -0
- package/dist/core/parse.js +155 -0
- package/dist/core/paths.js +101 -3
- package/dist/core/proposal-validators.js +61 -0
- package/dist/core/proposals.js +49 -38
- package/dist/core/state-db.js +731 -0
- package/dist/core/time.js +51 -0
- package/dist/core/warn.js +59 -1
- package/dist/indexer/db-search.js +52 -238
- package/dist/indexer/db.js +403 -54
- package/dist/indexer/ensure-index.js +61 -0
- package/dist/indexer/graph-boost.js +247 -94
- package/dist/indexer/graph-db.js +201 -0
- package/dist/indexer/graph-dedup.js +99 -0
- package/dist/indexer/graph-extraction.js +409 -76
- package/dist/indexer/index-context.js +10 -0
- package/dist/indexer/indexer.js +456 -290
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/matchers.js +124 -160
- package/dist/indexer/memory-inference.js +63 -29
- package/dist/indexer/metadata-contributors.js +26 -0
- package/dist/indexer/metadata.js +196 -197
- package/dist/indexer/path-resolver.js +89 -0
- package/dist/indexer/ranking-contributors.js +204 -0
- package/dist/indexer/ranking.js +74 -0
- package/dist/indexer/search-hit-enrichers.js +22 -0
- package/dist/indexer/search-source.js +24 -9
- package/dist/indexer/semantic-status.js +2 -16
- package/dist/indexer/walker.js +25 -0
- package/dist/integrations/agent/builders.js +109 -0
- package/dist/integrations/agent/config.js +203 -3
- package/dist/integrations/agent/index.js +5 -2
- package/dist/integrations/agent/model-aliases.js +63 -0
- package/dist/integrations/agent/profiles.js +67 -5
- package/dist/integrations/agent/prompts.js +77 -72
- package/dist/integrations/agent/sdk-runner.js +120 -0
- package/dist/integrations/agent/spawn.js +93 -22
- package/dist/integrations/lockfile.js +10 -18
- package/dist/integrations/session-logs/index.js +65 -0
- package/dist/integrations/session-logs/providers/claude-code.js +56 -0
- package/dist/integrations/session-logs/providers/opencode.js +52 -0
- package/dist/integrations/session-logs/types.js +1 -0
- package/dist/llm/call-ai.js +74 -0
- package/dist/llm/client.js +61 -122
- package/dist/llm/feature-gate.js +27 -16
- package/dist/llm/graph-extract.js +297 -62
- package/dist/llm/memory-infer.js +49 -71
- package/dist/llm/metadata-enhance.js +39 -22
- package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
- package/dist/output/cli-hints-full.md +277 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +2 -318
- package/dist/output/renderers.js +220 -256
- package/dist/output/shapes.js +101 -93
- package/dist/output/text.js +256 -17
- package/dist/registry/providers/skills-sh.js +61 -49
- package/dist/registry/providers/static-index.js +44 -48
- package/dist/registry/resolve.js +8 -16
- package/dist/setup/setup.js +510 -11
- package/dist/sources/provider-factory.js +2 -1
- package/dist/sources/providers/filesystem.js +16 -23
- package/dist/sources/providers/git.js +4 -5
- package/dist/sources/providers/website.js +15 -22
- package/dist/sources/website-ingest.js +4 -0
- package/dist/tasks/backends/cron.js +200 -0
- package/dist/tasks/backends/exec-utils.js +25 -0
- package/dist/tasks/backends/index.js +32 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +184 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +212 -0
- package/dist/tasks/parser.js +198 -0
- package/dist/tasks/resolveAkmBin.js +84 -0
- package/dist/tasks/runner.js +432 -0
- package/dist/tasks/schedule.js +208 -0
- package/dist/tasks/schema.js +13 -0
- package/dist/tasks/validator.js +59 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +12 -0
- package/dist/wiki/wiki.js +10 -61
- package/dist/workflows/authoring.js +5 -25
- package/dist/workflows/renderer.js +8 -3
- package/dist/workflows/runs.js +59 -91
- package/dist/workflows/validator.js +1 -1
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +5 -2
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.8.0.md +43 -0
- package/package.json +3 -2
- package/dist/templates/wiki-templates.js +0 -100
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state.db — Durable SQLite database for non-regenerable akm state.
|
|
3
|
+
*
|
|
4
|
+
* This module owns THREE tables that replace flat-file storage:
|
|
5
|
+
*
|
|
6
|
+
* events — replaces events.jsonl (append-only event bus)
|
|
7
|
+
* proposals — replaces per-uuid JSON directories under .akm/proposals/
|
|
8
|
+
* task_history — replaces per-task JSONL files under <cacheDir>/tasks/history/
|
|
9
|
+
*
|
|
10
|
+
* ## Why a separate database from index.db
|
|
11
|
+
*
|
|
12
|
+
* index.db uses a single DB_VERSION integer: when the version changes it drops
|
|
13
|
+
* ALL tables and recreates them. That is acceptable for the search index because
|
|
14
|
+
* every entry is fully regenerable from the stash on disk. Events, proposals, and
|
|
15
|
+
* task history are NON-REGENERABLE — losing them is data loss. They must live in
|
|
16
|
+
* a database whose schema evolves via incremental, additive migrations that never
|
|
17
|
+
* drop rows.
|
|
18
|
+
*
|
|
19
|
+
* ## Migration-safety contract
|
|
20
|
+
*
|
|
21
|
+
* The `schema_migrations` table records every applied migration by a stable string
|
|
22
|
+
* ID. `runMigrations(db)` is idempotent: new installs run all migrations in order;
|
|
23
|
+
* upgrades run only the ones not yet applied. No migration may DROP a table that
|
|
24
|
+
* holds durable data, RENAME a column, or change a column's type.
|
|
25
|
+
*
|
|
26
|
+
* Permitted schema evolution operations (always migration-safe in SQLite):
|
|
27
|
+
* - ALTER TABLE … ADD COLUMN <name> <type> DEFAULT <value>
|
|
28
|
+
* - CREATE INDEX IF NOT EXISTS …
|
|
29
|
+
* - CREATE TABLE IF NOT EXISTS … (additive new tables)
|
|
30
|
+
*
|
|
31
|
+
* ## Schema design: indexed columns vs. metadata_json
|
|
32
|
+
*
|
|
33
|
+
* Each table holds only the columns needed for indexed queries as first-class
|
|
34
|
+
* columns. All other fields live in a `metadata_json TEXT` column (a JSON object).
|
|
35
|
+
* New fields can be appended to the JSON blob at any time without touching the
|
|
36
|
+
* DDL. This is the same pattern used by `usage_events.metadata` in index.db and
|
|
37
|
+
* by the original events.jsonl format (the `metadata` field was always free-form
|
|
38
|
+
* JSON).
|
|
39
|
+
*
|
|
40
|
+
* ## WAL mode
|
|
41
|
+
*
|
|
42
|
+
* SQLite WAL mode allows concurrent readers while a writer is active and makes
|
|
43
|
+
* crashes safe (the WAL is replayed on next open). The O_APPEND multi-writer model
|
|
44
|
+
* of events.jsonl is replaced by WAL-mode serialised writes — acceptable because
|
|
45
|
+
* CLI commands are almost always single-writer.
|
|
46
|
+
*
|
|
47
|
+
* @module state-db
|
|
48
|
+
*/
|
|
49
|
+
import { Database } from "bun:sqlite";
|
|
50
|
+
import fs from "node:fs";
|
|
51
|
+
import path from "node:path";
|
|
52
|
+
import { getDataDir } from "./paths";
|
|
53
|
+
import { error } from "./warn";
|
|
54
|
+
// ── Path helper ──────────────────────────────────────────────────────────────
|
|
55
|
+
/**
|
|
56
|
+
* Default path: `<dataDir>/state.db`.
|
|
57
|
+
* Respects the same `AKM_DATA_DIR` / XDG_DATA_HOME env-isolation as `getDbPath()` so
|
|
58
|
+
* cooperating processes sharing a data root automatically share the same
|
|
59
|
+
* state database.
|
|
60
|
+
*/
|
|
61
|
+
export function getStateDbPath() {
|
|
62
|
+
return path.join(getDataDir(), "state.db");
|
|
63
|
+
}
|
|
64
|
+
// ── Database open ────────────────────────────────────────────────────────────
|
|
65
|
+
/**
|
|
66
|
+
* Open (and initialise / migrate) the state database.
|
|
67
|
+
*
|
|
68
|
+
* @param dbPath - Override the database file path. Pass a tmpdir path in tests
|
|
69
|
+
* to avoid touching the real user cache. Mirrors the `filePath` test seam
|
|
70
|
+
* on `EventsContext`.
|
|
71
|
+
*
|
|
72
|
+
* PRAGMA rationale:
|
|
73
|
+
*
|
|
74
|
+
* journal_mode = WAL
|
|
75
|
+
* Write-Ahead Logging: readers never block writers and vice-versa. Crashes
|
|
76
|
+
* are safe — the WAL is replayed on next open. Required for concurrent CLI
|
|
77
|
+
* invocations that may read while another writes.
|
|
78
|
+
*
|
|
79
|
+
* foreign_keys = ON
|
|
80
|
+
* Enforces FK constraints at runtime. SQLite disables them by default for
|
|
81
|
+
* backwards compatibility; enabling them prevents orphaned rows in tables
|
|
82
|
+
* that reference each other (not used in v1 schema but guards future ones).
|
|
83
|
+
*
|
|
84
|
+
* busy_timeout = 5000
|
|
85
|
+
* When another connection holds a write lock, SQLite retries for up to
|
|
86
|
+
* 5 000 ms before returning SQLITE_BUSY. Without this, the default timeout
|
|
87
|
+
* is 0 ms — any concurrent writer causes an immediate error. 5 s matches
|
|
88
|
+
* the same value used in openDatabase() for index.db.
|
|
89
|
+
*/
|
|
90
|
+
export function openStateDatabase(dbPath) {
|
|
91
|
+
const resolvedPath = dbPath ?? getStateDbPath();
|
|
92
|
+
const dir = path.dirname(resolvedPath);
|
|
93
|
+
if (!fs.existsSync(dir)) {
|
|
94
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
95
|
+
}
|
|
96
|
+
const db = new Database(resolvedPath);
|
|
97
|
+
// PRAGMAs must run before any DDL or DML.
|
|
98
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
99
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
100
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
101
|
+
runMigrations(db);
|
|
102
|
+
return db;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* All migrations in application order. New migrations are APPENDED to this
|
|
106
|
+
* array — never inserted in the middle or reordered.
|
|
107
|
+
*
|
|
108
|
+
* @see Migration
|
|
109
|
+
*/
|
|
110
|
+
const MIGRATIONS = [
|
|
111
|
+
// ── Migration 001 — initial schema ──────────────────────────────────────────
|
|
112
|
+
{
|
|
113
|
+
id: "001-initial-schema",
|
|
114
|
+
up: `
|
|
115
|
+
-- ── events ──────────────────────────────────────────────────────────────
|
|
116
|
+
--
|
|
117
|
+
-- Replaces events.jsonl. Indexed (query) columns:
|
|
118
|
+
-- id INTEGER PK — monotonic rowid; replaces byte-offset cursor.
|
|
119
|
+
-- Callers store this as "sinceId" for resume.
|
|
120
|
+
-- event_type TEXT — indexed; replaces the type filter in readEvents().
|
|
121
|
+
-- ts TEXT — ISO-8601 UTC ms; indexed for range queries.
|
|
122
|
+
-- ref TEXT — nullable asset ref; indexed for ref-scoped queries.
|
|
123
|
+
--
|
|
124
|
+
-- Extensible (metadata_json) columns:
|
|
125
|
+
-- metadata_json TEXT — JSON object storing all non-indexed payload
|
|
126
|
+
-- fields (tags, any future structured fields).
|
|
127
|
+
-- Maps directly to EventEnvelope.metadata.
|
|
128
|
+
--
|
|
129
|
+
-- schema_version mirrors EventEnvelope.schemaVersion — always 1 for v1
|
|
130
|
+
-- rows. Stored as a column (not in the JSON blob) so future schema
|
|
131
|
+
-- changes can be detected and migrated row-by-row if ever needed.
|
|
132
|
+
--
|
|
133
|
+
-- TTL: rows where ts < NOW() - 90 days can be deleted by a maintenance job.
|
|
134
|
+
-- No automatic deletion occurs here — callers call purgeOldEvents().
|
|
135
|
+
--
|
|
136
|
+
-- ADD COLUMN extension points (future migrations):
|
|
137
|
+
-- ALTER TABLE events ADD COLUMN stash_dir TEXT DEFAULT NULL;
|
|
138
|
+
-- ALTER TABLE events ADD COLUMN correlation_id TEXT DEFAULT NULL;
|
|
139
|
+
-- ALTER TABLE events ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;
|
|
140
|
+
--
|
|
141
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
142
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
143
|
+
event_type TEXT NOT NULL,
|
|
144
|
+
ts TEXT NOT NULL,
|
|
145
|
+
ref TEXT,
|
|
146
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
-- Query patterns supported by these indexes:
|
|
150
|
+
-- SELECT … WHERE event_type = ? → idx_events_type
|
|
151
|
+
-- SELECT … WHERE ref = ? → idx_events_ref
|
|
152
|
+
-- SELECT … WHERE ts >= ? AND ts <= ? → idx_events_ts
|
|
153
|
+
-- SELECT … WHERE event_type = ? AND ref = ? → idx_events_type (prefix scan) + filter
|
|
154
|
+
-- SELECT … WHERE id > ? → PK (rowid) — no extra index needed
|
|
155
|
+
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
|
|
156
|
+
CREATE INDEX IF NOT EXISTS idx_events_ref ON events(ref);
|
|
157
|
+
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
|
|
158
|
+
|
|
159
|
+
-- ── proposals ────────────────────────────────────────────────────────────
|
|
160
|
+
--
|
|
161
|
+
-- Replaces per-uuid JSON directories under <stashDir>/.akm/proposals/.
|
|
162
|
+
--
|
|
163
|
+
-- Indexed (query) columns:
|
|
164
|
+
-- id TEXT PK — UUID (crypto.randomUUID()); stable directory name.
|
|
165
|
+
-- stash_dir TEXT — absolute stash root; multi-stash installs need
|
|
166
|
+
-- this to partition proposal lists per stash.
|
|
167
|
+
-- ref TEXT — target asset ref (e.g. "lesson:alpha");
|
|
168
|
+
-- indexed for ref-scoped queue views.
|
|
169
|
+
-- status TEXT — "pending" | "accepted" | "rejected"; indexed
|
|
170
|
+
-- so pending-queue queries are fast.
|
|
171
|
+
-- source TEXT — human-readable origin tag (e.g. "reflect").
|
|
172
|
+
-- created_at TEXT — ISO-8601; used for ORDER BY created_at ASC.
|
|
173
|
+
-- updated_at TEXT — ISO-8601; updated on accept/reject.
|
|
174
|
+
--
|
|
175
|
+
-- Large payload columns (NOT indexed):
|
|
176
|
+
-- content TEXT — full markdown text; the proposal payload body.
|
|
177
|
+
-- frontmatter_json TEXT — JSON of parsed frontmatter (may be NULL when
|
|
178
|
+
-- the content has no frontmatter block).
|
|
179
|
+
--
|
|
180
|
+
-- Extensible (metadata_json) columns:
|
|
181
|
+
-- metadata_json TEXT — JSON object for future proposal fields.
|
|
182
|
+
-- Current fields stored here: sourceRun, review.
|
|
183
|
+
--
|
|
184
|
+
-- ADD COLUMN extension points (future migrations):
|
|
185
|
+
-- ALTER TABLE proposals ADD COLUMN source_run TEXT DEFAULT NULL;
|
|
186
|
+
-- ALTER TABLE proposals ADD COLUMN review_outcome TEXT DEFAULT NULL;
|
|
187
|
+
-- ALTER TABLE proposals ADD COLUMN review_reason TEXT DEFAULT NULL;
|
|
188
|
+
-- ALTER TABLE proposals ADD COLUMN review_decided_at TEXT DEFAULT NULL;
|
|
189
|
+
-- ALTER TABLE proposals ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
|
|
190
|
+
--
|
|
191
|
+
CREATE TABLE IF NOT EXISTS proposals (
|
|
192
|
+
id TEXT PRIMARY KEY,
|
|
193
|
+
stash_dir TEXT NOT NULL,
|
|
194
|
+
ref TEXT NOT NULL,
|
|
195
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
196
|
+
source TEXT NOT NULL,
|
|
197
|
+
created_at TEXT NOT NULL,
|
|
198
|
+
updated_at TEXT NOT NULL,
|
|
199
|
+
content TEXT NOT NULL DEFAULT '',
|
|
200
|
+
frontmatter_json TEXT,
|
|
201
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
-- Query patterns:
|
|
205
|
+
-- SELECT … WHERE stash_dir = ? AND status = ? → idx_proposals_stash_status
|
|
206
|
+
-- SELECT … WHERE ref = ? AND status = ? → idx_proposals_ref_status
|
|
207
|
+
-- SELECT … WHERE id = ? → PK
|
|
208
|
+
CREATE INDEX IF NOT EXISTS idx_proposals_stash_status
|
|
209
|
+
ON proposals(stash_dir, status);
|
|
210
|
+
CREATE INDEX IF NOT EXISTS idx_proposals_ref_status
|
|
211
|
+
ON proposals(ref, status);
|
|
212
|
+
|
|
213
|
+
-- ── task_history ─────────────────────────────────────────────────────────
|
|
214
|
+
--
|
|
215
|
+
-- Replaces per-task JSONL files under <cacheDir>/tasks/history/.
|
|
216
|
+
--
|
|
217
|
+
-- Indexed (query) columns:
|
|
218
|
+
-- task_id TEXT PK — stable task identifier string.
|
|
219
|
+
-- status TEXT — terminal status (e.g. "completed", "failed",
|
|
220
|
+
-- "cancelled"); indexed for status-scoped queries.
|
|
221
|
+
-- started_at TEXT — ISO-8601; indexed for time-range queries.
|
|
222
|
+
-- target_kind TEXT — kind of the target entity (e.g. "issue",
|
|
223
|
+
-- "workflow", "agent"); indexed for kind-scoped queries.
|
|
224
|
+
-- target_ref TEXT — stable ref of the target entity; indexed for
|
|
225
|
+
-- per-target history lookups.
|
|
226
|
+
--
|
|
227
|
+
-- Non-indexed time columns:
|
|
228
|
+
-- completed_at TEXT — ISO-8601 or NULL if still running.
|
|
229
|
+
-- failed_at TEXT — ISO-8601 or NULL.
|
|
230
|
+
--
|
|
231
|
+
-- Non-indexed diagnostic columns:
|
|
232
|
+
-- log_path TEXT — absolute path to the task log file, if any.
|
|
233
|
+
--
|
|
234
|
+
-- Extensible (metadata_json) columns:
|
|
235
|
+
-- metadata_json TEXT — JSON object for future task fields (exit_code,
|
|
236
|
+
-- runner, priority, parent_task_id, …).
|
|
237
|
+
--
|
|
238
|
+
-- ADD COLUMN extension points (future migrations):
|
|
239
|
+
-- ALTER TABLE task_history ADD COLUMN exit_code INTEGER DEFAULT NULL;
|
|
240
|
+
-- ALTER TABLE task_history ADD COLUMN runner TEXT DEFAULT NULL;
|
|
241
|
+
-- ALTER TABLE task_history ADD COLUMN parent_task_id TEXT DEFAULT NULL;
|
|
242
|
+
-- ALTER TABLE task_history ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;
|
|
243
|
+
--
|
|
244
|
+
CREATE TABLE IF NOT EXISTS task_history (
|
|
245
|
+
task_id TEXT PRIMARY KEY,
|
|
246
|
+
status TEXT NOT NULL,
|
|
247
|
+
started_at TEXT NOT NULL,
|
|
248
|
+
completed_at TEXT,
|
|
249
|
+
failed_at TEXT,
|
|
250
|
+
log_path TEXT,
|
|
251
|
+
target_kind TEXT,
|
|
252
|
+
target_ref TEXT,
|
|
253
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
-- Query patterns:
|
|
257
|
+
-- SELECT … WHERE task_id = ? → PK
|
|
258
|
+
-- SELECT … WHERE started_at >= ? AND started_at <= ? → idx_task_history_started
|
|
259
|
+
-- SELECT … WHERE target_kind = ? AND target_ref = ? → idx_task_history_target
|
|
260
|
+
-- SELECT … WHERE status = ? → idx_task_history_status
|
|
261
|
+
CREATE INDEX IF NOT EXISTS idx_task_history_started
|
|
262
|
+
ON task_history(started_at);
|
|
263
|
+
CREATE INDEX IF NOT EXISTS idx_task_history_target
|
|
264
|
+
ON task_history(target_kind, target_ref);
|
|
265
|
+
CREATE INDEX IF NOT EXISTS idx_task_history_status
|
|
266
|
+
ON task_history(status);
|
|
267
|
+
`,
|
|
268
|
+
},
|
|
269
|
+
// Migration 002 — fix task_history to be a true per-run log.
|
|
270
|
+
//
|
|
271
|
+
// Migration 001 used task_id as PRIMARY KEY, meaning each task had exactly
|
|
272
|
+
// one row and every new run overwrote the previous one. This silently
|
|
273
|
+
// discarded all historical runs — the opposite of a history table.
|
|
274
|
+
//
|
|
275
|
+
// This migration recreates the table with an AUTOINCREMENT id so each run
|
|
276
|
+
// appends a new row. The old single-row table is renamed to _old, the new
|
|
277
|
+
// table is created, data is copied, and the old table is dropped.
|
|
278
|
+
{
|
|
279
|
+
id: "002-task-history-per-run",
|
|
280
|
+
up: `
|
|
281
|
+
ALTER TABLE task_history RENAME TO task_history_v1;
|
|
282
|
+
|
|
283
|
+
CREATE TABLE task_history (
|
|
284
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
285
|
+
task_id TEXT NOT NULL,
|
|
286
|
+
status TEXT NOT NULL,
|
|
287
|
+
started_at TEXT NOT NULL,
|
|
288
|
+
completed_at TEXT,
|
|
289
|
+
failed_at TEXT,
|
|
290
|
+
log_path TEXT,
|
|
291
|
+
target_kind TEXT,
|
|
292
|
+
target_ref TEXT,
|
|
293
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
INSERT INTO task_history
|
|
297
|
+
(task_id, status, started_at, completed_at, failed_at,
|
|
298
|
+
log_path, target_kind, target_ref, metadata_json)
|
|
299
|
+
SELECT task_id, status, started_at, completed_at, failed_at,
|
|
300
|
+
log_path, target_kind, target_ref, metadata_json
|
|
301
|
+
FROM task_history_v1;
|
|
302
|
+
|
|
303
|
+
DROP TABLE task_history_v1;
|
|
304
|
+
|
|
305
|
+
-- Unique constraint: same task cannot have two runs with the same start time.
|
|
306
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_task_history_run
|
|
307
|
+
ON task_history(task_id, started_at);
|
|
308
|
+
CREATE INDEX IF NOT EXISTS idx_task_history_task_id
|
|
309
|
+
ON task_history(task_id);
|
|
310
|
+
CREATE INDEX IF NOT EXISTS idx_task_history_started
|
|
311
|
+
ON task_history(started_at);
|
|
312
|
+
CREATE INDEX IF NOT EXISTS idx_task_history_target
|
|
313
|
+
ON task_history(target_kind, target_ref);
|
|
314
|
+
CREATE INDEX IF NOT EXISTS idx_task_history_status
|
|
315
|
+
ON task_history(status);
|
|
316
|
+
`,
|
|
317
|
+
},
|
|
318
|
+
];
|
|
319
|
+
/**
|
|
320
|
+
* Create the migrations table if it does not exist. This must be called
|
|
321
|
+
* unconditionally on every open so a fresh database bootstraps correctly.
|
|
322
|
+
*/
|
|
323
|
+
function ensureMigrationsTable(db) {
|
|
324
|
+
db.exec(`
|
|
325
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
326
|
+
id TEXT PRIMARY KEY,
|
|
327
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
328
|
+
);
|
|
329
|
+
`);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Apply every pending migration in a single transaction per migration.
|
|
333
|
+
*
|
|
334
|
+
* Each migration is applied in its own transaction so a failure in migration N
|
|
335
|
+
* does not roll back already-applied migrations 1..N-1. The migration row is
|
|
336
|
+
* inserted AFTER the DDL succeeds, so a crash mid-migration leaves no row and
|
|
337
|
+
* the migration will be retried on next open (all DDL in `up` uses IF NOT
|
|
338
|
+
* EXISTS so the retry is safe).
|
|
339
|
+
*
|
|
340
|
+
* Called automatically by `openStateDatabase()`.
|
|
341
|
+
*/
|
|
342
|
+
export function runMigrations(db) {
|
|
343
|
+
ensureMigrationsTable(db);
|
|
344
|
+
const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
|
|
345
|
+
const applied = new Set(appliedRows.map((r) => r.id));
|
|
346
|
+
for (const migration of MIGRATIONS) {
|
|
347
|
+
if (applied.has(migration.id))
|
|
348
|
+
continue;
|
|
349
|
+
db.transaction(() => {
|
|
350
|
+
db.exec(migration.up);
|
|
351
|
+
db.prepare("INSERT INTO schema_migrations (id) VALUES (?)").run(migration.id);
|
|
352
|
+
})();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Convert a raw `EventRow` from the database to the public `EventEnvelope`
|
|
357
|
+
* interface used throughout the events module.
|
|
358
|
+
*/
|
|
359
|
+
export function eventRowToEnvelope(row) {
|
|
360
|
+
let metadata;
|
|
361
|
+
try {
|
|
362
|
+
const parsed = JSON.parse(row.metadata_json);
|
|
363
|
+
// Only attach metadata when the JSON blob is non-empty so downstream
|
|
364
|
+
// consumers that check `envelope.metadata !== undefined` keep working.
|
|
365
|
+
if (Object.keys(parsed).length > 0) {
|
|
366
|
+
metadata = parsed;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
// Corrupt JSON in the DB — treat as no metadata.
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
schemaVersion: 1,
|
|
374
|
+
id: row.id,
|
|
375
|
+
ts: row.ts,
|
|
376
|
+
eventType: row.event_type,
|
|
377
|
+
...(row.ref !== null ? { ref: row.ref } : {}),
|
|
378
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Convert a raw `ProposalRow` to the public `Proposal` shape.
|
|
383
|
+
*/
|
|
384
|
+
export function proposalRowToProposal(row) {
|
|
385
|
+
let frontmatter;
|
|
386
|
+
if (row.frontmatter_json) {
|
|
387
|
+
try {
|
|
388
|
+
frontmatter = JSON.parse(row.frontmatter_json);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
/* ignore corrupt frontmatter JSON */
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
let meta = {};
|
|
395
|
+
try {
|
|
396
|
+
meta = JSON.parse(row.metadata_json);
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
/* ignore */
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
id: row.id,
|
|
403
|
+
ref: row.ref,
|
|
404
|
+
status: row.status,
|
|
405
|
+
source: row.source,
|
|
406
|
+
...(typeof meta.sourceRun === "string" ? { sourceRun: meta.sourceRun } : {}),
|
|
407
|
+
createdAt: row.created_at,
|
|
408
|
+
updatedAt: row.updated_at,
|
|
409
|
+
payload: {
|
|
410
|
+
content: row.content,
|
|
411
|
+
...(frontmatter !== undefined ? { frontmatter } : {}),
|
|
412
|
+
},
|
|
413
|
+
...(meta.review !== undefined ? { review: meta.review } : {}),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Convert a public `Proposal` to column values ready for an INSERT/UPDATE.
|
|
418
|
+
* The `stash_dir` comes from the call site (proposals.ts has it in scope).
|
|
419
|
+
*/
|
|
420
|
+
export function proposalToRowValues(proposal, stashDir) {
|
|
421
|
+
// Fields that have no dedicated column live in metadata_json.
|
|
422
|
+
const metaObj = {};
|
|
423
|
+
if (proposal.sourceRun !== undefined)
|
|
424
|
+
metaObj.sourceRun = proposal.sourceRun;
|
|
425
|
+
if (proposal.review !== undefined)
|
|
426
|
+
metaObj.review = proposal.review;
|
|
427
|
+
return {
|
|
428
|
+
id: proposal.id,
|
|
429
|
+
stash_dir: stashDir,
|
|
430
|
+
ref: proposal.ref,
|
|
431
|
+
status: proposal.status,
|
|
432
|
+
source: proposal.source,
|
|
433
|
+
created_at: proposal.createdAt,
|
|
434
|
+
updated_at: proposal.updatedAt,
|
|
435
|
+
content: proposal.payload.content,
|
|
436
|
+
frontmatter_json: proposal.payload.frontmatter ? JSON.stringify(proposal.payload.frontmatter) : null,
|
|
437
|
+
metadata_json: JSON.stringify(metaObj),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
// ── events table helpers ─────────────────────────────────────────────────────
|
|
441
|
+
/**
|
|
442
|
+
* Insert a single event. Returns the auto-assigned monotonic rowid, which
|
|
443
|
+
* callers can store as a "sinceId" cursor for future `readEventsSince` calls.
|
|
444
|
+
*
|
|
445
|
+
* Best-effort: mirrors the behaviour of the old `appendEvent` — errors are
|
|
446
|
+
* caught and logged to stderr rather than propagated so observability never
|
|
447
|
+
* breaks mutation.
|
|
448
|
+
*/
|
|
449
|
+
export function insertEvent(db, input) {
|
|
450
|
+
try {
|
|
451
|
+
const result = db
|
|
452
|
+
.prepare(`INSERT INTO events (event_type, ts, ref, metadata_json)
|
|
453
|
+
VALUES (?, ?, ?, ?)
|
|
454
|
+
RETURNING id`)
|
|
455
|
+
.get(input.eventType, input.ts, input.ref ?? null, JSON.stringify(input.metadata ?? {}));
|
|
456
|
+
return result?.id;
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
460
|
+
error(`akm: state.db event insert failed (${message})`);
|
|
461
|
+
return undefined;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Read events from the database matching the filter. Returns events in
|
|
466
|
+
* ascending id order so consumers can process them in emission order.
|
|
467
|
+
*
|
|
468
|
+
* The returned `nextId` is the maximum id seen (or `sinceId` when no rows
|
|
469
|
+
* match), suitable as the next `sinceId` cursor value.
|
|
470
|
+
*/
|
|
471
|
+
export function readStateEvents(db, options = {}) {
|
|
472
|
+
const conditions = [];
|
|
473
|
+
const params = [];
|
|
474
|
+
if (options.sinceId !== undefined && options.sinceId > 0) {
|
|
475
|
+
conditions.push("id > ?");
|
|
476
|
+
params.push(options.sinceId);
|
|
477
|
+
}
|
|
478
|
+
if (options.since) {
|
|
479
|
+
conditions.push("ts >= ?");
|
|
480
|
+
params.push(options.since);
|
|
481
|
+
}
|
|
482
|
+
if (options.type) {
|
|
483
|
+
conditions.push("event_type = ?");
|
|
484
|
+
params.push(options.type);
|
|
485
|
+
}
|
|
486
|
+
if (options.ref) {
|
|
487
|
+
conditions.push("ref = ?");
|
|
488
|
+
params.push(options.ref);
|
|
489
|
+
}
|
|
490
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
491
|
+
const rows = db
|
|
492
|
+
.prepare(`SELECT id, event_type, ts, ref, metadata_json FROM events ${where} ORDER BY id ASC`)
|
|
493
|
+
.all(...params);
|
|
494
|
+
const events = rows.map(eventRowToEnvelope);
|
|
495
|
+
const nextId = events.length > 0 ? events[events.length - 1].id : (options.sinceId ?? 0);
|
|
496
|
+
return { events, nextId };
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Delete events older than `retentionDays` (default: 90). Safe to call from
|
|
500
|
+
* a maintenance cron; uses a single DELETE with an index-covered ts predicate.
|
|
501
|
+
*/
|
|
502
|
+
export function purgeOldEvents(db, retentionDays = 90) {
|
|
503
|
+
if (!Number.isFinite(retentionDays) || retentionDays <= 0)
|
|
504
|
+
return;
|
|
505
|
+
const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
|
|
506
|
+
db.prepare("DELETE FROM events WHERE ts < ?").run(cutoff);
|
|
507
|
+
}
|
|
508
|
+
// ── proposals table helpers ──────────────────────────────────────────────────
|
|
509
|
+
/**
|
|
510
|
+
* Upsert a proposal row. Called by the proposal write path when state.db is
|
|
511
|
+
* the active backend.
|
|
512
|
+
*/
|
|
513
|
+
export function upsertProposal(db, proposal, stashDir) {
|
|
514
|
+
const v = proposalToRowValues(proposal, stashDir);
|
|
515
|
+
db.prepare(`
|
|
516
|
+
INSERT INTO proposals
|
|
517
|
+
(id, stash_dir, ref, status, source, created_at, updated_at, content, frontmatter_json, metadata_json)
|
|
518
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
519
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
520
|
+
stash_dir = excluded.stash_dir,
|
|
521
|
+
ref = excluded.ref,
|
|
522
|
+
status = excluded.status,
|
|
523
|
+
source = excluded.source,
|
|
524
|
+
updated_at = excluded.updated_at,
|
|
525
|
+
content = excluded.content,
|
|
526
|
+
frontmatter_json = excluded.frontmatter_json,
|
|
527
|
+
metadata_json = excluded.metadata_json
|
|
528
|
+
`).run(v.id, v.stash_dir, v.ref, v.status, v.source, v.created_at, v.updated_at, v.content, v.frontmatter_json, v.metadata_json);
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* List proposals, optionally filtered by stashDir, status, and/or ref.
|
|
532
|
+
* Results are sorted by created_at ASC to match the existing listProposals() behaviour.
|
|
533
|
+
*/
|
|
534
|
+
export function listStateProposals(db, options = {}) {
|
|
535
|
+
const conditions = [];
|
|
536
|
+
const params = [];
|
|
537
|
+
if (options.stashDir) {
|
|
538
|
+
conditions.push("stash_dir = ?");
|
|
539
|
+
params.push(options.stashDir);
|
|
540
|
+
}
|
|
541
|
+
if (options.status) {
|
|
542
|
+
conditions.push("status = ?");
|
|
543
|
+
params.push(options.status);
|
|
544
|
+
}
|
|
545
|
+
if (options.ref) {
|
|
546
|
+
conditions.push("ref = ?");
|
|
547
|
+
params.push(options.ref);
|
|
548
|
+
}
|
|
549
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
550
|
+
const rows = db
|
|
551
|
+
.prepare(`SELECT id, stash_dir, ref, status, source, created_at, updated_at,
|
|
552
|
+
content, frontmatter_json, metadata_json
|
|
553
|
+
FROM proposals ${where} ORDER BY created_at ASC`)
|
|
554
|
+
.all(...params);
|
|
555
|
+
return rows.map(proposalRowToProposal);
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Look up a single proposal by id. Returns undefined when not found.
|
|
559
|
+
*/
|
|
560
|
+
export function getStateProposal(db, id) {
|
|
561
|
+
const row = db
|
|
562
|
+
.prepare(`SELECT id, stash_dir, ref, status, source, created_at, updated_at,
|
|
563
|
+
content, frontmatter_json, metadata_json
|
|
564
|
+
FROM proposals WHERE id = ?`)
|
|
565
|
+
.get(id);
|
|
566
|
+
return row ? proposalRowToProposal(row) : undefined;
|
|
567
|
+
}
|
|
568
|
+
// ── task_history table helpers ───────────────────────────────────────────────
|
|
569
|
+
/**
|
|
570
|
+
* Upsert a task history row.
|
|
571
|
+
*/
|
|
572
|
+
export function upsertTaskHistory(db, row) {
|
|
573
|
+
// INSERT OR IGNORE: if a run with the same (task_id, started_at) was already
|
|
574
|
+
// imported (e.g. by the migration script), skip it silently.
|
|
575
|
+
db.prepare(`
|
|
576
|
+
INSERT OR IGNORE INTO task_history
|
|
577
|
+
(task_id, status, started_at, completed_at, failed_at, log_path,
|
|
578
|
+
target_kind, target_ref, metadata_json)
|
|
579
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
580
|
+
`).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);
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Look up a task history row by task_id. Returns undefined when not found.
|
|
584
|
+
*/
|
|
585
|
+
/**
|
|
586
|
+
* Return the most recent run for a given task_id, or undefined if no runs exist.
|
|
587
|
+
*/
|
|
588
|
+
export function getTaskHistory(db, taskId) {
|
|
589
|
+
return db
|
|
590
|
+
.prepare(`SELECT id, task_id, status, started_at, completed_at, failed_at, log_path,
|
|
591
|
+
target_kind, target_ref, metadata_json
|
|
592
|
+
FROM task_history WHERE task_id = ? ORDER BY started_at DESC LIMIT 1`)
|
|
593
|
+
.get(taskId);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Return all runs for a given task_id, newest first.
|
|
597
|
+
*/
|
|
598
|
+
export function getTaskHistoryRuns(db, taskId, limit = 50) {
|
|
599
|
+
return db
|
|
600
|
+
.prepare(`SELECT id, task_id, status, started_at, completed_at, failed_at, log_path,
|
|
601
|
+
target_kind, target_ref, metadata_json
|
|
602
|
+
FROM task_history WHERE task_id = ? ORDER BY started_at DESC LIMIT ?`)
|
|
603
|
+
.all(taskId, limit);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Query task history rows by started_at range and/or status.
|
|
607
|
+
*/
|
|
608
|
+
export function queryTaskHistory(db, options = {}) {
|
|
609
|
+
const conditions = [];
|
|
610
|
+
const params = [];
|
|
611
|
+
if (options.since) {
|
|
612
|
+
conditions.push("started_at >= ?");
|
|
613
|
+
params.push(options.since);
|
|
614
|
+
}
|
|
615
|
+
if (options.until) {
|
|
616
|
+
conditions.push("started_at <= ?");
|
|
617
|
+
params.push(options.until);
|
|
618
|
+
}
|
|
619
|
+
if (options.status) {
|
|
620
|
+
conditions.push("status = ?");
|
|
621
|
+
params.push(options.status);
|
|
622
|
+
}
|
|
623
|
+
if (options.targetKind) {
|
|
624
|
+
conditions.push("target_kind = ?");
|
|
625
|
+
params.push(options.targetKind);
|
|
626
|
+
}
|
|
627
|
+
if (options.targetRef) {
|
|
628
|
+
conditions.push("target_ref = ?");
|
|
629
|
+
params.push(options.targetRef);
|
|
630
|
+
}
|
|
631
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
632
|
+
return db
|
|
633
|
+
.prepare(`SELECT task_id, status, started_at, completed_at, failed_at, log_path,
|
|
634
|
+
target_kind, target_ref, metadata_json
|
|
635
|
+
FROM task_history ${where} ORDER BY started_at DESC`)
|
|
636
|
+
.all(...params);
|
|
637
|
+
}
|
|
638
|
+
// ── events.jsonl import ──────────────────────────────────────────────────────
|
|
639
|
+
/**
|
|
640
|
+
* Import all events from an `events.jsonl` file into the `events` table.
|
|
641
|
+
*
|
|
642
|
+
* The old byte-offset `id` is NOT preserved — the database assigns new
|
|
643
|
+
* monotonic integer ids. Callers that persisted a byte-offset cursor must
|
|
644
|
+
* discard it after migration and use the returned `maxId` as the new cursor.
|
|
645
|
+
*
|
|
646
|
+
* The import is wrapped in a single transaction for atomicity. If the file
|
|
647
|
+
* has already been imported (the events table is non-empty and the file
|
|
648
|
+
* has not changed since last import), callers should skip calling this
|
|
649
|
+
* function — de-duplication is NOT performed here to keep the hot path fast.
|
|
650
|
+
*
|
|
651
|
+
* @param db - Open state.db connection.
|
|
652
|
+
* @param jsonlPath - Absolute path to the events.jsonl file to import.
|
|
653
|
+
* @returns Number of rows inserted and the max id assigned.
|
|
654
|
+
*/
|
|
655
|
+
export async function importEventsJsonl(db, jsonlPath) {
|
|
656
|
+
const { readFileSync, existsSync } = await import("node:fs");
|
|
657
|
+
if (!existsSync(jsonlPath)) {
|
|
658
|
+
return { imported: 0, maxId: 0 };
|
|
659
|
+
}
|
|
660
|
+
const text = readFileSync(jsonlPath, "utf8");
|
|
661
|
+
const lines = text.split("\n").filter((l) => l.trim().length > 0);
|
|
662
|
+
let imported = 0;
|
|
663
|
+
let maxId = 0;
|
|
664
|
+
const stmt = db.prepare(`INSERT INTO events (event_type, ts, ref, metadata_json)
|
|
665
|
+
VALUES (?, ?, ?, ?)
|
|
666
|
+
RETURNING id`);
|
|
667
|
+
db.transaction(() => {
|
|
668
|
+
for (const line of lines) {
|
|
669
|
+
let parsed;
|
|
670
|
+
try {
|
|
671
|
+
parsed = JSON.parse(line);
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
continue; // skip malformed lines — same behaviour as readEvents()
|
|
675
|
+
}
|
|
676
|
+
const eventType = typeof parsed.eventType === "string" ? parsed.eventType : "unknown";
|
|
677
|
+
const ts = typeof parsed.ts === "string" ? parsed.ts : new Date().toISOString();
|
|
678
|
+
const ref = typeof parsed.ref === "string" ? parsed.ref : null;
|
|
679
|
+
const metadata = parsed.metadata !== undefined && typeof parsed.metadata === "object" ? JSON.stringify(parsed.metadata) : "{}";
|
|
680
|
+
const result = stmt.get(eventType, ts, ref, metadata);
|
|
681
|
+
if (result) {
|
|
682
|
+
imported++;
|
|
683
|
+
if (result.id > maxId)
|
|
684
|
+
maxId = result.id;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
})();
|
|
688
|
+
return { imported, maxId };
|
|
689
|
+
}
|
|
690
|
+
// ── registry_index_cache (goes in index.db, not state.db) ───────────────────
|
|
691
|
+
/**
|
|
692
|
+
* DDL for the `registry_index_cache` table that lives in the EXISTING index.db
|
|
693
|
+
* (managed by src/indexer/db.ts).
|
|
694
|
+
*
|
|
695
|
+
* Design: uses the same migration-safe ADD COLUMN approach. The table is
|
|
696
|
+
* created with CREATE TABLE IF NOT EXISTS so it is safe to call inside
|
|
697
|
+
* ensureSchema() or as a standalone migration.
|
|
698
|
+
*
|
|
699
|
+
* Purpose: caches the result of resolving and fetching remote registry kit
|
|
700
|
+
* indexes so `akm search` does not hit the network on every invocation.
|
|
701
|
+
*
|
|
702
|
+
* Indexed (query) columns:
|
|
703
|
+
* registry_url TEXT PK — canonical URL of the registry; cache key.
|
|
704
|
+
* fetched_at TEXT — ISO-8601; used to detect stale entries (TTL).
|
|
705
|
+
* etag TEXT — HTTP ETag for conditional GET (If-None-Match).
|
|
706
|
+
* last_modified TEXT — HTTP Last-Modified for conditional GET.
|
|
707
|
+
*
|
|
708
|
+
* Non-indexed payload:
|
|
709
|
+
* index_json TEXT — JSON blob of the fetched registry index document.
|
|
710
|
+
*
|
|
711
|
+
* ADD COLUMN extension points (future migrations in db.ts):
|
|
712
|
+
* ALTER TABLE registry_index_cache ADD COLUMN schema_version INTEGER DEFAULT 1;
|
|
713
|
+
* ALTER TABLE registry_index_cache ADD COLUMN kit_count INTEGER DEFAULT NULL;
|
|
714
|
+
* ALTER TABLE registry_index_cache ADD COLUMN error_message TEXT DEFAULT NULL;
|
|
715
|
+
*
|
|
716
|
+
* To add this table to index.db, call ensureRegistryIndexCacheSchema(db) from
|
|
717
|
+
* within ensureSchema() in src/indexer/db.ts, or add it as a new CREATE TABLE
|
|
718
|
+
* IF NOT EXISTS block inside the existing ensureSchema() call.
|
|
719
|
+
*/
|
|
720
|
+
export const REGISTRY_INDEX_CACHE_DDL = `
|
|
721
|
+
CREATE TABLE IF NOT EXISTS registry_index_cache (
|
|
722
|
+
registry_url TEXT PRIMARY KEY,
|
|
723
|
+
fetched_at TEXT NOT NULL,
|
|
724
|
+
etag TEXT,
|
|
725
|
+
last_modified TEXT,
|
|
726
|
+
index_json TEXT NOT NULL DEFAULT '{}'
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
CREATE INDEX IF NOT EXISTS idx_registry_cache_fetched
|
|
730
|
+
ON registry_index_cache(fetched_at);
|
|
731
|
+
`;
|