@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 +2 -2
- package/src/adapters/sqlite-task-focus-store.ts +4 -0
- package/src/cli.ts +8 -0
- package/src/constants.ts +11 -0
- package/src/daemon.ts +10 -0
- package/src/db.ts +73 -8
- package/src/modules/tasks.ts +2 -1
- package/src/ports/task-focus-store.ts +10 -0
- package/src/service.ts +4 -0
- package/src/task-service.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.17.
|
|
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.
|
|
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
|
-
|
|
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
|
|
381
|
-
// `schemaVersion(db) === N` step below has had its chance to fire, `from` was never a
|
|
382
|
-
// starting point (a genuine gap in the chain) -- structurally cannot drift out of sync
|
|
383
|
-
// way a separate, parallel enumeration did.
|
|
384
|
-
|
|
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) !==
|
|
712
|
+
if (schemaVersion(db) !== LEGACY_MIGRATION_CHAIN_TARGET_VERSION) throw new Error(`no explicit migration path from database schema ${from}`);
|
|
664
713
|
});
|
|
665
|
-
|
|
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
|
|
package/src/modules/tasks.ts
CHANGED
|
@@ -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();
|
package/src/task-service.ts
CHANGED
|
@@ -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);
|