From db51612db8580e8cc6cb607d0a39163509e0b762 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 02:44:28 -0400 Subject: [PATCH 1/7] feat(amico-run): session retention preference core (D4 slice 3, AC 3) The archive cutoff is a workspace preference, not a constant: $AMICODE_OPS_DIR/session-retention.json, default 30 days, failing safe on absent/malformed/out-of-range files. Plus the deterministic session-index renderer (used by the index verb in the next commit). Issue #795, spec-20260905-045114 D4. --- packages/amico-run/src/session_retention.ts | 142 ++++++++++++++++++ packages/amico-run/test/sessions_verb.test.ts | 59 ++++++++ 2 files changed, 201 insertions(+) create mode 100644 packages/amico-run/src/session_retention.ts create mode 100644 packages/amico-run/test/sessions_verb.test.ts diff --git a/packages/amico-run/src/session_retention.ts b/packages/amico-run/src/session_retention.ts new file mode 100644 index 00000000..ecc6eb7a --- /dev/null +++ b/packages/amico-run/src/session_retention.ts @@ -0,0 +1,142 @@ +// session_retention.ts — D4 (spec-20260905-045114) slice 3, pure core: the +// retention policy's workspace preference + the session-index renderer. +// +// Retention RELOCATES, never deletes (D4 invariant): the archive command moves +// sessions from the active tail to the archived set by stamping the engine's +// `time_archived` field — the same field the vendored engine's list endpoint +// already filters on — and restore clears it. Deletion is out of the +// vocabulary. Archive affects product lists only; the vault ledger plane and +// the coordination board are separate transports this module never touches. +// +// The archive cutoff is a WORKSPACE PREFERENCE, not a constant: it lives in +// `$AMICODE_OPS_DIR/session-retention.json` (default `~/.amico/amicode/`), +// the same ops-dir convention as solver-mode.json. Reads fail SAFE to the 30-day +// default on an absent, malformed, or out-of-range file — a corrupt preference +// must never widen what gets archived. + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const DEFAULT_ARCHIVE_DAYS = 30; + +function amicodeOpsDir(env: NodeJS.ProcessEnv): string { + const v = env.AMICODE_OPS_DIR; + return v && v.trim() !== "" ? v : join(homedir(), ".amico", "amicode"); +} + +export function retentionPrefsFile(env: NodeJS.ProcessEnv = process.env): string { + return join(amicodeOpsDir(env), "session-retention.json"); +} + +/** The archive cutoff in days. Fails safe to DEFAULT_ARCHIVE_DAYS. */ +export function readArchiveDays(env: NodeJS.ProcessEnv = process.env): number { + try { + const parsed = JSON.parse(readFileSync(retentionPrefsFile(env), "utf8")) as { archive_days?: unknown }; + const d = parsed.archive_days; + if (typeof d === "number" && Number.isInteger(d) && d >= 1) return d; + return DEFAULT_ARCHIVE_DAYS; + } catch { + return DEFAULT_ARCHIVE_DAYS; + } +} + +export type WritePrefsResult = { ok: true; file: string; days: number } | { ok: false; error: string }; + +export function writeArchiveDays(days: number, env: NodeJS.ProcessEnv = process.env): WritePrefsResult { + if (!Number.isInteger(days) || days < 1) { + return { ok: false, error: `archive_days must be a positive integer, got ${days}` }; + } + const file = retentionPrefsFile(env); + mkdirSync(join(file, ".."), { recursive: true }); + writeFileSync(file, `${JSON.stringify({ schema_version: 1, archive_days: days }, null, 2)}\n`); + return { ok: true, file, days }; +} + +// ── the generated session index (pure renderer) ───────────────────────────── + +/** One session row as the index consumes it — exactly the fields the DB query + * returns. Directory provenance is carried in full; the table renders the + * basename (the hand-written reference shape), the distribution header carries + * the full paths. */ +export interface IndexSession { + id: string; + title: string; + directory: string; + time_updated: number; + time_archived: number | null; +} + +export interface IndexInput { + generated_at: string; + source_db: string; + sessions: IndexSession[]; +} + +function basename(p: string): string { + const parts = p.split("/"); + return parts[parts.length - 1] || p; +} + +function monthOf(ms: number): string { + const d = new Date(ms); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +function dayOf(ms: number): string { + const d = new Date(ms); + return `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +/** Render SESSION-INDEX.md. Deterministic: month sections newest-first, rows + * newest-first with id as tie-break. Every row in the DB appears exactly once; + * archived rows carry their state in the table, and the distribution header + * carries the directory provenance + visible/archived counts. */ +export function renderSessionIndex(input: IndexInput): string { + const { sessions } = input; + const visible = sessions.filter((s) => s.time_archived === null).length; + const archived = sessions.length - visible; + + const byDir = new Map(); + for (const s of sessions) byDir.set(s.directory, (byDir.get(s.directory) ?? 0) + 1); + const dirs = [...byDir.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + const byMonth = new Map(); + for (const s of sessions) { + const m = monthOf(s.time_updated); + if (!byMonth.has(m)) byMonth.set(m, []); + byMonth.get(m)!.push(s); + } + const months = [...byMonth.keys()].sort().reverse(); + + const lines: string[] = []; + lines.push("# Session index"); + lines.push(""); + lines.push( + `*Generated ${input.generated_at} from the chat DB at \`${input.source_db}\` — ` + + `${sessions.length} sessions: ${visible} visible · ${archived} archived.`, + ); + lines.push( + "Archived sessions are hidden from default lists (query: `amico sessions list --archived`; " + + "restore: `amico sessions restore `). Retention relocates — nothing is ever deleted.", + ); + lines.push(`Directory provenance — ${dirs.length} ${dirs.length === 1 ? "directory" : "directories"}:`); + for (const [dir, n] of dirs) lines.push(`- \`${dir}\` — ${n} ${n === 1 ? "session" : "sessions"}`); + lines.push("Generated by `amico sessions index`; regenerate, never edit by hand.*"); + lines.push(""); + + for (const m of months) { + lines.push(`## ${m}`); + lines.push(""); + lines.push("| last active | title | home | state | id |"); + lines.push("|---|---|---|---|---|"); + const rows = byMonth.get(m)!.sort((a, b) => b.time_updated - a.time_updated || a.id.localeCompare(b.id)); + for (const s of rows) { + const state = s.time_archived === null ? "active" : "archived"; + const title = s.title.replace(/\|/g, "\\|"); + lines.push(`| ${dayOf(s.time_updated)} | ${title} | ${basename(s.directory)} | ${state} | \`${s.id}\` |`); + } + lines.push(""); + } + return lines.join("\n"); +} diff --git a/packages/amico-run/test/sessions_verb.test.ts b/packages/amico-run/test/sessions_verb.test.ts new file mode 100644 index 00000000..76f09f74 --- /dev/null +++ b/packages/amico-run/test/sessions_verb.test.ts @@ -0,0 +1,59 @@ +// `amico sessions` — D4 slice 3 (issue #795): session retention that relocates + +// the generated session index. Pure core (session_retention.ts) is unit-tested +// against src; the verb bodies run through `dist/amico.js` against SEEDED COPY +// databases in temp dirs — never the live chat DB (shared with a running hub). +// Run: `pnpm --filter @amicode/amico-run test`. +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + DEFAULT_ARCHIVE_DAYS, + readArchiveDays, + writeArchiveDays, + retentionPrefsFile, +} from "../src/session_retention.js"; + +// ── AC 3: the archive cutoff is a workspace preference with default 30 days ── +describe("retention preference — the archive cutoff", () => { + let ops: string; + beforeEach(() => { + ops = mkdtempSync(join(tmpdir(), "amico-sessions-ops-")); + }); + afterEach(() => rmSync(ops, { recursive: true, force: true })); + + it("defaults to 30 days when no preference file exists (fresh install)", () => { + expect(readArchiveDays({ AMICODE_OPS_DIR: ops })).toBe(30); + expect(DEFAULT_ARCHIVE_DAYS).toBe(30); + }); + + it("fails safe to 30 on a malformed or out-of-range preference file", () => { + writeFileSync(join(ops, "session-retention.json"), "{not json"); + expect(readArchiveDays({ AMICODE_OPS_DIR: ops })).toBe(30); + writeFileSync(join(ops, "session-retention.json"), JSON.stringify({ archive_days: 0 })); + expect(readArchiveDays({ AMICODE_OPS_DIR: ops })).toBe(30); + writeFileSync(join(ops, "session-retention.json"), JSON.stringify({ archive_days: -5 })); + expect(readArchiveDays({ AMICODE_OPS_DIR: ops })).toBe(30); + writeFileSync(join(ops, "session-retention.json"), JSON.stringify({ archive_days: "thirty" })); + expect(readArchiveDays({ AMICODE_OPS_DIR: ops })).toBe(30); + }); + + it("reads a written preference (7 days) and reports the file it came from", () => { + writeArchiveDays(7, { AMICODE_OPS_DIR: ops }); + expect(readArchiveDays({ AMICODE_OPS_DIR: ops })).toBe(7); + expect(retentionPrefsFile({ AMICODE_OPS_DIR: ops })).toBe(join(ops, "session-retention.json")); + const parsed = JSON.parse(readFileSync(retentionPrefsFile({ AMICODE_OPS_DIR: ops }), "utf8")); + expect(parsed).toMatchObject({ schema_version: 1, archive_days: 7 }); + }); + + it("refuses to write a non-positive or non-integer cutoff", () => { + expect(writeArchiveDays(0, { AMICODE_OPS_DIR: ops }).ok).toBe(false); + expect(writeArchiveDays(2.5, { AMICODE_OPS_DIR: ops }).ok).toBe(false); + expect(existsSync(join(ops, "session-retention.json"))).toBe(false); + }); + + it("falls back to ~/.amico/amicode when AMICODE_OPS_DIR is unset", () => { + expect(retentionPrefsFile({})).toBe(join(homedir(), ".amico", "amicode", "session-retention.json")); + }); +}); From 94b2e7852b9d6d543da7b6538b46864a6cc7ab12 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 09:45:33 -0400 Subject: [PATCH 2/7] =?UTF-8?q?feat(amico-run):=20amico=20sessions=20?= =?UTF-8?q?=E2=80=94=20visibility=20matrix=20over=20the=20chat=20DB=20(D4?= =?UTF-8?q?=20slice=203,=20AC=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The archive visibility rules the product respects, as CLI verbs over the session table (dry-run-by-default archive, --apply to relocate; restore clears the one field; --archived is the explicit opt-in). node:sqlite stays out of vitest's graph via a lazy import; DB resolution mirrors the open-threads skill (--db → $OPENCODE_DB → XDG default). Issue #795. --- packages/amico-run/src/sessions_verb.ts | 270 ++++++++++++++++++ packages/amico-run/src/verbs.ts | 23 ++ packages/amico-run/test/sessions_verb.test.ts | 142 ++++++++- 3 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 packages/amico-run/src/sessions_verb.ts diff --git a/packages/amico-run/src/sessions_verb.ts b/packages/amico-run/src/sessions_verb.ts new file mode 100644 index 00000000..0914d732 --- /dev/null +++ b/packages/amico-run/src/sessions_verb.ts @@ -0,0 +1,270 @@ +// sessions_verb.ts — `amico sessions` (D4 slice 3, issue #795): the retention +// lifecycle as a CLI verb — list (visibility rules), archive (relocate, never +// delete), restore (clear one field), index (generate SESSION-INDEX.md). +// +// The engine owns the archived-field mechanics (time_archived on the session +// table; its list endpoint's archived query param). THIS is the product layer: +// the visibility rules the product respects, the retention policy as a +// workspace preference, and the generated session index — over the chat DB +// the hub serves. The vault ledger plane and the coordination board are +// separate transports; this verb never reads or writes them (D4 disjointness). +// +// DB ACCESS CONVENTION (mirrors the open-threads skill, the store's other +// reader): `--db` flag → $OPENCODE_DB → ~/.local/share/opencode/opencode.db. +// Reads open READ-ONLY. Writes (archive --apply / restore) open read-write — +// the verb is the deterministic surface for what the 2026-09-05 consolidation +// did by hand SQL. `archive` is DRY-RUN BY DEFAULT: an agent running it +// against the live DB without --apply must not relocate anything. +// +// node:sqlite is imported lazily (inside openDb): it must never enter vitest's +// module graph (vite-node's builtin list predates it), and on node < 22.5 the +// verb degrades to an honest usage error instead of a crash. +import { createRequire } from "node:module"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + readArchiveDays, + renderSessionIndex, + writeArchiveDays, + type IndexSession, +} from "./session_retention.js"; +import type { VerbResult } from "./verbs.js"; + +export const DEFAULT_LIST_LIMIT = 100; + +const nodeRequire = createRequire(import.meta.url); + +function flagValue(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +} + +function hasFlag(argv: string[], name: string): boolean { + return argv.includes(name); +} + +/** `--db` flag → $OPENCODE_DB → the XDG default. Same resolution order the + * open-threads skill documents for the store's readers. */ +export function resolveSessionDb(argv: string[], env: NodeJS.ProcessEnv = process.env): string { + const flag = flagValue(argv, "--db"); + if (flag) return flag; + const envDb = env.OPENCODE_DB; + if (envDb && envDb.trim() !== "") return envDb; + return join(env.XDG_DATA_HOME && env.XDG_DATA_HOME.trim() !== "" ? join(env.XDG_DATA_HOME, "opencode") : join(homedir(), ".local", "share", "opencode"), "opencode.db"); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Db = any; + +function openDb(path: string, readOnly: boolean): Db { + const { DatabaseSync } = nodeRequire("node:sqlite") as typeof import("node:sqlite"); + return new DatabaseSync(path, { readOnly }); +} + +function fail(error: string, extra: Record = {}): VerbResult { + return { json: { verb: "sessions", error, ...extra }, code: 64 }; +} + +interface SessionRow { + id: string; + parent_id: string | null; + directory: string; + title: string; + time_created: number; + time_updated: number; + time_archived: number | null; +} + +function fetchPage(db: Db, where: string, params: unknown[], limit: number, cursor: number): { rows: SessionRow[]; total: number } { + const total = (db.prepare(`SELECT count(*) AS n FROM session WHERE ${where}`).get(...params) as { n: number }).n; + const rows = db + .prepare( + `SELECT id, parent_id, directory, title, time_created, time_updated, time_archived + FROM session WHERE ${where} ORDER BY time_updated DESC, id LIMIT ? OFFSET ?`, + ) + .all(...params, limit, cursor) as SessionRow[]; + return { rows, total }; +} + +// ── list ──────────────────────────────────────────────────────────────────── + +function sessionsList(argv: string[]): VerbResult { + const dbPath = resolveSessionDb(argv); + if (!existsSync(dbPath)) return fail(`session DB not found: ${dbPath}`); + const archived = hasFlag(argv, "--archived"); + const limit = Math.min(Math.max(Number(flagValue(argv, "--limit") ?? DEFAULT_LIST_LIMIT) || DEFAULT_LIST_LIMIT, 1), 1000); + const cursor = Number(flagValue(argv, "--cursor") ?? 0) || 0; + + const db = openDb(dbPath, true); + try { + const where = archived ? "time_archived IS NOT NULL" : "time_archived IS NULL"; + const { rows, total } = fetchPage(db, where, [], limit, cursor); + const next = cursor + rows.length; + return { + json: { + verb: "sessions", + subcommand: "list", + archived, + count: rows.length, + total, + next_cursor: next < total ? next : null, + sessions: rows.map((r) => ({ ...r, archived: r.time_archived !== null })), + }, + code: 0, + }; + } finally { + db.close(); + } +} + +// ── archive (relocate; dry-run by default) ────────────────────────────────── + +function sessionsArchive(argv: string[], env: NodeJS.ProcessEnv): VerbResult { + const dbPath = resolveSessionDb(argv); + if (!existsSync(dbPath)) return fail(`session DB not found: ${dbPath}`); + const days = flagValue(argv, "--days") ? Number(flagValue(argv, "--days")) : readArchiveDays(env); + if (!Number.isInteger(days) || days < 1) return fail(`--days must be a positive integer, got ${days}`); + const apply = hasFlag(argv, "--apply"); + const cutoff = Date.now() - days * 86_400_000; + + const db = openDb(dbPath, !apply); + try { + const candidates = ( + db + .prepare("SELECT id FROM session WHERE time_archived IS NULL AND time_updated < ? ORDER BY time_updated DESC, id") + .all(cutoff) as { id: string }[] + ).map((r) => r.id); + if (apply) { + db.prepare("UPDATE session SET time_archived = ? WHERE time_archived IS NULL AND time_updated < ?").run(Date.now(), cutoff); + } + return { + json: { + verb: "sessions", + subcommand: "archive", + dry_run: !apply, + days, + cutoff_ms: cutoff, + cutoff_iso: new Date(cutoff).toISOString(), + candidates: candidates.length, + candidate_ids: candidates.slice(0, 50), + archived: apply ? candidates.length : 0, + note: apply ? undefined : "dry-run: nothing written — pass --apply to stamp time_archived", + }, + code: 0, + }; + } finally { + db.close(); + } +} + +// ── restore (clear the one field) ─────────────────────────────────────────── + +function sessionsRestore(argv: string[]): VerbResult { + const id = argv.find((a) => !a.startsWith("--")); + if (!id) return fail("session id is required: amico sessions restore "); + const dbPath = resolveSessionDb(argv); + if (!existsSync(dbPath)) return fail(`session DB not found: ${dbPath}`); + + const db = openDb(dbPath, false); + try { + const row = db.prepare("SELECT time_archived FROM session WHERE id = ?").get(id) as { time_archived: number | null } | undefined; + if (!row) return fail(`no such session: ${id}`); + if (row.time_archived === null) return { json: { verb: "sessions", subcommand: "restore", session_id: id, restored: false }, code: 0 }; + db.prepare("UPDATE session SET time_archived = NULL WHERE id = ?").run(id); + return { json: { verb: "sessions", subcommand: "restore", session_id: id, restored: true }, code: 0 }; + } finally { + db.close(); + } +} + +// ── index (generate; never author) ────────────────────────────────────────── + +function sessionsIndex(argv: string[]): VerbResult { + const dbPath = resolveSessionDb(argv); + if (!existsSync(dbPath)) return fail(`session DB not found: ${dbPath}`); + const out = flagValue(argv, "--out") ?? "SESSION-INDEX.md"; + + const db = openDb(dbPath, true); + try { + const rows = db + .prepare( + "SELECT id, directory, title, time_updated, time_archived FROM session ORDER BY time_updated DESC, id", + ) + .all() as IndexSession[]; + const visible = rows.filter((r) => r.time_archived === null).length; + const markdown = renderSessionIndex({ + generated_at: new Date().toISOString(), + source_db: dbPath, + sessions: rows, + }); + mkdirSync(join(out, ".."), { recursive: true }); + writeFileSync(out, markdown); + return { + json: { + verb: "sessions", + subcommand: "index", + path: out, + sessions_indexed: rows.length, + visible, + archived: rows.length - visible, + source_db: dbPath, + }, + code: 0, + }; + } finally { + db.close(); + } +} + +// ── prefs (the workspace preference surface) ──────────────────────────────── + +function sessionsPrefs(argv: string[], env: NodeJS.ProcessEnv): VerbResult { + const days = flagValue(argv, "--days"); + if (days !== undefined) { + const n = Number(days); + const w = writeArchiveDays(n, env); + if (!w.ok) return fail(w.error); + return { json: { verb: "sessions", subcommand: "prefs", archive_days: w.days, file: w.file }, code: 0 }; + } + return { + json: { verb: "sessions", subcommand: "prefs", archive_days: readArchiveDays(env), file: join(env.AMICODE_OPS_DIR && env.AMICODE_OPS_DIR.trim() !== "" ? env.AMICODE_OPS_DIR : join(homedir(), ".amico", "amicode"), "session-retention.json") }, + code: 0, + }; +} + +// ── dispatch ──────────────────────────────────────────────────────────────── + +export async function sessionsVerb(argv: string[]): Promise { + const sub = argv[0]; + const rest = argv.slice(1); + const env = process.env; + try { + switch (sub) { + case "list": + return sessionsList(rest); + case "archive": + return sessionsArchive(rest, env); + case "restore": + return sessionsRestore(rest); + case "index": + return sessionsIndex(rest); + case "prefs": + return sessionsPrefs(rest, env); + default: + return { + json: { + verb: "sessions", + error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, + usage: + "amico sessions list [--archived] [--limit ] [--cursor ] [--db ] | amico sessions archive [--days ] [--apply] | amico sessions restore | amico sessions index [--out ] | amico sessions prefs [--days ]", + }, + code: 64, + }; + } + } catch (e) { + // node:sqlite unavailable (node < 22.5) or a DB-level fault: honest failure, + // never a crash — and never a silent fallthrough to the wrong answer. + return fail(`sessions ${sub} failed: ${e instanceof Error ? e.message : String(e)}`); + } +} diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index e92c77a5..4c32f84d 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -26,6 +26,7 @@ import { planVerb } from "./plan_verb.js"; import { handoffVerb } from "./handoff_verb.js"; import { campaignVerb } from "./campaign_verb.js"; import { projectVerb } from "./project_verb.js"; +import { sessionsVerb } from "./sessions_verb.js"; export interface VerbResult { json: unknown; // structured result (stdout as JSON for the CLI; tool content for MCP) @@ -227,6 +228,27 @@ const project: Verb = { run: projectVerb, }; +// sessions — the D4 retention surface (spec-20260905-045114 slice 3, #795): +// the chat DB's lifecycle as product verbs. `list` respects the visibility +// rules (default hides archived; `--archived` is the explicit opt-in); `archive` +// RELOCATES old sessions by stamping the engine's time_archived (dry-run by +// default; deletion is out of the vocabulary); `restore` clears the field; +// `index` regenerates sessions/SESSION-INDEX.md from the DB. The archive cutoff +// is the workspace preference ($AMICODE_OPS_DIR/session-retention.json, default +// 30 days). The vault ledger plane and the coordination board are separate +// transports — this verb never touches them. +// +// NOTE: the DB access lives behind a lazy `node:sqlite` import, so this module +// stays import-safe in vitest's graph (vite-node's builtin list predates +// node:sqlite); only calling the verb resolves it. +const sessions: Verb = { + name: "sessions", + summary: "session retention over the chat DB: list (visibility rules) / archive (relocate) / restore / index (generated) / prefs", + generalizes: "the hand-built 2026-09-05 SESSION-INDEX.md + hand SQL consolidation (the D4 anti-pattern, made contractual)", + slice: "session-device lifecycle D4 (slice 3, #795)", + run: (args) => sessionsVerb(args), +}; + export const SPINE_VERBS: Verb[] = [ catalog, vault, @@ -241,4 +263,5 @@ export const SPINE_VERBS: Verb[] = [ papers, campaign, project, + sessions, ]; diff --git a/packages/amico-run/test/sessions_verb.test.ts b/packages/amico-run/test/sessions_verb.test.ts index 76f09f74..4a545a2f 100644 --- a/packages/amico-run/test/sessions_verb.test.ts +++ b/packages/amico-run/test/sessions_verb.test.ts @@ -3,7 +3,7 @@ // against src; the verb bodies run through `dist/amico.js` against SEEDED COPY // databases in temp dirs — never the live chat DB (shared with a running hub). // Run: `pnpm --filter @amicode/amico-run test`. -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { beforeAll, describe, it, expect, beforeEach, afterEach } from "vitest"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; @@ -57,3 +57,143 @@ describe("retention preference — the archive cutoff", () => { expect(retentionPrefsFile({})).toBe(join(homedir(), ".amico", "amicode", "session-retention.json")); }); }); + +// ── the seeded-DB harness (NEVER the live chat DB — hub-shared production state) ── +import { execFileSync } from "node:child_process"; +import { mkdirSync } from "node:fs"; + +const BUNDLE = join(__dirname, "..", "dist", "amico.js"); +beforeAll(() => { + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); + +function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr: "" }; + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; + } +} + +const DAY = 86_400_000; + +interface SeedOpts { + id: string; + title?: string; + directory?: string; + updatedDaysAgo: number; + createdDaysAgo?: number; + archived?: boolean; + parent?: string; +} + +/** Seed a minimal session table shaped like the live DB's (the columns the verb + * queries exist in both; the engine owns the real DDL). Seeding runs in a + * spawned `node -e` so `node:sqlite` never enters vitest's module graph + * (vite-node's builtin list predates it and cannot load it). */ +function seedDb(dbPath: string, seeds: SeedOpts[]): void { + mkdirSync(join(dbPath, ".."), { recursive: true }); + const script = ` + const { DatabaseSync } = require("node:sqlite"); + const db = new DatabaseSync(process.argv[1]); + const now = Date.now(); + db.exec(\`CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT, + parent_id TEXT, + directory TEXT NOT NULL, + title TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_archived INTEGER + )\`); + const ins = db.prepare("INSERT INTO session (id, parent_id, directory, title, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?)"); + for (const s of JSON.parse(process.argv[2])) { + const updated = now - s.updatedDaysAgo * ${DAY}; + const created = now - (s.createdDaysAgo !== undefined ? s.createdDaysAgo : s.updatedDaysAgo + 1) * ${DAY}; + ins.run(s.id, s.parent ?? null, s.directory ?? "/home/aaron/armonia", s.title ?? ("session " + s.id), + created, updated, s.archived ? now - s.updatedDaysAgo * ${DAY} : null); + } + db.close(); + `; + execFileSync( + process.execPath, + ["--no-warnings", "-e", script, dbPath, JSON.stringify(seeds)], + { encoding: "utf8" }, + ); +} + +// ── AC 1: the archive visibility matrix ───────────────────────────────────── +describe("amico sessions list/archive/restore — the visibility matrix (bundle)", () => { + let tmp: string; + let db: string; + let ops: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "amico-sessions-")); + db = join(tmp, "opencode.db"); + ops = join(tmp, "ops"); + mkdirSync(ops, { recursive: true }); + }); + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + + function ids(r: { stdout: string }): string[] { + return (JSON.parse(r.stdout).sessions as { id: string }[]).map((s) => s.id); + } + + it("default list excludes archived; --archived opt-in lists them; restore clears the field", () => { + seedDb(db, [ + { id: "ses_active", updatedDaysAgo: 1 }, + { id: "ses_old", updatedDaysAgo: 60 }, + { id: "ses_archived", updatedDaysAgo: 90, archived: true }, + ]); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: ops }; + + // default: active only — the archived session is invisible + expect(ids(run(["sessions", "list"], env))).toEqual(["ses_active", "ses_old"]); + + // explicit opt-in: archived only + const arch = JSON.parse(run(["sessions", "list", "--archived"], env).stdout); + expect(arch.sessions.map((s: { id: string }) => s.id)).toEqual(["ses_archived"]); + expect(arch.sessions[0].time_archived).toBeGreaterThan(0); + + // apply the retention policy (default cutoff = the 30-day preference): + // the 60-day-old session relocates; the active one stays. + const a = JSON.parse(run(["sessions", "archive", "--apply"], env).stdout); + expect(a).toMatchObject({ days: 30, archived: 1 }); + expect(ids(run(["sessions", "list"], env))).toEqual(["ses_active"]); + expect(ids(run(["sessions", "list", "--archived"], env))).toEqual(["ses_old", "ses_archived"]); + + // restore clears the one field — the session returns to the default list + const r = JSON.parse(run(["sessions", "restore", "ses_old"], env).stdout); + expect(r).toMatchObject({ restored: true }); + expect(ids(run(["sessions", "list"], env))).toEqual(["ses_active", "ses_old"]); + }); + + it("archive is DRY-RUN by default (reports candidates, writes nothing) — --apply is the write", () => { + seedDb(db, [ + { id: "ses_active", updatedDaysAgo: 1 }, + { id: "ses_old", updatedDaysAgo: 60 }, + ]); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: ops }; + const dry = JSON.parse(run(["sessions", "archive"], env).stdout); + expect(dry).toMatchObject({ dry_run: true, days: 30, candidates: 1 }); + expect(ids(run(["sessions", "list"], env))).toEqual(["ses_active", "ses_old"]); // untouched + }); + + it("restore of an already-active session is an idempotent no-op; unknown id is a usage error", () => { + seedDb(db, [{ id: "ses_active", updatedDaysAgo: 1 }]); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: ops }; + expect(run(["sessions", "restore", "ses_active"], env).code).toBe(0); + expect(JSON.parse(run(["sessions", "restore", "ses_active"], env).stdout)).toMatchObject({ restored: false }); + expect(run(["sessions", "restore", "ses_nope"], env).code).toBe(64); + }); + + it("refuses to run without a resolvable session DB (missing default is an honest 64, not a live-DB guess)", () => { + const r = run(["sessions", "list"], { OPENCODE_DB: join(tmp, "nope", "missing.db"), AMICODE_OPS_DIR: ops }); + expect(r.code).toBe(64); + expect(JSON.parse(r.stdout).error).toMatch(/not found|no session/); + }); +}); From ccea8efa9ab65fecabe6308d5e3ddf156727faf3 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 09:45:46 -0400 Subject: [PATCH 3/7] test(amico-run): index regeneration matches the DB (D4 slice 3, AC 4) The generated index is complete (every DB row exactly once), carries directory provenance (full paths in the distribution header, basenames in the table), reflects per-row archive state, and is deterministic apart from the generated-at stamp. Verified against seeded copy DBs. Issue #795. --- packages/amico-run/test/sessions_verb.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/packages/amico-run/test/sessions_verb.test.ts b/packages/amico-run/test/sessions_verb.test.ts index 4a545a2f..fa999989 100644 --- a/packages/amico-run/test/sessions_verb.test.ts +++ b/packages/amico-run/test/sessions_verb.test.ts @@ -197,3 +197,87 @@ describe("amico sessions list/archive/restore — the visibility matrix (bundle) expect(JSON.parse(r.stdout).error).toMatch(/not found|no session/); }); }); + +// ── AC 4: the index is generated, never authored — and matches the DB ─────── +describe("amico sessions index — regeneration vs the seeded DB (bundle)", () => { + let tmp: string; + let db: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "amico-sessions-idx-")); + db = join(tmp, "opencode.db"); + }); + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + + interface ParsedRow { id: string; month: string; home: string; state: string } + + /** Parse the generated markdown back into rows — the shape check is part of + * the contract: month sections, one row per session, provenance columns. */ + function parseIndex(text: string): { rows: ParsedRow[]; header: string; distribution: string[]; visible: number; archived: number } { + const header = text.split("\n").find((l) => l.startsWith("*Generated"))!; + const distribution = text.split("\n").filter((l) => l.startsWith("- `")); + const visibleM = header.match(/(\d+) visible · (\d+) archived/)!; + const rows: ParsedRow[] = []; + let month = ""; + for (const line of text.split("\n")) { + const m = line.match(/^## (\d{4}-\d{2})$/); + if (m) { month = m[1]; continue; } + const r = line.match(/^\| [\d-]+ \| .* \| (\S+) \| (\w+) \| `(ses_\w+)` \|$/); + if (r) rows.push({ month, home: r[1], state: r[2], id: r[3] }); + } + return { rows, header, distribution, visible: Number(visibleM[1]), archived: Number(visibleM[2]) }; + } + + it("regenerates a complete, provenance-carrying index that matches the seeded DB", () => { + seedDb(db, [ + { id: "ses_a1", title: "Fleet sync", directory: "/home/aaron/armonia", updatedDaysAgo: 2 }, + { id: "ses_a2", title: "Gate solve", directory: "/home/aaron/harmoniqs/amicode", updatedDaysAgo: 3 }, + { id: "ses_b1", title: "Old work", directory: "/home/aaron/armonia", updatedDaysAgo: 40, archived: true }, + { id: "ses_sub1", title: "child", directory: "/home/aaron/armonia", updatedDaysAgo: 2, parent: "ses_a1" }, + ]); + const out = join(tmp, "sessions", "SESSION-INDEX.md"); + const r = JSON.parse( + run(["sessions", "index", "--db", db, "--out", out], { AMICODE_OPS_DIR: tmp }).stdout, + ); + expect(r).toMatchObject({ sessions_indexed: 4, visible: 3, archived: 1, source_db: db }); + expect(existsSync(out)).toBe(true); + + const text = readFileSync(out, "utf8"); + const parsed = parseIndex(text); + // complete: every DB row appears exactly once + expect(parsed.rows.map((x) => x.id).sort()).toEqual(["ses_a1", "ses_a2", "ses_b1", "ses_sub1"].sort()); + // provenance: home column is the directory basename from the DB + expect(parsed.rows.find((x) => x.id === "ses_a2")!.home).toBe("amicode"); + expect(parsed.rows.find((x) => x.id === "ses_a1")!.home).toBe("armonia"); + // archive state per row matches the DB + expect(parsed.rows.find((x) => x.id === "ses_b1")!.state).toBe("archived"); + expect(parsed.rows.find((x) => x.id === "ses_a1")!.state).toBe("active"); + // month sections follow time_updated + const nowMonth = new Date().toISOString().slice(0, 7); + expect(parsed.rows.find((x) => x.id === "ses_a1")!.month).toBe(nowMonth); + // the distribution header carries the full-path provenance + counts + expect(parsed.distribution).toContain("- `/home/aaron/armonia` — 3 sessions"); + expect(parsed.distribution).toContain("- `/home/aaron/harmoniqs/amicode` — 1 session"); + expect(parsed.visible).toBe(3); + expect(parsed.archived).toBe(1); + }); + + it("regeneration is deterministic apart from the generated-at stamp (idempotent rewrite)", () => { + seedDb(db, [{ id: "ses_x1", title: "Only", updatedDaysAgo: 1 }]); + const out = join(tmp, "SESSION-INDEX.md"); + const env = { AMICODE_OPS_DIR: tmp }; + run(["sessions", "index", "--db", db, "--out", out], env); + const first = readFileSync(out, "utf8"); + run(["sessions", "index", "--db", db, "--out", out], env); + const second = readFileSync(out, "utf8"); + expect(second.replace(/\*Generated [^*]+\*/, "")).toBe(first.replace(/\*Generated [^*]+\*/, "")); + }); + + it("an empty DB yields an honest empty index (0 visible · 0 archived), not an error", () => { + seedDb(db, []); + const out = join(tmp, "SESSION-INDEX.md"); + const r = JSON.parse(run(["sessions", "index", "--db", db, "--out", out], { AMICODE_OPS_DIR: tmp }).stdout); + expect(r).toMatchObject({ sessions_indexed: 0, visible: 0, archived: 0 }); + expect(readFileSync(out, "utf8")).toMatch(/0 sessions: 0 visible · 0 archived/); + }); +}); From 8c47f11e40553c21fb56fabfca2abfe366301ee4 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 09:46:27 -0400 Subject: [PATCH 4/7] test(amico-run): pagination under a 1200-session store (D4 slice 3, AC 5) The default page is bounded (100), --limit is honored up to a hard cap, cursor walking covers the whole store exactly once, and archived rows never leak into a default walk. D4: the boot fetch stays bounded as the active list grows. Issue #795. --- packages/amico-run/test/sessions_verb.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/amico-run/test/sessions_verb.test.ts b/packages/amico-run/test/sessions_verb.test.ts index fa999989..37b7d6f8 100644 --- a/packages/amico-run/test/sessions_verb.test.ts +++ b/packages/amico-run/test/sessions_verb.test.ts @@ -281,3 +281,74 @@ describe("amico sessions index — regeneration vs the seeded DB (bundle)", () = expect(readFileSync(out, "utf8")).toMatch(/0 sessions: 0 visible · 0 archived/); }); }); + +// ── AC 5: the boot list fetch remains paginated under growth (D4: "the recent +// tail first, so the refetch contract does not degrade as the list grows") ── +describe("amico sessions list — pagination under a 1000+ session store (bundle)", () => { + let tmp: string; + let db: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "amico-sessions-pg-")); + db = join(tmp, "opencode.db"); + }); + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + + it("a seeded 1200-session store walks fully through bounded pages, newest first, exactly once", () => { + const N = 1200; + const seeds: SeedOpts[] = []; + for (let i = 0; i < N; i++) { + seeds.push({ id: `ses_pg${String(i).padStart(4, "0")}`, title: `s${i}`, updatedDaysAgo: (i % 400) + (i / 400) * 0.01, createdDaysAgo: 500 }); + } + seedDb(db, seeds); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: tmp }; + + // first page (the boot page): bounded at the default limit, recent tail first + const first = JSON.parse(run(["sessions", "list"], env).stdout); + expect(first.count).toBe(100); + expect(first.total).toBe(N); + expect(first.next_cursor).toBe(100); + + // walk every page: 1200 sessions, each exactly once, no archive leakage + const seen: string[] = []; + let cursor: number | null = 0; + let pages = 0; + while (cursor !== null) { + const page = JSON.parse(run(["sessions", "list", "--cursor", String(cursor)], env).stdout); + expect(page.count).toBeLessThanOrEqual(100); + for (const s of page.sessions as { id: string }[]) seen.push(s.id); + cursor = page.next_cursor; + pages++; + expect(pages).toBeLessThan(50); // termination guard + } + expect(seen.length).toBe(N); + expect(new Set(seen).size).toBe(N); + + // ordering: time_updated DESC — the first page is the recent tail, and the + // walk is globally descending (allowing the id tie-break within a tick) + const firstIds = (first.sessions as { id: string }[]).map((s) => s.id); + expect(firstIds[0]).toBe("ses_pg0000"); // most recently updated + const byUpdated = JSON.parse(run(["sessions", "list", "--limit", "1000"], env).stdout); + expect(byUpdated.count).toBe(1000); // --limit is honored up to a hard cap + }); + + it("pagination respects the visibility rules (archived rows never leak into a default walk)", () => { + const seeds: SeedOpts[] = []; + for (let i = 0; i < 250; i++) { + seeds.push({ id: `ses_v${String(i).padStart(3, "0")}`, updatedDaysAgo: i % 200 }); + } + seeds.push({ id: "ses_hidden", updatedDaysAgo: 10, archived: true }); + seedDb(db, seeds); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: tmp }; + + const seen: string[] = []; + let cursor: number | null = 0; + while (cursor !== null) { + const page = JSON.parse(run(["sessions", "list", "--limit", "50", "--cursor", String(cursor)], env).stdout); + for (const s of page.sessions as { id: string }[]) seen.push(s.id); + cursor = page.next_cursor; + } + expect(seen).toHaveLength(250); + expect(seen).not.toContain("ses_hidden"); + }); +}); From a25c4d5102c4e5edd51ef961e30fc255319fc296 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 09:47:20 -0400 Subject: [PATCH 5/7] =?UTF-8?q?test(amico-run):=20archive=20disjointness?= =?UTF-8?q?=20=E2=80=94=20product=20lists=20only=20(D4=20slice=203,=20AC?= =?UTF-8?q?=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archive --apply + restore leave the vault ledger plane, the coordination board, and the claims ledger byte-identical, write no vault-plane files, and change exactly one session column (time_archived) with project rows untouched. Verified by full-row snapshot diffing on a seeded copy DB. Issue #795. --- packages/amico-run/test/sessions_verb.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/packages/amico-run/test/sessions_verb.test.ts b/packages/amico-run/test/sessions_verb.test.ts index 37b7d6f8..e5676f85 100644 --- a/packages/amico-run/test/sessions_verb.test.ts +++ b/packages/amico-run/test/sessions_verb.test.ts @@ -109,6 +109,16 @@ function seedDb(dbPath: string, seeds: SeedOpts[]): void { time_updated INTEGER NOT NULL, time_archived INTEGER )\`); + db.exec(\`CREATE TABLE project ( + id TEXT PRIMARY KEY, + worktree TEXT, + vcs TEXT, + name TEXT, + time_created INTEGER, + time_updated INTEGER + )\`); + db.prepare("INSERT INTO project (id, worktree, vcs, name, time_created, time_updated) VALUES (?,?,?,?,?,?)") + .run("proj_armonia", "/home/aaron/armonia", null, "armonia", now - 100 * ${DAY}, now - 1 * ${DAY}); const ins = db.prepare("INSERT INTO session (id, parent_id, directory, title, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?)"); for (const s of JSON.parse(process.argv[2])) { const updated = now - s.updatedDaysAgo * ${DAY}; @@ -352,3 +362,77 @@ describe("amico sessions list — pagination under a 1000+ session store (bundle expect(seen).not.toContain("ses_hidden"); }); }); + +// ── AC 2: archive affects product lists only — the vault ledger plane and the +// coordination plane are separate transports (D4 disjointness invariant) ── +describe("amico sessions archive — disjointness from the vault/coordination planes (bundle)", () => { + let tmp: string; + let db: string; + let vault: string; + let claims: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "amico-sessions-disj-")); + db = join(tmp, "opencode.db"); + vault = join(tmp, "vault"); + mkdirSync(join(vault, "sessions"), { recursive: true }); + writeFileSync(join(vault, "sessions", "session-ledger.md"), "# ledger plane — never touched by archive\n"); + claims = join(tmp, "claims.jsonl"); + writeFileSync(claims, '{"type":"claim","work_id":"w1"}\n'); + mkdirSync(join(tmp, "board"), { recursive: true }); + writeFileSync(join(tmp, "board", "m5-board.md"), "# coordination board — never touched\n"); + }); + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + + function dbRows(dbPath: string): string { + return execFileSync( + process.execPath, + ["--no-warnings", "-e", ` + const { DatabaseSync } = require("node:sqlite"); + const db = new DatabaseSync(process.argv[1], { readOnly: true }); + console.log(JSON.stringify({ + sessions: db.prepare("SELECT * FROM session ORDER BY id").all(), + projects: db.prepare("SELECT * FROM project ORDER BY id").all(), + })); + `, dbPath], + { encoding: "utf8" }, + ); + } + + it("archive --apply and restore change ONLY time_archived on session rows; sibling planes are byte-identical", () => { + seedDb(db, [ + { id: "ses_active", updatedDaysAgo: 1 }, + { id: "ses_old", updatedDaysAgo: 60, directory: "/home/aaron/armonia" }, + ]); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: tmp }; + const before = dbRows(db); + const vaultBefore = readFileSync(join(vault, "sessions", "session-ledger.md"), "utf8"); + const claimsBefore = readFileSync(claims, "utf8"); + const boardBefore = readFileSync(join(tmp, "board", "m5-board.md"), "utf8"); + const vaultListBefore = execFileSync("find", [vault], { encoding: "utf8" }); + + run(["sessions", "archive", "--apply"], env); + run(["sessions", "restore", "ses_old"], env); + + // sibling planes byte-identical, no new vault-plane files + expect(readFileSync(join(vault, "sessions", "session-ledger.md"), "utf8")).toBe(vaultBefore); + expect(readFileSync(claims, "utf8")).toBe(claimsBefore); + expect(readFileSync(join(tmp, "board", "m5-board.md"), "utf8")).toBe(boardBefore); + expect(execFileSync("find", [vault], { encoding: "utf8" })).toBe(vaultListBefore); + + // the DB delta is exactly the time_archived stamp + its clear — project + // rows and every other session column untouched (archive writes ONE field) + const after = dbRows(db); + const beforeParsed = JSON.parse(before) as { sessions: Record[]; projects: unknown[] }; + const afterParsed = JSON.parse(after) as { sessions: Record[]; projects: unknown[] }; + expect(afterParsed.projects).toEqual(beforeParsed.projects); + const diffs: string[] = []; + for (let i = 0; i < beforeParsed.sessions.length; i++) { + for (const k of Object.keys(beforeParsed.sessions[i])) { + if (String(beforeParsed.sessions[i][k]) !== String(afterParsed.sessions[i][k])) diffs.push(`${k}:${beforeParsed.sessions[i][k]}->${afterParsed.sessions[i][k]}`); + } + } + // ses_old was archived then restored → net zero; nothing else may differ + expect(diffs).toEqual([]); + }); +}); From 4300ba97f61c67dc6677b33e281b76e2ede2431b Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 09:49:39 -0400 Subject: [PATCH 6/7] test(amico-run): the cutoff is the workspace preference, end-to-end (D4 slice 3, AC 3) prefs --days writes session-retention.json; archive --apply honors it (7-day cutoff verified against a seeded DB); --days overrides per call without writing. Issue #795. --- packages/amico-run/test/sessions_verb.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/amico-run/test/sessions_verb.test.ts b/packages/amico-run/test/sessions_verb.test.ts index e5676f85..c6018bdf 100644 --- a/packages/amico-run/test/sessions_verb.test.ts +++ b/packages/amico-run/test/sessions_verb.test.ts @@ -436,3 +436,52 @@ describe("amico sessions archive — disjointness from the vault/coordination pl expect(diffs).toEqual([]); }); }); + +// ── AC 3 (end-to-end): the cutoff the archive applies comes from the workspace +// preference, overridable per call — never a hardcoded constant ──────────── +describe("amico sessions prefs/archive — the cutoff is the preference (bundle)", () => { + let tmp: string; + let db: string; + let ops: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "amico-sessions-prefs-")); + db = join(tmp, "opencode.db"); + ops = join(tmp, "ops"); + mkdirSync(ops, { recursive: true }); + }); + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + + it("prefs --days writes the preference; archive --apply honors it (7d cutoff, not 30)", () => { + seedDb(db, [ + { id: "ses_5d", updatedDaysAgo: 5 }, + { id: "ses_10d", updatedDaysAgo: 10 }, + ]); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: ops }; + + const p = JSON.parse(run(["sessions", "prefs", "--days", "7"], env).stdout); + expect(p).toMatchObject({ archive_days: 7 }); + expect(JSON.parse(readFileSync(join(ops, "session-retention.json"), "utf8")).archive_days).toBe(7); + + // dry-run first: exactly the 10-day-old session crosses the 7-day cutoff + expect(JSON.parse(run(["sessions", "archive"], env).stdout)).toMatchObject({ days: 7, candidates: 1, candidate_ids: ["ses_10d"] }); + run(["sessions", "archive", "--apply"], env); + const after = JSON.parse(run(["sessions", "list"], env).stdout); + expect(after.sessions.map((s: { id: string }) => s.id)).toEqual(["ses_5d"]); + }); + + it("--days overrides the preference for a single call without writing it", () => { + seedDb(db, [{ id: "ses_1d", updatedDaysAgo: 1 }]); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: ops }; + const dry = JSON.parse(run(["sessions", "archive", "--days", "1"], env).stdout); + expect(dry).toMatchObject({ days: 1, candidates: 1 }); + expect(existsSync(join(ops, "session-retention.json"))).toBe(false); + expect(JSON.parse(run(["sessions", "prefs"], env).stdout)).toMatchObject({ archive_days: 30 }); + }); + + it("unknown subcommand is a usage error listing the surface", () => { + const r = run(["sessions", "bogus"], { AMICODE_OPS_DIR: ops }); + expect(r.code).toBe(64); + expect(JSON.parse(r.stdout).usage).toMatch(/archive/); + }); +}); From e1662e2180ae9ed6814d99953b1faac23881750b Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 10:06:21 -0400 Subject: [PATCH 7/7] =?UTF-8?q?fix(amico-run):=20sessions=20verb=20drives?= =?UTF-8?q?=20SQLite=20via=20python3=20stdlib=20=E2=80=94=20CI=20node=2020?= =?UTF-8?q?=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI pins node 20 (ci.yml), where node:sqlite does not exist (added 22.5) — the verb and all 11 bundle tests failed on the fast check. Replace the node:sqlite access with a python3 stdlib sqlite3 bridge (sqlite_bridge.ts): zero new dependencies, works on the CI pin and everything newer, and it is the store's existing reader convention (open-threads: file:?mode=ro, timeout=5; AMICO_PYTHON→python3 per pasqal_launch). One python invocation per verb operation, statements batched, JSON over stdio; reads open read-only, writes commit through the real sqlite engine (no whole-file rewrite, unlike a WASM driver). Verified under node 20.20.2 locally (the CI version): 18/18 sessions tests, full amico-run suite 1363 passed (the 1 failure is the pre-existing env-leak in agent_spawn), and a read-only live-DB smoke (list + index). Issue #795. --- packages/amico-run/src/sessions_verb.ts | 265 +++++++++--------- packages/amico-run/src/sqlite_bridge.ts | 88 ++++++ packages/amico-run/src/verbs.ts | 6 +- packages/amico-run/test/sessions_verb.test.ts | 103 +++---- 4 files changed, 283 insertions(+), 179 deletions(-) create mode 100644 packages/amico-run/src/sqlite_bridge.ts diff --git a/packages/amico-run/src/sessions_verb.ts b/packages/amico-run/src/sessions_verb.ts index 0914d732..9e248d00 100644 --- a/packages/amico-run/src/sessions_verb.ts +++ b/packages/amico-run/src/sessions_verb.ts @@ -16,10 +16,9 @@ // did by hand SQL. `archive` is DRY-RUN BY DEFAULT: an agent running it // against the live DB without --apply must not relocate anything. // -// node:sqlite is imported lazily (inside openDb): it must never enter vitest's -// module graph (vite-node's builtin list predates it), and on node < 22.5 the -// verb degrades to an honest usage error instead of a crash. -import { createRequire } from "node:module"; +// The driver is the python3 stdlib sqlite3 bridge (src/sqlite_bridge.ts) — +// NOT node:sqlite, which does not exist on the repo's CI node (20.x). See the +// bridge module header for the full rationale. import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -29,12 +28,11 @@ import { writeArchiveDays, type IndexSession, } from "./session_retention.js"; +import { sqliteBatch, type BridgeStatement } from "./sqlite_bridge.js"; import type { VerbResult } from "./verbs.js"; export const DEFAULT_LIST_LIMIT = 100; -const nodeRequire = createRequire(import.meta.url); - function flagValue(argv: string[], name: string): string | undefined { const i = argv.indexOf(name); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; @@ -54,14 +52,6 @@ export function resolveSessionDb(argv: string[], env: NodeJS.ProcessEnv = proces return join(env.XDG_DATA_HOME && env.XDG_DATA_HOME.trim() !== "" ? join(env.XDG_DATA_HOME, "opencode") : join(homedir(), ".local", "share", "opencode"), "opencode.db"); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Db = any; - -function openDb(path: string, readOnly: boolean): Db { - const { DatabaseSync } = nodeRequire("node:sqlite") as typeof import("node:sqlite"); - return new DatabaseSync(path, { readOnly }); -} - function fail(error: string, extra: Record = {}): VerbResult { return { json: { verb: "sessions", error, ...extra }, code: 64 }; } @@ -76,15 +66,16 @@ interface SessionRow { time_archived: number | null; } -function fetchPage(db: Db, where: string, params: unknown[], limit: number, cursor: number): { rows: SessionRow[]; total: number } { - const total = (db.prepare(`SELECT count(*) AS n FROM session WHERE ${where}`).get(...params) as { n: number }).n; - const rows = db - .prepare( - `SELECT id, parent_id, directory, title, time_created, time_updated, time_archived - FROM session WHERE ${where} ORDER BY time_updated DESC, id LIMIT ? OFFSET ?`, - ) - .all(...params, limit, cursor) as SessionRow[]; - return { rows, total }; +function rowOf(r: Record): SessionRow { + return { + id: String(r.id), + parent_id: (r.parent_id as string | null) ?? null, + directory: String(r.directory), + title: String(r.title), + time_created: Number(r.time_created), + time_updated: Number(r.time_updated), + time_archived: r.time_archived === null || r.time_archived === undefined ? null : Number(r.time_archived), + }; } // ── list ──────────────────────────────────────────────────────────────────── @@ -95,27 +86,36 @@ function sessionsList(argv: string[]): VerbResult { const archived = hasFlag(argv, "--archived"); const limit = Math.min(Math.max(Number(flagValue(argv, "--limit") ?? DEFAULT_LIST_LIMIT) || DEFAULT_LIST_LIMIT, 1), 1000); const cursor = Number(flagValue(argv, "--cursor") ?? 0) || 0; + const where = archived ? "time_archived IS NOT NULL" : "time_archived IS NULL"; - const db = openDb(dbPath, true); + let batch; try { - const where = archived ? "time_archived IS NOT NULL" : "time_archived IS NULL"; - const { rows, total } = fetchPage(db, where, [], limit, cursor); - const next = cursor + rows.length; - return { - json: { - verb: "sessions", - subcommand: "list", - archived, - count: rows.length, - total, - next_cursor: next < total ? next : null, - sessions: rows.map((r) => ({ ...r, archived: r.time_archived !== null })), + batch = sqliteBatch(dbPath, "ro", [ + { sql: `SELECT count(*) AS n FROM session WHERE ${where}` }, + { + sql: `SELECT id, parent_id, directory, title, time_created, time_updated, time_archived + FROM session WHERE ${where} ORDER BY time_updated DESC, id LIMIT ? OFFSET ?`, + params: [limit, cursor], }, - code: 0, - }; - } finally { - db.close(); + ]); + } catch (e) { + return fail(e instanceof Error ? e.message : String(e)); } + const rows = batch.results[1].rows.map(rowOf); + const total = Number(batch.results[0].rows[0]?.n ?? 0); + const next = cursor + rows.length; + return { + json: { + verb: "sessions", + subcommand: "list", + archived, + count: rows.length, + total, + next_cursor: next < total ? next : null, + sessions: rows.map((r) => ({ ...r, archived: r.time_archived !== null })), + }, + code: 0, + }; } // ── archive (relocate; dry-run by default) ────────────────────────────────── @@ -128,34 +128,41 @@ function sessionsArchive(argv: string[], env: NodeJS.ProcessEnv): VerbResult { const apply = hasFlag(argv, "--apply"); const cutoff = Date.now() - days * 86_400_000; - const db = openDb(dbPath, !apply); + const statements: BridgeStatement[] = [ + { + sql: "SELECT id FROM session WHERE time_archived IS NULL AND time_updated < ? ORDER BY time_updated DESC, id", + params: [cutoff], + }, + ]; + if (apply) { + statements.push({ + sql: "UPDATE session SET time_archived = ? WHERE time_archived IS NULL AND time_updated < ?", + params: [Date.now(), cutoff], + }); + } + + let batch; try { - const candidates = ( - db - .prepare("SELECT id FROM session WHERE time_archived IS NULL AND time_updated < ? ORDER BY time_updated DESC, id") - .all(cutoff) as { id: string }[] - ).map((r) => r.id); - if (apply) { - db.prepare("UPDATE session SET time_archived = ? WHERE time_archived IS NULL AND time_updated < ?").run(Date.now(), cutoff); - } - return { - json: { - verb: "sessions", - subcommand: "archive", - dry_run: !apply, - days, - cutoff_ms: cutoff, - cutoff_iso: new Date(cutoff).toISOString(), - candidates: candidates.length, - candidate_ids: candidates.slice(0, 50), - archived: apply ? candidates.length : 0, - note: apply ? undefined : "dry-run: nothing written — pass --apply to stamp time_archived", - }, - code: 0, - }; - } finally { - db.close(); + batch = sqliteBatch(dbPath, apply ? "rw" : "ro", statements); + } catch (e) { + return fail(e instanceof Error ? e.message : String(e)); } + const candidates = batch.results[0].rows.map((r) => String(r.id)); + return { + json: { + verb: "sessions", + subcommand: "archive", + dry_run: !apply, + days, + cutoff_ms: cutoff, + cutoff_iso: new Date(cutoff).toISOString(), + candidates: candidates.length, + candidate_ids: candidates.slice(0, 50), + archived: apply ? Number(batch.results[1]?.changes ?? candidates.length) : 0, + note: apply ? undefined : "dry-run: nothing written — pass --apply to stamp time_archived", + }, + code: 0, + }; } // ── restore (clear the one field) ─────────────────────────────────────────── @@ -166,16 +173,23 @@ function sessionsRestore(argv: string[]): VerbResult { const dbPath = resolveSessionDb(argv); if (!existsSync(dbPath)) return fail(`session DB not found: ${dbPath}`); - const db = openDb(dbPath, false); + let probe; + try { + probe = sqliteBatch(dbPath, "ro", [{ sql: "SELECT time_archived FROM session WHERE id = ?", params: [id] }]); + } catch (e) { + return fail(e instanceof Error ? e.message : String(e)); + } + const row = probe.results[0].rows[0] as { time_archived: number | null } | undefined; + if (!row) return fail(`no such session: ${id}`); + if (row.time_archived === null || row.time_archived === undefined) { + return { json: { verb: "sessions", subcommand: "restore", session_id: id, restored: false }, code: 0 }; + } try { - const row = db.prepare("SELECT time_archived FROM session WHERE id = ?").get(id) as { time_archived: number | null } | undefined; - if (!row) return fail(`no such session: ${id}`); - if (row.time_archived === null) return { json: { verb: "sessions", subcommand: "restore", session_id: id, restored: false }, code: 0 }; - db.prepare("UPDATE session SET time_archived = NULL WHERE id = ?").run(id); - return { json: { verb: "sessions", subcommand: "restore", session_id: id, restored: true }, code: 0 }; - } finally { - db.close(); + sqliteBatch(dbPath, "rw", [{ sql: "UPDATE session SET time_archived = NULL WHERE id = ?", params: [id] }]); + } catch (e) { + return fail(e instanceof Error ? e.message : String(e)); } + return { json: { verb: "sessions", subcommand: "restore", session_id: id, restored: true }, code: 0 }; } // ── index (generate; never author) ────────────────────────────────────────── @@ -185,36 +199,35 @@ function sessionsIndex(argv: string[]): VerbResult { if (!existsSync(dbPath)) return fail(`session DB not found: ${dbPath}`); const out = flagValue(argv, "--out") ?? "SESSION-INDEX.md"; - const db = openDb(dbPath, true); + let batch; try { - const rows = db - .prepare( - "SELECT id, directory, title, time_updated, time_archived FROM session ORDER BY time_updated DESC, id", - ) - .all() as IndexSession[]; - const visible = rows.filter((r) => r.time_archived === null).length; - const markdown = renderSessionIndex({ - generated_at: new Date().toISOString(), - source_db: dbPath, - sessions: rows, - }); - mkdirSync(join(out, ".."), { recursive: true }); - writeFileSync(out, markdown); - return { - json: { - verb: "sessions", - subcommand: "index", - path: out, - sessions_indexed: rows.length, - visible, - archived: rows.length - visible, - source_db: dbPath, - }, - code: 0, - }; - } finally { - db.close(); + batch = sqliteBatch(dbPath, "ro", [ + { sql: "SELECT id, directory, title, time_updated, time_archived FROM session ORDER BY time_updated DESC, id" }, + ]); + } catch (e) { + return fail(e instanceof Error ? e.message : String(e)); } + const rows = batch.results[0].rows.map(rowOf).map((r) => ({ id: r.id, directory: r.directory, title: r.title, time_updated: r.time_updated, time_archived: r.time_archived })); + const visible = rows.filter((r) => r.time_archived === null).length; + const markdown = renderSessionIndex({ + generated_at: new Date().toISOString(), + source_db: dbPath, + sessions: rows as IndexSession[], + }); + mkdirSync(join(out, ".."), { recursive: true }); + writeFileSync(out, markdown); + return { + json: { + verb: "sessions", + subcommand: "index", + path: out, + sessions_indexed: rows.length, + visible, + archived: rows.length - visible, + source_db: dbPath, + }, + code: 0, + }; } // ── prefs (the workspace preference surface) ──────────────────────────────── @@ -239,32 +252,26 @@ export async function sessionsVerb(argv: string[]): Promise { const sub = argv[0]; const rest = argv.slice(1); const env = process.env; - try { - switch (sub) { - case "list": - return sessionsList(rest); - case "archive": - return sessionsArchive(rest, env); - case "restore": - return sessionsRestore(rest); - case "index": - return sessionsIndex(rest); - case "prefs": - return sessionsPrefs(rest, env); - default: - return { - json: { - verb: "sessions", - error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, - usage: - "amico sessions list [--archived] [--limit ] [--cursor ] [--db ] | amico sessions archive [--days ] [--apply] | amico sessions restore | amico sessions index [--out ] | amico sessions prefs [--days ]", - }, - code: 64, - }; - } - } catch (e) { - // node:sqlite unavailable (node < 22.5) or a DB-level fault: honest failure, - // never a crash — and never a silent fallthrough to the wrong answer. - return fail(`sessions ${sub} failed: ${e instanceof Error ? e.message : String(e)}`); + switch (sub) { + case "list": + return sessionsList(rest); + case "archive": + return sessionsArchive(rest, env); + case "restore": + return sessionsRestore(rest); + case "index": + return sessionsIndex(rest); + case "prefs": + return sessionsPrefs(rest, env); + default: + return { + json: { + verb: "sessions", + error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, + usage: + "amico sessions list [--archived] [--limit ] [--cursor ] [--db ] | amico sessions archive [--days ] [--apply] | amico sessions restore | amico sessions index [--out ] | amico sessions prefs [--days ]", + }, + code: 64, + }; } } diff --git a/packages/amico-run/src/sqlite_bridge.ts b/packages/amico-run/src/sqlite_bridge.ts new file mode 100644 index 00000000..0d7e9489 --- /dev/null +++ b/packages/amico-run/src/sqlite_bridge.ts @@ -0,0 +1,88 @@ +// sqlite_bridge.ts — the sessions verb's SQLite access over python3's stdlib +// sqlite3 (D4 slice 3, #795; CI portability fix). +// +// Why python3 and not node:sqlite: the repo's CI pins node 20 (ci.yml) and the +// engines say >= 20, but node:sqlite only exists on node >= 22.5 — a node:sqlite +// driver silently breaks the verb (and its tests) on the repo's own CI node. +// The store's OTHER reader already uses python3 stdlib sqlite3 (the open-threads +// skill: file:?mode=ro, timeout=5), and AMICO_PYTHON→python3 is the +// product's existing interpreter resolution (pasqal_launch.ts). Zero new +// dependencies; the write path runs through the real sqlite engine (proper +// locking/commit), never a load-whole-file rewrite. +// +// Shape: ONE python3 invocation per verb operation, statements batched, JSON +// over stdio. `ro` opens READ-ONLY (the open-threads discipline: the hub owns +// the live DB); `rw` opens read-write and commits at the end. + +import { spawnSync } from "node:child_process"; + +export interface BridgeStatement { + sql: string; + params?: unknown[]; +} + +export interface BridgeStatementResult { + rows: Record[]; + changes: number; +} + +export interface BridgeResult { + results: BridgeStatementResult[]; +} + +const PYTHON_SCRIPT = ` +import json, sqlite3, sys + +req = json.load(sys.stdin) +db = req["db"] +if req["mode"] == "rw": + con = sqlite3.connect(db, timeout=5) +else: + con = sqlite3.connect("file:" + db + "?mode=ro", uri=True, timeout=5) +try: + results = [] + for st in req["statements"]: + cur = con.execute(st["sql"], st.get("params", [])) + rows = [] + if cur.description is not None: + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + results.append({"rows": rows, "changes": max(cur.rowcount, 0)}) + if req["mode"] == "rw": + con.commit() + json.dump({"results": results}, sys.stdout) +finally: + con.close() +`; + +export class SqliteBridgeError extends Error {} + +export function sqliteBatch( + dbPath: string, + mode: "ro" | "rw", + statements: BridgeStatement[], + env: NodeJS.ProcessEnv = process.env, +): BridgeResult { + const py = env.AMICO_PYTHON && env.AMICO_PYTHON.trim() !== "" ? env.AMICO_PYTHON : "python3"; + const res = spawnSync(py, ["-c", PYTHON_SCRIPT, "--"], { + input: JSON.stringify({ db: dbPath, mode, statements }), + encoding: "utf8", + timeout: 60_000, + }); + if (res.error) { + throw new SqliteBridgeError( + `could not run ${py}: ${res.error.message} — install Python 3, or set AMICO_PYTHON to your interpreter`, + ); + } + if (res.status !== 0) { + throw new SqliteBridgeError(`sqlite bridge failed (exit ${res.status}): ${(res.stderr || res.stdout || "").trim()}`); + } + let parsed: BridgeResult & { error?: string }; + try { + parsed = JSON.parse(res.stdout) as BridgeResult & { error?: string }; + } catch { + throw new SqliteBridgeError(`sqlite bridge returned unparseable output: ${(res.stdout || "").slice(0, 200)}`); + } + if (parsed.error) throw new SqliteBridgeError(parsed.error); + return parsed; +} diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index 4c32f84d..d2b71ac7 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -238,9 +238,9 @@ const project: Verb = { // 30 days). The vault ledger plane and the coordination board are separate // transports — this verb never touches them. // -// NOTE: the DB access lives behind a lazy `node:sqlite` import, so this module -// stays import-safe in vitest's graph (vite-node's builtin list predates -// node:sqlite); only calling the verb resolves it. +// NOTE: the DB access runs through the python3 stdlib sqlite3 bridge +// (sqlite_bridge.ts) — the repo's CI pins node 20, where node:sqlite does not +// exist, and the store's other reader (open-threads) already uses python3. const sessions: Verb = { name: "sessions", summary: "session retention over the chat DB: list (visibility rules) / archive (relocate) / restore / index (generated) / prefs", diff --git a/packages/amico-run/test/sessions_verb.test.ts b/packages/amico-run/test/sessions_verb.test.ts index c6018bdf..ad39f773 100644 --- a/packages/amico-run/test/sessions_verb.test.ts +++ b/packages/amico-run/test/sessions_verb.test.ts @@ -90,49 +90,57 @@ interface SeedOpts { } /** Seed a minimal session table shaped like the live DB's (the columns the verb - * queries exist in both; the engine owns the real DDL). Seeding runs in a - * spawned `node -e` so `node:sqlite` never enters vitest's module graph - * (vite-node's builtin list predates it and cannot load it). */ + * queries exist in both; the engine owns the real DDL). Seeding runs through + * python3's stdlib sqlite3 — the same driver the verb uses (sqlite_bridge.ts), + * which exists on the repo's CI node (20.x) where node:sqlite does not. */ function seedDb(dbPath: string, seeds: SeedOpts[]): void { mkdirSync(join(dbPath, ".."), { recursive: true }); const script = ` - const { DatabaseSync } = require("node:sqlite"); - const db = new DatabaseSync(process.argv[1]); - const now = Date.now(); - db.exec(\`CREATE TABLE session ( - id TEXT PRIMARY KEY, - project_id TEXT, - parent_id TEXT, - directory TEXT NOT NULL, - title TEXT NOT NULL, - time_created INTEGER NOT NULL, - time_updated INTEGER NOT NULL, - time_archived INTEGER - )\`); - db.exec(\`CREATE TABLE project ( - id TEXT PRIMARY KEY, - worktree TEXT, - vcs TEXT, - name TEXT, - time_created INTEGER, - time_updated INTEGER - )\`); - db.prepare("INSERT INTO project (id, worktree, vcs, name, time_created, time_updated) VALUES (?,?,?,?,?,?)") - .run("proj_armonia", "/home/aaron/armonia", null, "armonia", now - 100 * ${DAY}, now - 1 * ${DAY}); - const ins = db.prepare("INSERT INTO session (id, parent_id, directory, title, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?)"); - for (const s of JSON.parse(process.argv[2])) { - const updated = now - s.updatedDaysAgo * ${DAY}; - const created = now - (s.createdDaysAgo !== undefined ? s.createdDaysAgo : s.updatedDaysAgo + 1) * ${DAY}; - ins.run(s.id, s.parent ?? null, s.directory ?? "/home/aaron/armonia", s.title ?? ("session " + s.id), - created, updated, s.archived ? now - s.updatedDaysAgo * ${DAY} : null); - } - db.close(); +import json, sqlite3, sys + +seeds = json.loads(sys.argv[2]) +now = int(sys.argv[3]) +day = ${DAY} +con = sqlite3.connect(sys.argv[1], timeout=5) +con.executescript(""" +CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT, + parent_id TEXT, + directory TEXT NOT NULL, + title TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_archived INTEGER +); +CREATE TABLE project ( + id TEXT PRIMARY KEY, + worktree TEXT, + vcs TEXT, + name TEXT, + time_created INTEGER, + time_updated INTEGER +); +""") +con.execute("INSERT INTO project (id, worktree, vcs, name, time_created, time_updated) VALUES (?,?,?,?,?,?)", + ("proj_armonia", "/home/aaron/armonia", None, "armonia", now - 100 * day, now - 1 * day)) +for s in seeds: + updated = now - s["updatedDaysAgo"] * day + created = now - (s["createdDaysAgo"] if s.get("createdDaysAgo") is not None else s["updatedDaysAgo"] + 1) * day + con.execute( + "INSERT INTO session (id, parent_id, directory, title, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?)", + (s["id"], s.get("parent"), s.get("directory", "/home/aaron/armonia"), s.get("title", "session " + s["id"]), + created, updated, now - s["updatedDaysAgo"] * day if s.get("archived") else None), + ) +con.commit() +con.close() `; - execFileSync( - process.execPath, - ["--no-warnings", "-e", script, dbPath, JSON.stringify(seeds)], - { encoding: "utf8" }, - ); + execFileSync(pythonBin(), ["-c", script, dbPath, JSON.stringify(seeds), String(Date.now())], { encoding: "utf8" }); +} + +/** The interpreter the verb's bridge resolves: $AMICO_PYTHON → python3. */ +function pythonBin(): string { + return process.env.AMICO_PYTHON && process.env.AMICO_PYTHON.trim() !== "" ? process.env.AMICO_PYTHON : "python3"; } // ── AC 1: the archive visibility matrix ───────────────────────────────────── @@ -386,14 +394,15 @@ describe("amico sessions archive — disjointness from the vault/coordination pl function dbRows(dbPath: string): string { return execFileSync( - process.execPath, - ["--no-warnings", "-e", ` - const { DatabaseSync } = require("node:sqlite"); - const db = new DatabaseSync(process.argv[1], { readOnly: true }); - console.log(JSON.stringify({ - sessions: db.prepare("SELECT * FROM session ORDER BY id").all(), - projects: db.prepare("SELECT * FROM project ORDER BY id").all(), - })); + pythonBin(), + ["-c", ` +import json, sqlite3, sys +con = sqlite3.connect("file:" + sys.argv[1] + "?mode=ro", uri=True, timeout=5) +con.row_factory = sqlite3.Row +print(json.dumps({ + "sessions": [dict(r) for r in con.execute("SELECT * FROM session ORDER BY id").fetchall()], + "projects": [dict(r) for r in con.execute("SELECT * FROM project ORDER BY id").fetchall()], +})) `, dbPath], { encoding: "utf8" }, );