@danypops/papyrus 0.17.0 → 0.17.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -42,6 +42,6 @@
42
42
  "files": ["src", "extension", "README.md"],
43
43
  "dependencies": {
44
44
  "beautiful-mermaid": "1.1.3",
45
- "@danypops/daemon-kit": "^0.2.0"
45
+ "@danypops/daemon-kit": "^0.2.1"
46
46
  }
47
47
  }
@@ -31,6 +31,10 @@ export class SQLiteTaskFocusStore implements TaskFocusStore {
31
31
  });
32
32
  }
33
33
 
34
+ reapStale(olderThanIso: string): number {
35
+ return this.db.prepare("DELETE FROM task_focus WHERE updated_at < ?").run(olderThanIso).changes;
36
+ }
37
+
34
38
  private transition(taskId: string, expected: TaskFocusStatus, status: TaskFocusStatus, reason: string | undefined, scope: string | undefined): TaskFocusState {
35
39
  const current = this.get(scope);
36
40
  if (current?.taskId !== taskId) throw new Error(`task "${taskId}" is not focused`);
package/src/cli.ts CHANGED
@@ -122,6 +122,7 @@ const USAGE = `Usage:
122
122
  papyrus tasks pause [--session-id <id>] [--session-secret <secret>] [--json]
123
123
  papyrus tasks unpause [--session-id <id>] [--session-secret <secret>] [--json]
124
124
  papyrus tasks clear-focus [--session-id <id>] [--session-secret <secret>] [--json]
125
+ papyrus tasks reap-stale-focus [--json]
125
126
  papyrus tasks history <id> [--json]
126
127
  papyrus tasks scope [project|all|graph <root-id>] [--json]
127
128
  papyrus tasks assign-project <id> [project-root] [--json]
@@ -1119,6 +1120,13 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1119
1120
  human = cleared.cleared ? "Task focus cleared." : "No focused task.";
1120
1121
  break;
1121
1122
  }
1123
+ case "reap-stale-focus": {
1124
+ if (id) throw new Error("tasks reap-stale-focus accepts no positional arguments");
1125
+ const reaped = await client.call<Record<string, unknown>, { removed: number }>("tasks.reap_stale_focus", {});
1126
+ result = reaped;
1127
+ human = `Reaped ${reaped.removed} stale Focus scope(s).`;
1128
+ break;
1129
+ }
1122
1130
  case "create": {
1123
1131
  if (id) throw new Error("tasks create accepts no positional arguments");
1124
1132
  if (!title) throw new Error("tasks create requires --title");
package/src/constants.ts CHANGED
@@ -146,6 +146,17 @@ export const TASK_FOCUS_DEFAULT_SCOPE = "global";
146
146
  export const TASK_FOCUS_SCOPE_MAX_LENGTH = 128;
147
147
  /** Hard cap on distinct concurrent focus scopes (sessions); oldest-updated scope is evicted beyond this. */
148
148
  export const TASK_FOCUS_MAX_SCOPES = 500;
149
+ /**
150
+ * A Task Focus row not touched by any Focus-mutating operation (focus/pause/unpause) in this
151
+ * long is eligible for time-based reaping (see Tasks.reapStaleFocus), independent of the
152
+ * TASK_FOCUS_MAX_SCOPES hard cap above -- see clean-up-stale-per-session-task-focus-rows-
153
+ * on-real-session-l-9i7s. Deliberately NOT driven by Pi's session_start/session_shutdown
154
+ * hooks: a "resume" reuses the exact same session_id as a prior process incarnation, so
155
+ * neither hook reliably signals "this session is gone forever" -- only real elapsed time
156
+ * without any Focus activity does. 30 days is long enough that a genuine multi-week pause-
157
+ * and-resume workflow survives; short enough to actually bound long-run accumulation.
158
+ */
159
+ export const TASK_FOCUS_STALE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
149
160
  /** Hard cap on registered session_identities rows (see domain/session-identity.ts); oldest-seen identity is evicted beyond this, mirroring TASK_FOCUS_MAX_SCOPES. */
150
161
  export const SESSION_IDENTITY_MAX_ROWS = 2_000;
151
162
  /** Persisted project and focused-graph Task view bounds. */
package/src/daemon.ts CHANGED
@@ -25,12 +25,22 @@ export function serveMain(): void {
25
25
  const optimizeTimer = setInterval(() => {
26
26
  try { service.optimize(); } catch (error) { logEvent("error", "optimize_failed", { message: error instanceof Error ? error.message : String(error) }); }
27
27
  }, DB_OPTIMIZE_INTERVAL_MS);
28
+ // Daily cadence (reusing DB_OPTIMIZE_INTERVAL_MS) is plenty against a 30-day staleness
29
+ // threshold (TASK_FOCUS_STALE_AFTER_MS) -- see clean-up-stale-per-session-task-focus-rows-
30
+ // on-real-session-l-9i7s.
31
+ const reapFocusTimer = setInterval(() => {
32
+ try {
33
+ const removed = service.reapStaleFocus();
34
+ if (removed > 0) logEvent("info", "stale_focus_reaped", { removed });
35
+ } catch (error) { logEvent("error", "reap_stale_focus_failed", { message: error instanceof Error ? error.message : String(error) }); }
36
+ }, DB_OPTIMIZE_INTERVAL_MS);
28
37
  let stopping = false;
29
38
  const shutdown = () => {
30
39
  if (stopping) return;
31
40
  stopping = true;
32
41
  clearInterval(checkpointTimer);
33
42
  clearInterval(optimizeTimer);
43
+ clearInterval(reapFocusTimer);
34
44
  clearDaemonPort(stateDir);
35
45
  service.close();
36
46
  void server.stop(true).finally(() => process.exit(0));
package/src/db.ts CHANGED
@@ -7,6 +7,7 @@ import { createHash } from "node:crypto";
7
7
  import { createRequire } from "node:module";
8
8
  import { mkdirSync } from "node:fs";
9
9
  import { join, dirname } from "node:path";
10
+ import { runMigrations, type SqliteMigrationRunner } from "@danypops/daemon-kit/storage";
10
11
  import { SQLITE_BUSY_TIMEOUT_MS, SQLITE_SCHEMA_VERSION } from "./constants.ts";
11
12
 
12
13
  const require_ = createRequire(import.meta.url);
@@ -20,7 +21,8 @@ const DatabaseCtor = (
20
21
  ) as new (path: string, opts?: { create?: boolean }) => Db;
21
22
 
22
23
  export interface DbStatement {
23
- run(...params: unknown[]): { lastInsertRowid: number | bigint };
24
+ /** changes: number of rows the statement affected. Both bun:sqlite and node:sqlite's real run() return this at runtime; declared here so callers (e.g. reapStale) can rely on it without an unsafe cast. */
25
+ run(...params: unknown[]): { lastInsertRowid: number | bigint; changes: number };
24
26
  get(...params: unknown[]): unknown;
25
27
  all(...params: unknown[]): unknown[];
26
28
  }
@@ -362,6 +364,47 @@ function bootstrapEmptyDatabase(db: Db): void {
362
364
  ensureCoreLedger(db, false);
363
365
  }
364
366
 
367
+ /**
368
+ * Version the hardcoded, sequential if-chain below produces once fully applied. Frozen
369
+ * forever, per this same function's own "deliberately not a hand-enumerated allow-list"
370
+ * history below: that chain is never edited once shipped, only ever extended with a new
371
+ * `if` branch -- except a NEW branch is no longer how migrations beyond this version are
372
+ * added (see FUTURE_MIGRATIONS). The legacy chain itself stays byte-for-byte as it always
373
+ * was: same SQL, same single all-or-nothing transaction, same dynamic post-hoc gap check.
374
+ */
375
+ const LEGACY_MIGRATION_CHAIN_TARGET_VERSION = 13;
376
+
377
+ /**
378
+ * A migration beyond LEGACY_MIGRATION_CHAIN_TARGET_VERSION. Runs through @danypops/
379
+ * daemon-kit's generic runMigrations engine (one transaction per migration, daemon-kit's
380
+ * default) via dbMigrationRunner below, instead of a new branch appended to the legacy
381
+ * if-chain -- the exact reuse daemon-kit's storage module was refactored (v0.2.1) to allow,
382
+ * since Papyrus's dual bun:sqlite/node:sqlite Db abstraction could never satisfy that
383
+ * engine's original bun:sqlite-only signature.
384
+ */
385
+ export interface PapyrusMigration {
386
+ version: number;
387
+ name: string;
388
+ up: (db: Db) => void;
389
+ }
390
+
391
+ /** Currently empty -- no schema version beyond LEGACY_MIGRATION_CHAIN_TARGET_VERSION exists yet. The next migration is appended here, never as a new branch in the legacy chain above. */
392
+ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [];
393
+
394
+ /**
395
+ * Adapts Papyrus's own Db/inTransaction to daemon-kit's storage-agnostic
396
+ * SqliteMigrationRunner port, so its runMigrations engine (written against bun:sqlite's
397
+ * concrete Database) runs unmodified against Papyrus's dual-runtime Db abstraction instead.
398
+ */
399
+ export function dbMigrationRunner(db: Db): SqliteMigrationRunner<Db> {
400
+ return {
401
+ raw: db,
402
+ userVersion: () => schemaVersion(db),
403
+ setUserVersion: (version) => db.exec(`PRAGMA user_version = ${version}`),
404
+ transaction: (fn) => inTransaction(db, fn),
405
+ };
406
+ }
407
+
365
408
  export function migrateDb(db: Db): MigrationResult {
366
409
  const from = schemaVersion(db);
367
410
  if (from > SQLITE_SCHEMA_VERSION) {
@@ -377,11 +420,17 @@ export function migrateDb(db: Db): MigrationResult {
377
420
  // migrating any already-deployed database sitting at schema 8, 9, or 10 (including the real
378
421
  // production database at the time this was found) would have thrown "no explicit migration
379
422
  // path" before ever reaching the migration chain below. Checked dynamically after the chain
380
- // runs instead: if schemaVersion(db) hasn't reached SQLITE_SCHEMA_VERSION once every
381
- // `schemaVersion(db) === N` step below has had its chance to fire, `from` was never a valid
382
- // starting point (a genuine gap in the chain) -- structurally cannot drift out of sync the
383
- // way a separate, parallel enumeration did.
384
- inTransaction(db, () => {
423
+ // runs instead: if schemaVersion(db) hasn't reached LEGACY_MIGRATION_CHAIN_TARGET_VERSION once
424
+ // every `schemaVersion(db) === N` step below has had its chance to fire, `from` was never a
425
+ // valid starting point (a genuine gap in the chain) -- structurally cannot drift out of sync
426
+ // the way a separate, parallel enumeration did.
427
+ //
428
+ // Guarded by `from < LEGACY_MIGRATION_CHAIN_TARGET_VERSION`: a database already at or past
429
+ // that version (only reachable once FUTURE_MIGRATIONS below has entries) must skip this
430
+ // entire frozen chain, not merely fail to match any of its branches -- entering it and
431
+ // falling through to the final gap check would otherwise misreport a database correctly
432
+ // mid-way through FUTURE_MIGRATIONS as "no explicit migration path".
433
+ if (from < LEGACY_MIGRATION_CHAIN_TARGET_VERSION) inTransaction(db, () => {
385
434
  if (schemaVersion(db) === 1) {
386
435
  db.exec(`
387
436
  INSERT OR IGNORE INTO statuses VALUES ('todo','task');
@@ -660,9 +709,25 @@ export function migrateDb(db: Db): MigrationResult {
660
709
  `);
661
710
  applied.push("session-identity");
662
711
  }
663
- if (schemaVersion(db) !== SQLITE_SCHEMA_VERSION) throw new Error(`no explicit migration path from database schema ${from}`);
712
+ if (schemaVersion(db) !== LEGACY_MIGRATION_CHAIN_TARGET_VERSION) throw new Error(`no explicit migration path from database schema ${from}`);
664
713
  });
665
- if (schemaVersion(db) === SQLITE_SCHEMA_VERSION) ensureCoreLedger(db, true);
714
+
715
+ // Guarded by length: runMigrations treats an empty migrations array's "target version" as
716
+ // 0 (see its own sorted.at(-1) ?? 0), which would misreport a database the legacy chain
717
+ // already advanced past 0 as a downgrade. FUTURE_MIGRATIONS is empty only when there is
718
+ // nothing beyond LEGACY_MIGRATION_CHAIN_TARGET_VERSION to apply -- exactly the case where
719
+ // skipping the call is correct, not merely convenient.
720
+ if (FUTURE_MIGRATIONS.length > 0) {
721
+ const beforeFuture = schemaVersion(db);
722
+ runMigrations(dbMigrationRunner(db), [...FUTURE_MIGRATIONS]);
723
+ const afterFuture = schemaVersion(db);
724
+ for (const migration of FUTURE_MIGRATIONS) {
725
+ if (migration.version > beforeFuture && migration.version <= afterFuture) applied.push(migration.name);
726
+ }
727
+ }
728
+
729
+ if (schemaVersion(db) !== SQLITE_SCHEMA_VERSION) throw new Error(`no explicit migration path from database schema ${from}`);
730
+ ensureCoreLedger(db, true);
666
731
  return { from, to: schemaVersion(db), applied };
667
732
  }
668
733
 
@@ -90,7 +90,7 @@ export const TASKS_OPERATION_NAMES = [
90
90
  "tasks.scope", "tasks.set_scope", "tasks.assign_project", "tasks.active", "tasks.focused", "tasks.focus",
91
91
  "tasks.pause", "tasks.unpause", "tasks.clear_focus", "tasks.start", "tasks.submit", "tasks.complete",
92
92
  "tasks.run_gates", "tasks.set_checklist", "tasks.context", "tasks.reject", "tasks.retry", "tasks.cancel",
93
- "tasks.depend", "tasks.undepend", "tasks.contain", "tasks.uncontain",
93
+ "tasks.depend", "tasks.undepend", "tasks.contain", "tasks.uncontain", "tasks.reap_stale_focus",
94
94
  ] as const;
95
95
 
96
96
  export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionIdentity: SessionIdentity): OperationDefinition[] {
@@ -151,6 +151,7 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
151
151
  define("tasks.pause", (input: OperationInput) => { guardFocusMutation(input); return tasks.pauseFocus(eventContext(input)); }),
152
152
  define("tasks.unpause", (input: OperationInput) => { guardFocusMutation(input); return tasks.unpauseFocus(eventContext(input)); }),
153
153
  define("tasks.clear_focus", (input: OperationInput) => { guardFocusMutation(input); return tasks.clearFocus(eventContext(input)); }),
154
+ define("tasks.reap_stale_focus", () => ({ removed: tasks.reapStaleFocus() })),
154
155
  define("tasks.start", (input: OperationInput) => tasks.transition(string(input, "id"), "start", eventContext(input))),
155
156
  define("tasks.submit", (input: OperationInput) => tasks.transition(string(input, "id"), "submit", eventContext(input))),
156
157
  define("tasks.complete", (input: OperationInput) => tasks.completeAsync(string(input, "id"), eventContext(input))),
@@ -30,6 +30,8 @@ export interface TaskFocusStore {
30
30
  clear(taskId?: string, scope?: string): void;
31
31
  /** Clears this task's Focus in every scope (session), not just one — for lifecycle events (e.g. cancel) that are not scoped to a single caller. */
32
32
  clearEverywhere(taskId: string): void;
33
+ /** Deletes every Focus row whose updatedAt is strictly before olderThanIso (see TASK_FOCUS_STALE_AFTER_MS). Returns how many rows were removed. */
34
+ reapStale(olderThanIso: string): number;
33
35
  }
34
36
 
35
37
  export class InMemoryTaskFocusStore implements TaskFocusStore {
@@ -74,6 +76,14 @@ export class InMemoryTaskFocusStore implements TaskFocusStore {
74
76
  }
75
77
  }
76
78
 
79
+ reapStale(olderThanIso: string): number {
80
+ let removed = 0;
81
+ for (const [key, focus] of this.state) {
82
+ if (focus.updatedAt < olderThanIso) { this.state.delete(key); removed++; }
83
+ }
84
+ return removed;
85
+ }
86
+
77
87
  private evictOldest(): void {
78
88
  let oldestKey: string | undefined;
79
89
  let oldestAt: string | undefined;
package/src/service.ts CHANGED
@@ -171,6 +171,8 @@ export interface PapyrusService {
171
171
  execute(operation: string, input?: OperationInput): Promise<unknown>;
172
172
  checkpoint(): void;
173
173
  optimize(): void;
174
+ /** Time-based Task Focus reclamation (see Tasks.reapStaleFocus); returns how many rows were removed, for daemon logging. */
175
+ reapStaleFocus(): number;
174
176
  close(): void;
175
177
  }
176
178
 
@@ -318,6 +320,7 @@ function handlers(
318
320
  "tasks.undepend": forwardToModule("tasks.undepend"),
319
321
  "tasks.contain": forwardToModule("tasks.contain"),
320
322
  "tasks.uncontain": forwardToModule("tasks.uncontain"),
323
+ "tasks.reap_stale_focus": forwardToModule("tasks.reap_stale_focus"),
321
324
  "docs.create": forwardToModule("docs.create"),
322
325
  "docs.list": forwardToModule("docs.list"),
323
326
  "docs.show": forwardToModule("docs.show"),
@@ -417,6 +420,7 @@ export function createPapyrusService(path: string): PapyrusService {
417
420
  },
418
421
  checkpoint: () => { db.exec("PRAGMA wal_checkpoint(PASSIVE)"); },
419
422
  optimize: () => { db.exec("PRAGMA optimize"); },
423
+ reapStaleFocus: () => tasks.reapStaleFocus(),
420
424
  close: () => {
421
425
  db.exec("PRAGMA optimize");
422
426
  db.close();
@@ -5,6 +5,7 @@ import {
5
5
  TASK_EXECUTION_MAX_NODES,
6
6
  TASK_LABEL_MAX_COUNT,
7
7
  TASK_LABEL_MAX_LENGTH,
8
+ TASK_FOCUS_STALE_AFTER_MS,
8
9
  TASK_SCOPE_MAX_TASKS,
9
10
  TASK_TITLE_MAX_LENGTH,
10
11
  } from "./constants.ts";
@@ -383,6 +384,19 @@ export class Tasks {
383
384
  });
384
385
  }
385
386
 
387
+ /**
388
+ * Time-based reclamation of Focus scopes nobody has touched in TASK_FOCUS_STALE_AFTER_MS,
389
+ * independent of and in addition to the TASK_FOCUS_MAX_SCOPES hard cap -- see
390
+ * clean-up-stale-per-session-task-focus-rows-on-real-session-l-9i7s and constants.ts's
391
+ * comment on why this is deliberately not driven by session_start/session_shutdown.
392
+ * No task-lifecycle event is appended: this is daemon housekeeping, not a caller-driven
393
+ * mutation, and there is no longer a specific session/actor to attribute it to.
394
+ */
395
+ reapStaleFocus(now: () => string = () => new Date().toISOString()): number {
396
+ const cutoff = new Date(new Date(now()).getTime() - TASK_FOCUS_STALE_AFTER_MS).toISOString();
397
+ return this.focusStore.reapStale(cutoff);
398
+ }
399
+
386
400
  transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
387
401
  return this.events.atomic(() => {
388
402
  const task = this.require(id);