@danypops/papyrus 0.42.0 → 0.42.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-artifact-scope-store.ts +18 -9
- package/src/adapters/sqlite-artifact-store.ts +7 -5
- package/src/adapters/sqlite-discussion-round-store.ts +26 -17
- package/src/adapters/sqlite-gate-runner.ts +1 -1
- package/src/adapters/sqlite-graph-projection-store.ts +14 -10
- package/src/adapters/sqlite-log-store.ts +36 -17
- package/src/adapters/sqlite-note-event-store.ts +20 -16
- package/src/adapters/sqlite-session-identity-store.ts +13 -7
- package/src/adapters/sqlite-task-event-store.ts +29 -21
- package/src/adapters/sqlite-task-focus-store.ts +36 -10
- package/src/adapters/sqlite-task-lease-store.ts +12 -6
- package/src/adapters/sqlite-task-scope-store.ts +25 -14
- package/src/artifact-relationship-view.ts +3 -3
- package/src/artifact-subtree.ts +4 -2
- package/src/authority-registry.ts +2 -1
- package/src/cli.ts +785 -179
- package/src/client.ts +46 -20
- package/src/constants.ts +15 -4
- package/src/daemon-state.ts +4 -12
- package/src/daemon.ts +31 -9
- package/src/db.ts +119 -98
- package/src/discussion-service.ts +109 -44
- package/src/domain/artifact-event.ts +17 -4
- package/src/domain/artifact.ts +3 -1
- package/src/domain/blueprint-definition.ts +26 -31
- package/src/domain/checklist.ts +20 -17
- package/src/domain/discussion.ts +37 -18
- package/src/domain/gate.ts +7 -7
- package/src/domain/log-entry.ts +1 -1
- package/src/domain/note-event.ts +20 -7
- package/src/domain/task-event.ts +17 -7
- package/src/domain-services.ts +241 -110
- package/src/graph-projection-service.ts +34 -8
- package/src/id-migration.ts +17 -4
- package/src/index.ts +16 -11
- package/src/log-service.ts +6 -5
- package/src/log.ts +19 -0
- package/src/modules/discuss.ts +63 -28
- package/src/modules/docs.ts +74 -17
- package/src/modules/graph-projection.ts +20 -9
- package/src/modules/logs.ts +33 -21
- package/src/modules/notes.ts +66 -28
- package/src/modules/playbooks.ts +88 -29
- package/src/modules/rules.ts +57 -15
- package/src/modules/session-identity.ts +6 -2
- package/src/modules/tasks.ts +142 -67
- package/src/note-service.ts +11 -7
- package/src/ops.ts +131 -68
- package/src/playbook-definition.ts +56 -17
- package/src/playbook-execution.ts +13 -3
- package/src/ports/note-event-store.ts +6 -4
- package/src/ports/task-event-store.ts +9 -6
- package/src/ports/task-focus-store.ts +17 -4
- package/src/ports/task-lease-store.ts +9 -4
- package/src/ports/task-scope-store.ts +3 -1
- package/src/service.ts +143 -89
- package/src/session-identity-service.ts +10 -2
- package/src/task-context.ts +28 -16
- package/src/task-execution.ts +4 -12
- package/src/task-graph-view.ts +12 -12
- package/src/task-relationship-view.ts +1 -3
- package/src/task-service.ts +167 -72
- package/src/vehicle/artifact-trash-vehicle.ts +25 -13
- package/src/vehicle/artifact-vehicle-shared.ts +28 -7
- package/src/vehicle/docs-vehicle.ts +48 -16
- package/src/vehicle/notes-vehicle.ts +24 -6
- package/src/vehicle/papyrus-vehicle.ts +14 -3
- package/src/vehicle/playbooks-vehicle.ts +87 -18
- package/src/vehicle/rules-vehicle.ts +58 -21
- package/src/vehicle/tasks-vehicle.ts +366 -54
- package/src/version.ts +1 -1
- package/src/workflow-execution.ts +71 -52
package/src/client.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { spawn as spawnProcess } from "node:child_process";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import {
|
|
3
|
+
import { connectWithVersionCheck, spawnDetachedDaemon } from "@danypops/vehicle-client/daemon-client";
|
|
4
|
+
import { readPackageVersion } from "@danypops/vehicle-client/version";
|
|
4
5
|
import { DAEMON_CLIENT_TIMEOUT_MS, DAEMON_DIR_ENV, DAEMON_PROBE_TIMEOUT_MS } from "./constants.ts";
|
|
5
|
-
import { daemonStateDir, readDaemonHandle
|
|
6
|
+
import { type DaemonHandle, daemonStateDir, readDaemonHandle } from "./daemon-state.ts";
|
|
6
7
|
import type { OperationName, SchemaState } from "./service.ts";
|
|
7
8
|
|
|
9
|
+
/** Compared against the running daemon's /health-reported version by connectWithVersionCheck below -- a long-lived daemon holds whatever code was loaded at its own start. */
|
|
10
|
+
const PAPYRUS_VERSION = readPackageVersion(new URL("../package.json", import.meta.url), "Papyrus");
|
|
11
|
+
|
|
8
12
|
export type FetchAdapter = (request: Request) => Promise<Response>;
|
|
9
13
|
|
|
10
14
|
export class PapyrusClient {
|
|
@@ -26,7 +30,7 @@ export class PapyrusClient {
|
|
|
26
30
|
signal: init.signal ?? AbortSignal.timeout(this.timeoutMs),
|
|
27
31
|
});
|
|
28
32
|
const response = await this.fetchAdapter(request);
|
|
29
|
-
const body = await response.json() as { error?: string } & T;
|
|
33
|
+
const body = (await response.json()) as { error?: string } & T;
|
|
30
34
|
if (!response.ok) throw new Error(body.error ?? `Papyrus daemon HTTP ${response.status}`);
|
|
31
35
|
return body;
|
|
32
36
|
}
|
|
@@ -64,6 +68,16 @@ function papyrusCliPath(): string {
|
|
|
64
68
|
return fileURLToPath(new URL("cli.ts", import.meta.url));
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
/** connectWithVersionCheck's killStaleProcess callback, factored out for a direct unit test -- a real spawned daemon can't be made to report a mismatched version without a second build. */
|
|
72
|
+
export function killStalePapyrusDaemon(handle: Pick<DaemonHandle, "pid">): void {
|
|
73
|
+
if (handle.pid <= 0) return; // daemon-state.ts's inert "unknown pid" sentinel -- never a real process.
|
|
74
|
+
try {
|
|
75
|
+
process.kill(handle.pid, "SIGTERM");
|
|
76
|
+
} catch {
|
|
77
|
+
// Caller's handle-file poll is the real guarantee, not this call succeeding.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
67
81
|
export interface ConnectPapyrusClientOptions {
|
|
68
82
|
/**
|
|
69
83
|
* Environment passed to an auto-spawned daemon child. Defaults to the current
|
|
@@ -74,6 +88,8 @@ export interface ConnectPapyrusClientOptions {
|
|
|
74
88
|
* silently diverge from wherever the child actually starts writing its handle.
|
|
75
89
|
*/
|
|
76
90
|
env?: Record<string, string | undefined>;
|
|
91
|
+
/** Overrides the version connectWithVersionCheck compares against. Defaults to PAPYRUS_VERSION; test-only -- lets a test force a mismatch against a real daemon without a second build. */
|
|
92
|
+
expectedVersion?: string;
|
|
77
93
|
}
|
|
78
94
|
|
|
79
95
|
/**
|
|
@@ -87,24 +103,34 @@ export interface ConnectPapyrusClientOptions {
|
|
|
87
103
|
* failure (stale, not "never started") and is NOT auto-recovered here -- it still
|
|
88
104
|
* throws its own actionable "restart manually" error, unchanged from before.
|
|
89
105
|
*/
|
|
90
|
-
export async function connectPapyrusClient(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
export async function connectPapyrusClient(
|
|
107
|
+
dir: string = daemonStateDir(),
|
|
108
|
+
options: ConnectPapyrusClientOptions = {},
|
|
109
|
+
): Promise<PapyrusClient> {
|
|
110
|
+
return connectWithVersionCheck(
|
|
111
|
+
{
|
|
112
|
+
readHandle: () => readDaemonHandle(dir) ?? null,
|
|
113
|
+
buildClient: probedPapyrusClient,
|
|
114
|
+
autoStart: true,
|
|
115
|
+
spawn: () => {
|
|
116
|
+
spawnDetachedDaemon({
|
|
117
|
+
binPath: papyrusCliPath(),
|
|
118
|
+
args: ["serve"],
|
|
119
|
+
env: { ...(options.env ?? process.env), [DAEMON_DIR_ENV]: dir },
|
|
120
|
+
spawn: (command, args, spawnOptions) => {
|
|
121
|
+
const child = spawnProcess(command, args, spawnOptions);
|
|
122
|
+
child.unref();
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
fallbackMessage: "Papyrus daemon failed to start automatically; run `papyrus service install` or `papyrus serve` manually.",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
expectedVersion: options.expectedVersion ?? PAPYRUS_VERSION,
|
|
130
|
+
readVersion: async (client) => (await client.health()).version,
|
|
131
|
+
killStaleProcess: killStalePapyrusDaemon,
|
|
105
132
|
},
|
|
106
|
-
|
|
107
|
-
});
|
|
133
|
+
);
|
|
108
134
|
}
|
|
109
135
|
|
|
110
136
|
export interface PushChannelTarget {
|
package/src/constants.ts
CHANGED
|
@@ -289,7 +289,7 @@ export const TASK_RECONCILIATION_INSTRUCTION = [
|
|
|
289
289
|
|
|
290
290
|
/** $XDG_DATA_HOME/papyrus/papyrus.db */
|
|
291
291
|
export function dbPath(): string {
|
|
292
|
-
const xdg = process.env
|
|
292
|
+
const xdg = process.env.XDG_DATA_HOME || `${process.env.HOME}/.local/share`;
|
|
293
293
|
return `${xdg}/papyrus/papyrus.db`;
|
|
294
294
|
}
|
|
295
295
|
|
|
@@ -324,7 +324,18 @@ export const DEFAULT_STATUS_BY_KIND: Readonly<Record<string, string>> = {
|
|
|
324
324
|
* triggers: this playbook run applies to that work (playbook→task)
|
|
325
325
|
*/
|
|
326
326
|
export const SEED_RELATIONS = [
|
|
327
|
-
"references",
|
|
328
|
-
"
|
|
329
|
-
"
|
|
327
|
+
"references",
|
|
328
|
+
"implements",
|
|
329
|
+
"follows",
|
|
330
|
+
"depends_on",
|
|
331
|
+
"documents",
|
|
332
|
+
"blocks",
|
|
333
|
+
"supersedes",
|
|
334
|
+
"relates_to",
|
|
335
|
+
"gates",
|
|
336
|
+
"triggers",
|
|
337
|
+
"contains",
|
|
338
|
+
"part_of",
|
|
339
|
+
"reply_to",
|
|
340
|
+
"discusses",
|
|
330
341
|
] as const;
|
package/src/daemon-state.ts
CHANGED
|
@@ -2,12 +2,7 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
DAEMON_DIR_ENV,
|
|
7
|
-
DAEMON_HOST,
|
|
8
|
-
DAEMON_PORT_FILE,
|
|
9
|
-
DAEMON_TOKEN_FILE,
|
|
10
|
-
} from "./constants.ts";
|
|
5
|
+
import { DAEMON_DIR_ENV, DAEMON_HOST, DAEMON_PORT_FILE, DAEMON_TOKEN_FILE } from "./constants.ts";
|
|
11
6
|
|
|
12
7
|
export interface DaemonHandle {
|
|
13
8
|
baseUrl: string;
|
|
@@ -17,13 +12,10 @@ export interface DaemonHandle {
|
|
|
17
12
|
pid: number;
|
|
18
13
|
}
|
|
19
14
|
|
|
20
|
-
export function daemonStateDir(
|
|
21
|
-
env: Record<string, string | undefined> = process.env,
|
|
22
|
-
home: string = homedir(),
|
|
23
|
-
): string {
|
|
15
|
+
export function daemonStateDir(env: Record<string, string | undefined> = process.env, home: string = homedir()): string {
|
|
24
16
|
if (env[DAEMON_DIR_ENV]) return env[DAEMON_DIR_ENV];
|
|
25
|
-
if (env
|
|
26
|
-
if (env
|
|
17
|
+
if (env.XDG_RUNTIME_DIR) return join(env.XDG_RUNTIME_DIR, "papyrus");
|
|
18
|
+
if (env.XDG_STATE_HOME) return join(env.XDG_STATE_HOME, "papyrus");
|
|
27
19
|
return join(home, ".local", "state", "papyrus");
|
|
28
20
|
}
|
|
29
21
|
|
package/src/daemon.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { PushChannel } from "@danypops/vehicle-server/push-channel";
|
|
2
|
-
import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS,
|
|
2
|
+
import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, dbPath, WAL_CHECKPOINT_INTERVAL_MS } from "./constants.ts";
|
|
3
3
|
import { clearDaemonPort, daemonStateDir, loadOrCreateToken, writeDaemonPort } from "./daemon-state.ts";
|
|
4
|
+
import { logEvent, vehicleLogger } from "./log.ts";
|
|
4
5
|
import { createApp, createPapyrusService } from "./service.ts";
|
|
5
|
-
import { logEvent } from "./log.ts";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Operations that never change what a Task-graph reader (the pi-papyrus widget's
|
|
@@ -13,8 +13,16 @@ import { logEvent } from "./log.ts";
|
|
|
13
13
|
* silently-uncovered new mutation.
|
|
14
14
|
*/
|
|
15
15
|
const TASK_READ_ONLY_OPERATIONS = new Set([
|
|
16
|
-
"tasks.active",
|
|
17
|
-
"tasks.
|
|
16
|
+
"tasks.active",
|
|
17
|
+
"tasks.context",
|
|
18
|
+
"tasks.event_feed",
|
|
19
|
+
"tasks.focused",
|
|
20
|
+
"tasks.graph",
|
|
21
|
+
"tasks.history",
|
|
22
|
+
"tasks.list",
|
|
23
|
+
"tasks.plan",
|
|
24
|
+
"tasks.scope",
|
|
25
|
+
"tasks.show",
|
|
18
26
|
]);
|
|
19
27
|
|
|
20
28
|
/** Start the supervised, long-running Papyrus service. */
|
|
@@ -31,6 +39,7 @@ export function serveMain(): void {
|
|
|
31
39
|
pushChannel.publish("tasks", { operation });
|
|
32
40
|
}
|
|
33
41
|
},
|
|
42
|
+
logger: vehicleLogger(),
|
|
34
43
|
});
|
|
35
44
|
const server = Bun.serve({
|
|
36
45
|
hostname: DAEMON_HOST,
|
|
@@ -49,10 +58,18 @@ export function serveMain(): void {
|
|
|
49
58
|
}
|
|
50
59
|
writeDaemonPort(stateDir, server.port);
|
|
51
60
|
const checkpointTimer = setInterval(() => {
|
|
52
|
-
try {
|
|
61
|
+
try {
|
|
62
|
+
service.checkpoint();
|
|
63
|
+
} catch (error) {
|
|
64
|
+
logEvent("error", "checkpoint_failed", { message: error instanceof Error ? error.message : String(error) });
|
|
65
|
+
}
|
|
53
66
|
}, WAL_CHECKPOINT_INTERVAL_MS);
|
|
54
67
|
const optimizeTimer = setInterval(() => {
|
|
55
|
-
try {
|
|
68
|
+
try {
|
|
69
|
+
service.optimize();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
logEvent("error", "optimize_failed", { message: error instanceof Error ? error.message : String(error) });
|
|
72
|
+
}
|
|
56
73
|
}, DB_OPTIMIZE_INTERVAL_MS);
|
|
57
74
|
// Daily cadence (reusing DB_OPTIMIZE_INTERVAL_MS) is plenty against a 30-day staleness
|
|
58
75
|
// threshold (TASK_FOCUS_STALE_AFTER_MS) -- see clean-up-stale-per-session-task-focus-rows-
|
|
@@ -61,7 +78,9 @@ export function serveMain(): void {
|
|
|
61
78
|
try {
|
|
62
79
|
const removed = service.reapStaleFocus();
|
|
63
80
|
if (removed > 0) logEvent("info", "stale_focus_reaped", { removed });
|
|
64
|
-
} catch (error) {
|
|
81
|
+
} catch (error) {
|
|
82
|
+
logEvent("error", "reap_stale_focus_failed", { message: error instanceof Error ? error.message : String(error) });
|
|
83
|
+
}
|
|
65
84
|
}, DB_OPTIMIZE_INTERVAL_MS);
|
|
66
85
|
// Same daily cadence: ARTIFACT_TRASH_RETENTION_MS is 30 days, so a daily sweep finds newly
|
|
67
86
|
// due artifacts promptly without needing its own tighter interval -- see domain/artifact-trash.ts.
|
|
@@ -69,7 +88,9 @@ export function serveMain(): void {
|
|
|
69
88
|
try {
|
|
70
89
|
const purged = service.purgeDueTrash();
|
|
71
90
|
if (purged > 0) logEvent("info", "artifact_trash_purged", { purged });
|
|
72
|
-
} catch (error) {
|
|
91
|
+
} catch (error) {
|
|
92
|
+
logEvent("error", "purge_trash_failed", { message: error instanceof Error ? error.message : String(error) });
|
|
93
|
+
}
|
|
73
94
|
}, DB_OPTIMIZE_INTERVAL_MS);
|
|
74
95
|
let stopping = false;
|
|
75
96
|
const shutdown = () => {
|
|
@@ -83,7 +104,8 @@ export function serveMain(): void {
|
|
|
83
104
|
service.close();
|
|
84
105
|
// .finally() re-throws rather than handling a rejection -- catching it first turns a bare
|
|
85
106
|
// unhandled-rejection warning into a real, queryable shutdown-failure log line.
|
|
86
|
-
void server
|
|
107
|
+
void server
|
|
108
|
+
.stop(true)
|
|
87
109
|
.catch((error) => logEvent("error", "server_stop_failed", { message: error instanceof Error ? error.message : String(error) }))
|
|
88
110
|
.finally(() => process.exit(0));
|
|
89
111
|
};
|
package/src/db.ts
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* db.ts — enforced-schema SQLite store for Papyrus.
|
|
3
|
-
* Dual-runtime: bun:sqlite (Bun) / node:sqlite (Node/pi host).
|
|
4
|
-
* Four kinds (doc/task/rule/skill) are FK-enforced; relations are universal (any→any).
|
|
5
|
-
*/
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
7
|
-
import { createRequire } from "node:module";
|
|
8
1
|
import { mkdirSync } from "node:fs";
|
|
9
|
-
import {
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname } from "node:path";
|
|
10
4
|
import { runMigrations, type SqliteMigrationRunner } from "@danypops/vehicle-server/storage";
|
|
11
5
|
import { SQLITE_BUSY_TIMEOUT_MS, SQLITE_SCHEMA_VERSION } from "./constants.ts";
|
|
12
6
|
|
|
@@ -373,10 +367,22 @@ export function migrationLedger(db: Db): ModuleMigrationRow[] {
|
|
|
373
367
|
// has no ledger table yet -- "nothing recorded" is the correct answer, not an error.
|
|
374
368
|
const tableExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'module_migrations'").get() != null;
|
|
375
369
|
if (!tableExists) return [];
|
|
376
|
-
const rows = db
|
|
377
|
-
module_id
|
|
370
|
+
const rows = db
|
|
371
|
+
.prepare("SELECT module_id, version, name, checksum, applied_at FROM module_migrations ORDER BY module_id, version")
|
|
372
|
+
.all() as Array<{
|
|
373
|
+
module_id: string;
|
|
374
|
+
version: number;
|
|
375
|
+
name: string;
|
|
376
|
+
checksum: string;
|
|
377
|
+
applied_at: string;
|
|
378
378
|
}>;
|
|
379
|
-
return rows.map((row) => ({
|
|
379
|
+
return rows.map((row) => ({
|
|
380
|
+
moduleId: row.module_id,
|
|
381
|
+
version: row.version,
|
|
382
|
+
name: row.name,
|
|
383
|
+
checksum: row.checksum,
|
|
384
|
+
appliedAt: row.applied_at,
|
|
385
|
+
}));
|
|
380
386
|
}
|
|
381
387
|
|
|
382
388
|
/**
|
|
@@ -407,10 +413,14 @@ function ensureCoreLedger(db: Db, alreadyAtCurrentSchema: boolean): void {
|
|
|
407
413
|
`);
|
|
408
414
|
inTransaction(db, () => {
|
|
409
415
|
for (const [index, entry] of CORE_LEDGER_VERSIONS.entries()) {
|
|
410
|
-
const existingRow = db
|
|
416
|
+
const existingRow = db
|
|
417
|
+
.prepare("SELECT checksum FROM module_migrations WHERE module_id = 'core' AND version = ?")
|
|
418
|
+
.get(entry.version) as { checksum: string } | null;
|
|
411
419
|
if (existingRow != null) {
|
|
412
420
|
if (existingRow.checksum !== entry.checksum) {
|
|
413
|
-
throw new Error(
|
|
421
|
+
throw new Error(
|
|
422
|
+
`module migration "core" version ${entry.version} checksum mismatch: the frozen definition for this version was edited after it was applied`,
|
|
423
|
+
);
|
|
414
424
|
}
|
|
415
425
|
continue;
|
|
416
426
|
}
|
|
@@ -420,16 +430,18 @@ function ensureCoreLedger(db: Db, alreadyAtCurrentSchema: boolean): void {
|
|
|
420
430
|
db.exec(SEED_SQL);
|
|
421
431
|
db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
|
|
422
432
|
}
|
|
423
|
-
db.prepare("INSERT INTO module_migrations (module_id, version, name, checksum, applied_at) VALUES ('core', ?, ?, ?, ?)")
|
|
424
|
-
|
|
433
|
+
db.prepare("INSERT INTO module_migrations (module_id, version, name, checksum, applied_at) VALUES ('core', ?, ?, ?, ?)").run(
|
|
434
|
+
entry.version,
|
|
435
|
+
entry.name,
|
|
436
|
+
entry.checksum,
|
|
437
|
+
new Date().toISOString(),
|
|
438
|
+
);
|
|
425
439
|
}
|
|
426
440
|
});
|
|
427
441
|
}
|
|
428
442
|
|
|
429
443
|
function bootstrapEmptyDatabase(db: Db): void {
|
|
430
|
-
const existing = db
|
|
431
|
-
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1")
|
|
432
|
-
.get();
|
|
444
|
+
const existing = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
|
|
433
445
|
if (existing) throw new Error("database schema is unversioned; refusing to migrate existing data during boot");
|
|
434
446
|
ensureCoreLedger(db, false);
|
|
435
447
|
}
|
|
@@ -524,7 +536,9 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
524
536
|
// older user_version to exercise this migration path must not fail with "duplicate column
|
|
525
537
|
// name" -- the same class of already-bootstrapped-fixture concern version 9's comment covers.
|
|
526
538
|
up: (db) => {
|
|
527
|
-
const existing = new Set(
|
|
539
|
+
const existing = new Set(
|
|
540
|
+
(db.prepare("PRAGMA table_info(discussion_rounds)").all() as Array<{ name: string }>).map((row) => row.name),
|
|
541
|
+
);
|
|
528
542
|
for (const column of ["options", "options_mode", "selected"]) {
|
|
529
543
|
if (!existing.has(column)) db.exec(`ALTER TABLE discussion_rounds ADD COLUMN ${column} TEXT`);
|
|
530
544
|
}
|
|
@@ -568,7 +582,9 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
568
582
|
// pattern as version 16's discuss-options -- a round with no per-option description (every
|
|
569
583
|
// round before this feature existed, and any that simply doesn't need one) stores NULL.
|
|
570
584
|
up: (db) => {
|
|
571
|
-
const existing = new Set(
|
|
585
|
+
const existing = new Set(
|
|
586
|
+
(db.prepare("PRAGMA table_info(discussion_rounds)").all() as Array<{ name: string }>).map((row) => row.name),
|
|
587
|
+
);
|
|
572
588
|
if (!existing.has("option_descriptions")) db.exec("ALTER TABLE discussion_rounds ADD COLUMN option_descriptions TEXT");
|
|
573
589
|
},
|
|
574
590
|
},
|
|
@@ -621,7 +637,10 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
621
637
|
WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.note_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
622
638
|
BEGIN SELECT RAISE(ABORT, 'note_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
|
|
623
639
|
`);
|
|
624
|
-
const rows = db.prepare("SELECT id, extra FROM artifacts WHERE kind = 'doc' AND subtype = 'note'").all() as Array<{
|
|
640
|
+
const rows = db.prepare("SELECT id, extra FROM artifacts WHERE kind = 'doc' AND subtype = 'note'").all() as Array<{
|
|
641
|
+
id: string;
|
|
642
|
+
extra: string;
|
|
643
|
+
}>;
|
|
625
644
|
const insert = db.prepare(`
|
|
626
645
|
INSERT INTO note_events (note_id, occurred_at, event_type, actor, source, session_id, reason, related_id, disposition, event_schema_version)
|
|
627
646
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
@@ -629,20 +648,20 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
629
648
|
const strip = db.prepare("UPDATE artifacts SET extra = ? WHERE id = ?");
|
|
630
649
|
for (const row of rows) {
|
|
631
650
|
const extra = JSON.parse(row.extra) as Record<string, unknown>;
|
|
632
|
-
const noteHistory = extra
|
|
651
|
+
const noteHistory = extra.noteHistory;
|
|
633
652
|
if (Array.isArray(noteHistory)) {
|
|
634
653
|
for (const raw of noteHistory) {
|
|
635
654
|
const entry = raw as Record<string, unknown>;
|
|
636
655
|
insert.run(
|
|
637
656
|
row.id,
|
|
638
|
-
typeof entry
|
|
639
|
-
typeof entry
|
|
640
|
-
typeof entry
|
|
641
|
-
typeof entry
|
|
642
|
-
typeof entry
|
|
643
|
-
typeof entry
|
|
644
|
-
typeof entry
|
|
645
|
-
typeof entry
|
|
657
|
+
typeof entry.at === "string" ? entry.at : new Date().toISOString(),
|
|
658
|
+
typeof entry.action === "string" ? entry.action : "captured",
|
|
659
|
+
typeof entry.actor === "string" ? entry.actor : "system",
|
|
660
|
+
typeof entry.source === "string" ? entry.source : "unknown",
|
|
661
|
+
typeof entry.sessionId === "string" ? entry.sessionId : null,
|
|
662
|
+
typeof entry.reason === "string" ? entry.reason : null,
|
|
663
|
+
typeof entry.targetId === "string" ? entry.targetId : null,
|
|
664
|
+
typeof entry.disposition === "string" ? entry.disposition : null,
|
|
646
665
|
);
|
|
647
666
|
}
|
|
648
667
|
}
|
|
@@ -724,9 +743,10 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
724
743
|
// entire frozen chain, not merely fail to match any of its branches -- entering it and
|
|
725
744
|
// falling through to the final gap check would otherwise misreport a database correctly
|
|
726
745
|
// mid-way through FUTURE_MIGRATIONS as "no explicit migration path".
|
|
727
|
-
if (from < LEGACY_MIGRATION_CHAIN_TARGET_VERSION)
|
|
728
|
-
|
|
729
|
-
db
|
|
746
|
+
if (from < LEGACY_MIGRATION_CHAIN_TARGET_VERSION)
|
|
747
|
+
inTransaction(db, () => {
|
|
748
|
+
if (schemaVersion(db) === 1) {
|
|
749
|
+
db.exec(`
|
|
730
750
|
INSERT OR IGNORE INTO statuses VALUES ('todo','task');
|
|
731
751
|
INSERT OR IGNORE INTO statuses VALUES ('in-progress','task');
|
|
732
752
|
INSERT OR IGNORE INTO statuses VALUES ('review','task');
|
|
@@ -751,10 +771,10 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
751
771
|
DELETE FROM statuses WHERE kind = 'task' AND name IN ('pending', 'active', 'failed');
|
|
752
772
|
PRAGMA user_version = 2;
|
|
753
773
|
`);
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
774
|
+
applied.push("task-lifecycle-and-focus");
|
|
775
|
+
}
|
|
776
|
+
if (schemaVersion(db) === 2) {
|
|
777
|
+
db.exec(`
|
|
758
778
|
CREATE TABLE task_events (
|
|
759
779
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
760
780
|
task_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
@@ -777,10 +797,10 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
777
797
|
BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
|
|
778
798
|
PRAGMA user_version = 3;
|
|
779
799
|
`);
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
800
|
+
applied.push("task-history");
|
|
801
|
+
}
|
|
802
|
+
if (schemaVersion(db) === 3) {
|
|
803
|
+
db.exec(`
|
|
784
804
|
CREATE TABLE task_scopes (
|
|
785
805
|
task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
786
806
|
project_root TEXT,
|
|
@@ -800,18 +820,18 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
800
820
|
FROM artifacts WHERE kind = 'task';
|
|
801
821
|
PRAGMA user_version = 4;
|
|
802
822
|
`);
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
823
|
+
applied.push("task-project-scope");
|
|
824
|
+
}
|
|
825
|
+
if (schemaVersion(db) === 4) {
|
|
826
|
+
db.exec(`
|
|
807
827
|
ALTER TABLE task_focus ADD COLUMN status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused'));
|
|
808
828
|
ALTER TABLE task_focus ADD COLUMN pause_reason TEXT;
|
|
809
829
|
PRAGMA user_version = 5;
|
|
810
830
|
`);
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
831
|
+
applied.push("task-focus-continuation");
|
|
832
|
+
}
|
|
833
|
+
if (schemaVersion(db) === 5) {
|
|
834
|
+
db.exec(`
|
|
815
835
|
INSERT OR IGNORE INTO relation_names VALUES ('reply_to','Append-only message replies to another message in the same thread');
|
|
816
836
|
INSERT OR IGNORE INTO relation_names VALUES ('discusses','Message or turn concerns a verified artifact');
|
|
817
837
|
CREATE TABLE discourse_threads (
|
|
@@ -853,10 +873,10 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
853
873
|
BEGIN SELECT RAISE(ABORT, 'discourse Context Mesh artifact type is immutable'); END;
|
|
854
874
|
PRAGMA user_version = 6;
|
|
855
875
|
`);
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
876
|
+
applied.push("discourse-context-mesh");
|
|
877
|
+
}
|
|
878
|
+
if (schemaVersion(db) === 6) {
|
|
879
|
+
db.exec(`
|
|
860
880
|
CREATE TABLE artifact_events (
|
|
861
881
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
862
882
|
artifact_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
@@ -881,10 +901,10 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
881
901
|
BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only'); END;
|
|
882
902
|
PRAGMA user_version = 7;
|
|
883
903
|
`);
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
904
|
+
applied.push("artifact-event-log");
|
|
905
|
+
}
|
|
906
|
+
if (schemaVersion(db) === 7) {
|
|
907
|
+
db.exec(`
|
|
888
908
|
CREATE TABLE task_focus_v7 (
|
|
889
909
|
scope TEXT PRIMARY KEY,
|
|
890
910
|
task_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
@@ -897,15 +917,15 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
897
917
|
ALTER TABLE task_focus_v7 RENAME TO task_focus;
|
|
898
918
|
PRAGMA user_version = 8;
|
|
899
919
|
`);
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
920
|
+
applied.push("task-focus-session-scope");
|
|
921
|
+
}
|
|
922
|
+
if (schemaVersion(db) === 8) {
|
|
923
|
+
// IF NOT EXISTS here, unlike earlier migration branches: a fully-bootstrapped
|
|
924
|
+
// :memory: fixture (used by unrelated tests that only roll user_version back to
|
|
925
|
+
// simulate an older *file* database) already has every table the current bootstrap
|
|
926
|
+
// DDL declares, this one included -- so this branch must be safe to run whether or
|
|
927
|
+
// not that already happened, not assume a truly-old database created it first.
|
|
928
|
+
db.exec(`
|
|
909
929
|
CREATE TABLE IF NOT EXISTS graph_projection_checkpoints (
|
|
910
930
|
producer_id TEXT PRIMARY KEY,
|
|
911
931
|
last_sequence INTEGER NOT NULL,
|
|
@@ -921,10 +941,10 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
921
941
|
CREATE INDEX IF NOT EXISTS graph_projection_identities_artifact_idx ON graph_projection_identities(artifact_id);
|
|
922
942
|
PRAGMA user_version = 9;
|
|
923
943
|
`);
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
944
|
+
applied.push("graph-projection-protocol");
|
|
945
|
+
}
|
|
946
|
+
if (schemaVersion(db) === 9) {
|
|
947
|
+
db.exec(`
|
|
928
948
|
CREATE TABLE IF NOT EXISTS artifact_scopes (
|
|
929
949
|
artifact_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
930
950
|
project_root TEXT,
|
|
@@ -934,10 +954,10 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
934
954
|
CREATE INDEX IF NOT EXISTS artifact_scopes_project_idx ON artifact_scopes(project_root, artifact_id);
|
|
935
955
|
PRAGMA user_version = 10;
|
|
936
956
|
`);
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
957
|
+
applied.push("docs-rules-skills-project-scope");
|
|
958
|
+
}
|
|
959
|
+
if (schemaVersion(db) === 10) {
|
|
960
|
+
db.exec(`
|
|
941
961
|
CREATE TABLE IF NOT EXISTS log_sources (
|
|
942
962
|
id TEXT PRIMARY KEY,
|
|
943
963
|
label TEXT NOT NULL,
|
|
@@ -961,18 +981,18 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
961
981
|
BEGIN SELECT RAISE(ABORT, 'log_entries are immutable once written; retention trimming is the only supported deletion path'); END;
|
|
962
982
|
PRAGMA user_version = 11;
|
|
963
983
|
`);
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
984
|
+
applied.push("log-domain");
|
|
985
|
+
}
|
|
986
|
+
if (schemaVersion(db) === 11) {
|
|
987
|
+
// Removes Discourse's Papyrus-embedded storage entirely: confirmed zero rows in every
|
|
988
|
+
// discourse_* table and zero Docs carrying the reserved context-thread/context-message
|
|
989
|
+
// subtypes in the real production database before this was written -- Discourse's real
|
|
990
|
+
// home is now the standalone @danypops/discourse package plus host adapters, and
|
|
991
|
+
// Papyrus's own copy never had a single real caller since it was built. IF EXISTS
|
|
992
|
+
// throughout: a database that never actually reached the v5->v6 discourse-context-mesh
|
|
993
|
+
// step in the first place (e.g. a test fixture that starts partway through the chain)
|
|
994
|
+
// must not fail here just because there was nothing to remove.
|
|
995
|
+
db.exec(`
|
|
976
996
|
DROP TRIGGER IF EXISTS discourse_artifact_type_immutable;
|
|
977
997
|
DROP TRIGGER IF EXISTS discourse_posts_artifact_type;
|
|
978
998
|
DROP TRIGGER IF EXISTS discourse_threads_artifact_type;
|
|
@@ -985,14 +1005,14 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
985
1005
|
DELETE FROM relation_names WHERE name IN ('reply_to', 'discusses');
|
|
986
1006
|
PRAGMA user_version = 12;
|
|
987
1007
|
`);
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1008
|
+
applied.push("remove-discourse");
|
|
1009
|
+
}
|
|
1010
|
+
if (schemaVersion(db) === 12) {
|
|
1011
|
+
// See domain/session-identity.ts and verify-caller-identity-behind-papyrus-mutation-
|
|
1012
|
+
// attribution-koxt: first-touch capability binding for session_id, the one place it is
|
|
1013
|
+
// behavior-affecting today (Task Focus). Purely additive -- a session_id that never
|
|
1014
|
+
// registers here behaves exactly as before.
|
|
1015
|
+
db.exec(`
|
|
996
1016
|
CREATE TABLE IF NOT EXISTS session_identities (
|
|
997
1017
|
session_id TEXT PRIMARY KEY,
|
|
998
1018
|
secret_hash TEXT NOT NULL,
|
|
@@ -1001,10 +1021,11 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
1001
1021
|
);
|
|
1002
1022
|
PRAGMA user_version = 13;
|
|
1003
1023
|
`);
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1024
|
+
applied.push("session-identity");
|
|
1025
|
+
}
|
|
1026
|
+
if (schemaVersion(db) !== LEGACY_MIGRATION_CHAIN_TARGET_VERSION)
|
|
1027
|
+
throw new Error(`no explicit migration path from database schema ${from}`);
|
|
1028
|
+
});
|
|
1008
1029
|
|
|
1009
1030
|
// Guarded by length: runMigrations treats an empty migrations array's "target version" as
|
|
1010
1031
|
// 0 (see its own sorted.at(-1) ?? 0), which would misreport a database the legacy chain
|