@danypops/papyrus 0.33.5 → 0.34.0
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/extension/src/domain-tools.ts +8 -1
- package/package.json +1 -1
- package/src/adapters/sqlite-note-event-store.ts +80 -0
- package/src/cli.ts +9 -2
- package/src/constants.ts +3 -2
- package/src/db.ts +79 -0
- package/src/domain/note-event.ts +71 -0
- package/src/modules/notes.ts +5 -1
- package/src/note-service.ts +37 -48
- package/src/ops.ts +5 -4
- package/src/ports/note-event-store.ts +38 -0
- package/src/service.ts +4 -1
|
@@ -6,6 +6,7 @@ import { PROOF_TYPES } from "../../src/domain/checklist.ts";
|
|
|
6
6
|
import type { GateResult } from "../../src/domain/gate.ts";
|
|
7
7
|
import type { TaskExecutionPlan } from "../../src/task-execution.ts";
|
|
8
8
|
import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
|
|
9
|
+
import type { NoteHistoryPage } from "../../src/domain/note-event.ts";
|
|
9
10
|
import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
|
|
10
11
|
import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
|
|
11
12
|
import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
|
|
@@ -474,7 +475,7 @@ export function registerNotesTool(pi: ExtensionAPI): void {
|
|
|
474
475
|
pi.registerTool({
|
|
475
476
|
name: "notes",
|
|
476
477
|
label: "Notes",
|
|
477
|
-
description: "Deferred human-intent inbox. ACTIONS: capture, list, show, consume, promote, archive. Capture stores a request without creating work. Consume marks it considered. To promote, first create the resulting Task, Doc, Rule, or Skill through its domain tool, then link it with target_id (or target_name). Archive requires an explicit disposition. PREFER `name` (the note's exact title) over `id` for show/consume/promote/archive, and `target_name` over `target_id` for promote -- all are backend implementation details, resolved from name automatically (target_name searches across every kind, since a promotion target can be a task, doc, rule, or skill).",
|
|
478
|
+
description: "Deferred human-intent inbox. ACTIONS: capture, list, show, history, consume, promote, archive. Capture stores a request without creating work. Consume marks it considered. To promote, first create the resulting Task, Doc, Rule, or Skill through its domain tool, then link it with target_id (or target_name). Archive requires an explicit disposition. history returns this note's own real append-only event log (captured/consumed/promoted/archived), not the generic cross-kind graph.history. PREFER `name` (the note's exact title) over `id` for show/history/consume/promote/archive, and `target_name` over `target_id` for promote -- all are backend implementation details, resolved from name automatically (target_name searches across every kind, since a promotion target can be a task, doc, rule, or skill).",
|
|
478
479
|
parameters: Type.Object({
|
|
479
480
|
action: Type.String(),
|
|
480
481
|
id: Type.Optional(Type.String()),
|
|
@@ -516,6 +517,12 @@ export function registerNotesTool(pi: ExtensionAPI): void {
|
|
|
516
517
|
const artifact = await callService<Record<string, unknown>, Artifact>("notes.show", request);
|
|
517
518
|
return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("notes.show", artifact));
|
|
518
519
|
}
|
|
520
|
+
if (action === "history") {
|
|
521
|
+
const page = await callService<Record<string, unknown>, NoteHistoryPage>("notes.history", request);
|
|
522
|
+
const lines = page.events.map((event) => `${event.occurredAt} ${event.type} · ${event.actor}/${event.source}${event.relatedId ? ` · ${event.relatedId}` : ""}${event.disposition ? ` · ${event.disposition}` : ""}${event.reason ? ` · ${event.reason}` : ""}`);
|
|
523
|
+
const output = lines.join("\n") || "No recorded history for this note.";
|
|
524
|
+
return text(output, createPreviewDetails("notes.history", "Note history", output));
|
|
525
|
+
}
|
|
519
526
|
const operations = { consume: "notes.consume", promote: "notes.promote", archive: "notes.archive" } as const;
|
|
520
527
|
const operation = operations[action as keyof typeof operations];
|
|
521
528
|
if (!operation) throw new Error(`unknown notes action: ${action}`);
|
package/package.json
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Db } from "../db.ts";
|
|
2
|
+
import {
|
|
3
|
+
normalizeNoteHistoryQuery,
|
|
4
|
+
validateNoteEvent,
|
|
5
|
+
type AppendNoteEvent,
|
|
6
|
+
type NoteEvent,
|
|
7
|
+
type NoteEventType,
|
|
8
|
+
type NoteHistoryPage,
|
|
9
|
+
type NoteHistoryQuery,
|
|
10
|
+
} from "../domain/note-event.ts";
|
|
11
|
+
import type { NoteEventStore } from "../ports/note-event-store.ts";
|
|
12
|
+
|
|
13
|
+
interface NoteEventRow {
|
|
14
|
+
id: number;
|
|
15
|
+
note_id: string;
|
|
16
|
+
occurred_at: string;
|
|
17
|
+
event_type: NoteEventType;
|
|
18
|
+
actor: string;
|
|
19
|
+
source: string;
|
|
20
|
+
session_id: string | null;
|
|
21
|
+
reason: string | null;
|
|
22
|
+
related_id: string | null;
|
|
23
|
+
disposition: string | null;
|
|
24
|
+
event_schema_version: 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mapRow(row: NoteEventRow): NoteEvent {
|
|
28
|
+
return {
|
|
29
|
+
id: row.id,
|
|
30
|
+
noteId: row.note_id,
|
|
31
|
+
occurredAt: row.occurred_at,
|
|
32
|
+
type: row.event_type,
|
|
33
|
+
actor: row.actor,
|
|
34
|
+
source: row.source,
|
|
35
|
+
...(row.session_id === null ? {} : { sessionId: row.session_id }),
|
|
36
|
+
...(row.reason === null ? {} : { reason: row.reason }),
|
|
37
|
+
...(row.related_id === null ? {} : { relatedId: row.related_id }),
|
|
38
|
+
...(row.disposition === null ? {} : { disposition: row.disposition }),
|
|
39
|
+
schemaVersion: row.event_schema_version,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class SQLiteNoteEventStore implements NoteEventStore {
|
|
44
|
+
constructor(private readonly db: Db) {}
|
|
45
|
+
|
|
46
|
+
append(input: AppendNoteEvent): NoteEvent {
|
|
47
|
+
const event = validateNoteEvent(input);
|
|
48
|
+
const result = this.db.prepare(`
|
|
49
|
+
INSERT INTO note_events (
|
|
50
|
+
note_id, occurred_at, event_type, actor, source, session_id, reason, related_id, disposition, event_schema_version
|
|
51
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
52
|
+
`).run(
|
|
53
|
+
event.noteId,
|
|
54
|
+
new Date().toISOString(),
|
|
55
|
+
event.type,
|
|
56
|
+
event.actor,
|
|
57
|
+
event.source,
|
|
58
|
+
event.sessionId ?? null,
|
|
59
|
+
event.reason ?? null,
|
|
60
|
+
event.relatedId ?? null,
|
|
61
|
+
event.disposition ?? null,
|
|
62
|
+
);
|
|
63
|
+
return mapRow(this.db.prepare("SELECT * FROM note_events WHERE id = ?").get(result.lastInsertRowid) as NoteEventRow);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
history(noteId: string, query: NoteHistoryQuery = {}): NoteHistoryPage {
|
|
67
|
+
const { limit, direction, cursor } = normalizeNoteHistoryQuery(query);
|
|
68
|
+
const comparator = direction === "desc" ? "<" : ">";
|
|
69
|
+
const order = direction === "desc" ? "DESC" : "ASC";
|
|
70
|
+
const rows = this.db.prepare(`
|
|
71
|
+
SELECT * FROM note_events
|
|
72
|
+
WHERE note_id = ? ${cursor === undefined ? "" : `AND id ${comparator} ?`}
|
|
73
|
+
ORDER BY occurred_at ${order}, id ${order}
|
|
74
|
+
LIMIT ?
|
|
75
|
+
`).all(...(cursor === undefined ? [noteId, limit + 1] : [noteId, cursor, limit + 1])) as NoteEventRow[];
|
|
76
|
+
const hasMore = rows.length > limit;
|
|
77
|
+
const events = rows.slice(0, limit).map(mapRow);
|
|
78
|
+
return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -1274,7 +1274,7 @@ export async function runNoteCli(args: string[], client: TaskCliClient, projectR
|
|
|
1274
1274
|
positional.push(argument);
|
|
1275
1275
|
}
|
|
1276
1276
|
const [action, id, target] = positional;
|
|
1277
|
-
let result: CliArtifact | CliArtifact[];
|
|
1277
|
+
let result: CliArtifact | CliArtifact[] | import("./domain/note-event.ts").NoteHistoryPage;
|
|
1278
1278
|
let human: string;
|
|
1279
1279
|
if (action === "capture") {
|
|
1280
1280
|
if (!id || target) throw new Error("notes capture requires exactly one request argument");
|
|
@@ -1288,6 +1288,13 @@ export async function runNoteCli(args: string[], client: TaskCliClient, projectR
|
|
|
1288
1288
|
if (!id || target) throw new Error("notes show requires exactly one note id");
|
|
1289
1289
|
result = await client.call("notes.show", { id, project_root: projectRoot }) as CliArtifact;
|
|
1290
1290
|
human = `${artifactLabel(result)}\n\n${result.body ?? ""}`.trimEnd();
|
|
1291
|
+
} else if (action === "history") {
|
|
1292
|
+
if (!id || target) throw new Error("notes history requires exactly one note id");
|
|
1293
|
+
const page = await client.call<Record<string, unknown>, import("./domain/note-event.ts").NoteHistoryPage>("notes.history", { id, project_root: projectRoot, direction: "desc", ...(limit === undefined ? {} : { limit }) });
|
|
1294
|
+
result = page;
|
|
1295
|
+
human = page.events.length === 0
|
|
1296
|
+
? `No recorded history for ${id}.`
|
|
1297
|
+
: [...page.events].reverse().map((event) => `${event.occurredAt} ${event.type} · ${event.actor}/${event.source}${event.relatedId ? ` · ${event.relatedId}` : ""}${event.disposition ? ` · ${event.disposition}` : ""}${event.reason ? ` · ${event.reason}` : ""}`).join("\n");
|
|
1291
1298
|
} else if (action === "consume") {
|
|
1292
1299
|
if (!id || target) throw new Error("notes consume requires exactly one note id");
|
|
1293
1300
|
result = await client.call("notes.consume", { id, project_root: projectRoot, actor: "agent", source: "cli", ...(reason ? { reason } : {}) }) as CliArtifact;
|
|
@@ -1301,7 +1308,7 @@ export async function runNoteCli(args: string[], client: TaskCliClient, projectR
|
|
|
1301
1308
|
result = await client.call("notes.archive", { id, disposition: target, project_root: projectRoot, actor: "human", source: "cli", ...(reason ? { reason } : {}) }) as CliArtifact;
|
|
1302
1309
|
human = `Archived: ${artifactLabel(result)} · ${target}`;
|
|
1303
1310
|
} else {
|
|
1304
|
-
throw new Error("notes action must be capture, list, show, consume, promote, or archive");
|
|
1311
|
+
throw new Error("notes action must be capture, list, show, history, consume, promote, or archive");
|
|
1305
1312
|
}
|
|
1306
1313
|
return json ? JSON.stringify(result) : human;
|
|
1307
1314
|
}
|
package/src/constants.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
|
7
7
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
8
8
|
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
9
9
|
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
10
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
10
|
+
export const SQLITE_SCHEMA_VERSION = 21;
|
|
11
11
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
12
12
|
|
|
13
13
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
@@ -156,9 +156,10 @@ export const NOTE_BODY_MAX_CHARACTERS = 10_000;
|
|
|
156
156
|
export const NOTE_TITLE_MAX_CHARACTERS = 80;
|
|
157
157
|
export const NOTE_LIST_DEFAULT_LIMIT = 50;
|
|
158
158
|
export const NOTE_LIST_MAX_LIMIT = 200;
|
|
159
|
-
export const NOTE_HISTORY_MAX_EVENTS = 20;
|
|
160
159
|
export const NOTE_PROVENANCE_MAX_LENGTH = 128;
|
|
161
160
|
export const NOTE_REASON_MAX_CHARACTERS = 2_000;
|
|
161
|
+
export const NOTE_HISTORY_DEFAULT_LIMIT = 25;
|
|
162
|
+
export const NOTE_HISTORY_MAX_LIMIT = 100;
|
|
162
163
|
/** Generic, kind-agnostic mutation event log bounds (doc/task/rule/skill share one log). */
|
|
163
164
|
export const ARTIFACT_EVENT_ACTOR_MAX_LENGTH = 128;
|
|
164
165
|
export const ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT = 25;
|
package/src/db.ts
CHANGED
|
@@ -266,6 +266,25 @@ CREATE TABLE IF NOT EXISTS task_leases (
|
|
|
266
266
|
note TEXT
|
|
267
267
|
);
|
|
268
268
|
CREATE INDEX IF NOT EXISTS task_leases_expiry_idx ON task_leases(lease_expires_at);
|
|
269
|
+
CREATE TABLE IF NOT EXISTS note_events (
|
|
270
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
271
|
+
note_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
272
|
+
occurred_at TEXT NOT NULL,
|
|
273
|
+
event_type TEXT NOT NULL,
|
|
274
|
+
actor TEXT NOT NULL,
|
|
275
|
+
source TEXT NOT NULL,
|
|
276
|
+
session_id TEXT,
|
|
277
|
+
reason TEXT,
|
|
278
|
+
related_id TEXT,
|
|
279
|
+
disposition TEXT,
|
|
280
|
+
event_schema_version INTEGER NOT NULL DEFAULT 1
|
|
281
|
+
);
|
|
282
|
+
CREATE INDEX IF NOT EXISTS note_events_history_idx ON note_events(note_id, occurred_at, id);
|
|
283
|
+
CREATE TRIGGER IF NOT EXISTS note_events_no_update BEFORE UPDATE ON note_events
|
|
284
|
+
BEGIN SELECT RAISE(ABORT, 'note_events are append-only'); END;
|
|
285
|
+
CREATE TRIGGER IF NOT EXISTS note_events_no_delete BEFORE DELETE ON note_events
|
|
286
|
+
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'))
|
|
287
|
+
BEGIN SELECT RAISE(ABORT, 'note_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
|
|
269
288
|
`;
|
|
270
289
|
|
|
271
290
|
const SEED_SQL = `
|
|
@@ -576,6 +595,66 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
576
595
|
`);
|
|
577
596
|
},
|
|
578
597
|
},
|
|
598
|
+
{
|
|
599
|
+
version: 21,
|
|
600
|
+
name: "note-events",
|
|
601
|
+
// See domain/note-event.ts. Retires extra.noteHistory (a bounded array inside a mutable
|
|
602
|
+
// JSON field, which a later setExtra from a concurrent operation could silently overwrite)
|
|
603
|
+
// in favor of a real append-only table, mirroring task_events' already-proven shape.
|
|
604
|
+
up: (db) => {
|
|
605
|
+
db.exec(`
|
|
606
|
+
CREATE TABLE IF NOT EXISTS note_events (
|
|
607
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
608
|
+
note_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
609
|
+
occurred_at TEXT NOT NULL,
|
|
610
|
+
event_type TEXT NOT NULL,
|
|
611
|
+
actor TEXT NOT NULL,
|
|
612
|
+
source TEXT NOT NULL,
|
|
613
|
+
session_id TEXT,
|
|
614
|
+
reason TEXT,
|
|
615
|
+
related_id TEXT,
|
|
616
|
+
disposition TEXT,
|
|
617
|
+
event_schema_version INTEGER NOT NULL DEFAULT 1
|
|
618
|
+
);
|
|
619
|
+
CREATE INDEX IF NOT EXISTS note_events_history_idx ON note_events(note_id, occurred_at, id);
|
|
620
|
+
CREATE TRIGGER IF NOT EXISTS note_events_no_update BEFORE UPDATE ON note_events
|
|
621
|
+
BEGIN SELECT RAISE(ABORT, 'note_events are append-only'); END;
|
|
622
|
+
CREATE TRIGGER IF NOT EXISTS note_events_no_delete BEFORE DELETE ON note_events
|
|
623
|
+
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'))
|
|
624
|
+
BEGIN SELECT RAISE(ABORT, 'note_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
|
|
625
|
+
`);
|
|
626
|
+
const rows = db.prepare("SELECT id, extra FROM artifacts WHERE kind = 'doc' AND subtype = 'note'").all() as Array<{ id: string; extra: string }>;
|
|
627
|
+
const insert = db.prepare(`
|
|
628
|
+
INSERT INTO note_events (note_id, occurred_at, event_type, actor, source, session_id, reason, related_id, disposition, event_schema_version)
|
|
629
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
630
|
+
`);
|
|
631
|
+
const strip = db.prepare("UPDATE artifacts SET extra = ? WHERE id = ?");
|
|
632
|
+
for (const row of rows) {
|
|
633
|
+
const extra = JSON.parse(row.extra) as Record<string, unknown>;
|
|
634
|
+
const noteHistory = extra["noteHistory"];
|
|
635
|
+
if (Array.isArray(noteHistory)) {
|
|
636
|
+
for (const raw of noteHistory) {
|
|
637
|
+
const entry = raw as Record<string, unknown>;
|
|
638
|
+
insert.run(
|
|
639
|
+
row.id,
|
|
640
|
+
typeof entry["at"] === "string" ? entry["at"] : new Date().toISOString(),
|
|
641
|
+
typeof entry["action"] === "string" ? entry["action"] : "captured",
|
|
642
|
+
typeof entry["actor"] === "string" ? entry["actor"] : "system",
|
|
643
|
+
typeof entry["source"] === "string" ? entry["source"] : "unknown",
|
|
644
|
+
typeof entry["sessionId"] === "string" ? entry["sessionId"] : null,
|
|
645
|
+
typeof entry["reason"] === "string" ? entry["reason"] : null,
|
|
646
|
+
typeof entry["targetId"] === "string" ? entry["targetId"] : null,
|
|
647
|
+
typeof entry["disposition"] === "string" ? entry["disposition"] : null,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if ("noteHistory" in extra) {
|
|
652
|
+
const { noteHistory: _drop, ...rest } = extra;
|
|
653
|
+
strip.run(JSON.stringify(rest), row.id);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
},
|
|
579
658
|
];
|
|
580
659
|
|
|
581
660
|
/**
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notes' own append-only event log, mirroring task-event.ts's proven shape. Replaces the
|
|
3
|
+
* previous extra.noteHistory: a bounded array inside a mutable JSON field, which a later
|
|
4
|
+
* setExtra from a concurrent operation could overwrite -- a real record can't do that.
|
|
5
|
+
*/
|
|
6
|
+
import { NOTE_HISTORY_DEFAULT_LIMIT, NOTE_HISTORY_MAX_LIMIT, NOTE_PROVENANCE_MAX_LENGTH, NOTE_REASON_MAX_CHARACTERS } from "../constants.ts";
|
|
7
|
+
|
|
8
|
+
export const NOTE_EVENT_TYPES = ["captured", "consumed", "promoted", "archived"] as const;
|
|
9
|
+
export type NoteEventType = typeof NOTE_EVENT_TYPES[number];
|
|
10
|
+
export type NoteEventDirection = "asc" | "desc";
|
|
11
|
+
|
|
12
|
+
export interface NoteEvent {
|
|
13
|
+
id: number;
|
|
14
|
+
noteId: string;
|
|
15
|
+
occurredAt: string;
|
|
16
|
+
type: NoteEventType;
|
|
17
|
+
actor: string;
|
|
18
|
+
source: string;
|
|
19
|
+
sessionId?: string;
|
|
20
|
+
reason?: string;
|
|
21
|
+
/** Promotion target, when type is "promoted". */
|
|
22
|
+
relatedId?: string;
|
|
23
|
+
/** Archive disposition, or "promoted" -- mirrors the summary already kept at extra.disposition. */
|
|
24
|
+
disposition?: string;
|
|
25
|
+
schemaVersion: 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AppendNoteEvent {
|
|
29
|
+
noteId: string;
|
|
30
|
+
type: NoteEventType;
|
|
31
|
+
actor: string;
|
|
32
|
+
source: string;
|
|
33
|
+
sessionId?: string;
|
|
34
|
+
reason?: string;
|
|
35
|
+
relatedId?: string;
|
|
36
|
+
disposition?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NoteHistoryQuery {
|
|
40
|
+
limit?: number;
|
|
41
|
+
cursor?: number;
|
|
42
|
+
direction?: NoteEventDirection;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface NoteHistoryPage {
|
|
46
|
+
events: NoteEvent[];
|
|
47
|
+
nextCursor?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeNoteHistoryQuery(query: NoteHistoryQuery = {}): Required<Pick<NoteHistoryQuery, "limit" | "direction">> & Pick<NoteHistoryQuery, "cursor"> {
|
|
51
|
+
const limit = query.limit ?? NOTE_HISTORY_DEFAULT_LIMIT;
|
|
52
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > NOTE_HISTORY_MAX_LIMIT) {
|
|
53
|
+
throw new Error(`note history limit must be between 1 and ${NOTE_HISTORY_MAX_LIMIT}`);
|
|
54
|
+
}
|
|
55
|
+
if (query.cursor !== undefined && (!Number.isInteger(query.cursor) || query.cursor < 1)) {
|
|
56
|
+
throw new Error("note history cursor must be a positive integer");
|
|
57
|
+
}
|
|
58
|
+
if (query.direction !== undefined && query.direction !== "asc" && query.direction !== "desc") {
|
|
59
|
+
throw new Error("note history direction must be asc or desc");
|
|
60
|
+
}
|
|
61
|
+
return { limit, direction: query.direction ?? "desc", ...(query.cursor === undefined ? {} : { cursor: query.cursor }) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function validateNoteEvent(event: AppendNoteEvent): AppendNoteEvent {
|
|
65
|
+
for (const [field, value] of [["actor", event.actor], ["source", event.source]] as const) {
|
|
66
|
+
if (!value || value.length > NOTE_PROVENANCE_MAX_LENGTH) throw new Error(`${field} must be between 1 and ${NOTE_PROVENANCE_MAX_LENGTH} characters`);
|
|
67
|
+
}
|
|
68
|
+
if (event.sessionId !== undefined && event.sessionId.length > NOTE_PROVENANCE_MAX_LENGTH) throw new Error(`sessionId cannot exceed ${NOTE_PROVENANCE_MAX_LENGTH} characters`);
|
|
69
|
+
if (event.reason !== undefined && event.reason.length > NOTE_REASON_MAX_CHARACTERS) throw new Error(`reason cannot exceed ${NOTE_REASON_MAX_CHARACTERS} characters`);
|
|
70
|
+
return event;
|
|
71
|
+
}
|
package/src/modules/notes.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* module's infrastructure" constraint.
|
|
14
14
|
*/
|
|
15
15
|
import type { OperationDefinition } from "../module-registry.ts";
|
|
16
|
+
import type { NoteEventDirection } from "../domain/note-event.ts";
|
|
16
17
|
import { Notes, type NoteDisposition } from "../note-service.ts";
|
|
17
18
|
|
|
18
19
|
const MODULE_ID = "notes";
|
|
@@ -41,7 +42,7 @@ function optionalNumber(input: OperationInput, key: string): number | undefined
|
|
|
41
42
|
|
|
42
43
|
/** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
|
|
43
44
|
export const NOTES_OPERATION_NAMES = [
|
|
44
|
-
"notes.capture", "notes.list", "notes.show", "notes.consume", "notes.promote", "notes.archive",
|
|
45
|
+
"notes.capture", "notes.list", "notes.show", "notes.history", "notes.consume", "notes.promote", "notes.archive",
|
|
45
46
|
] as const;
|
|
46
47
|
|
|
47
48
|
/** Registers every notes.* operation against one Notes instance. Behavior is unchanged from the prior inline handlers in src/service.ts. */
|
|
@@ -59,6 +60,9 @@ export function notesOperations(notes: Notes): OperationDefinition[] {
|
|
|
59
60
|
text: optionalString(input, "text"), limit: optionalNumber(input, "limit"),
|
|
60
61
|
})),
|
|
61
62
|
define("notes.show", (input: OperationInput) => notes.show(string(input, "id"), string(input, "project_root"))),
|
|
63
|
+
define("notes.history", (input: OperationInput) => notes.history(string(input, "id"), string(input, "project_root"), {
|
|
64
|
+
limit: optionalNumber(input, "limit"), cursor: optionalNumber(input, "cursor"), direction: optionalString(input, "direction") as NoteEventDirection | undefined,
|
|
65
|
+
})),
|
|
62
66
|
define("notes.consume", (input: OperationInput) => notes.consume(string(input, "id"), {
|
|
63
67
|
projectRoot: string(input, "project_root"), actor: optionalString(input, "actor"), source: optionalString(input, "source"),
|
|
64
68
|
sessionId: optionalString(input, "session_id"), reason: optionalString(input, "reason"),
|
package/src/note-service.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
NOTE_BODY_MAX_CHARACTERS,
|
|
3
|
-
NOTE_HISTORY_MAX_EVENTS,
|
|
4
3
|
NOTE_LIST_DEFAULT_LIMIT,
|
|
5
4
|
NOTE_LIST_MAX_LIMIT,
|
|
6
5
|
NOTE_PROVENANCE_MAX_LENGTH,
|
|
@@ -9,6 +8,8 @@ import {
|
|
|
9
8
|
TASK_PROJECT_ROOT_MAX_LENGTH,
|
|
10
9
|
} from "./constants.ts";
|
|
11
10
|
import type { Artifact } from "./domain/artifact.ts";
|
|
11
|
+
import type { AppendNoteEvent, NoteEventType, NoteHistoryPage, NoteHistoryQuery } from "./domain/note-event.ts";
|
|
12
|
+
import { InMemoryNoteEventStore, type NoteEventStore } from "./ports/note-event-store.ts";
|
|
12
13
|
import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
|
|
13
14
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
14
15
|
|
|
@@ -41,17 +42,6 @@ export interface ArchiveNoteInput extends NoteProvenance {
|
|
|
41
42
|
disposition: NoteDisposition;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
interface NoteHistoryEvent {
|
|
45
|
-
action: "captured" | "consumed" | "promoted" | "archived";
|
|
46
|
-
at: string;
|
|
47
|
-
actor: string;
|
|
48
|
-
source: string;
|
|
49
|
-
sessionId?: string;
|
|
50
|
-
reason?: string;
|
|
51
|
-
targetId?: string;
|
|
52
|
-
disposition?: NoteDisposition | "promoted";
|
|
53
|
-
}
|
|
54
|
-
|
|
55
45
|
function requiredBounded(value: string, field: string, maximum: number): string {
|
|
56
46
|
const normalized = value.trim();
|
|
57
47
|
if (!normalized) throw new Error(`${field} is required`);
|
|
@@ -70,7 +60,7 @@ function noteTitle(body: string, requested?: string): string {
|
|
|
70
60
|
return firstLine.slice(0, NOTE_TITLE_MAX_CHARACTERS) || "Deferred note";
|
|
71
61
|
}
|
|
72
62
|
|
|
73
|
-
function provenance(input: NoteProvenance, defaults: { actor: string; source: string }): Omit<
|
|
63
|
+
function provenance(input: NoteProvenance, defaults: { actor: string; source: string }): Omit<AppendNoteEvent, "noteId" | "type" | "relatedId" | "disposition"> {
|
|
74
64
|
return {
|
|
75
65
|
actor: optionalBounded(input.actor, "note actor", NOTE_PROVENANCE_MAX_LENGTH) ?? defaults.actor,
|
|
76
66
|
source: optionalBounded(input.source, "note source", NOTE_PROVENANCE_MAX_LENGTH) ?? defaults.source,
|
|
@@ -79,44 +69,26 @@ function provenance(input: NoteProvenance, defaults: { actor: string; source: st
|
|
|
79
69
|
};
|
|
80
70
|
}
|
|
81
71
|
|
|
82
|
-
function history(artifact: Artifact): NoteHistoryEvent[] {
|
|
83
|
-
const value = artifact.extra["noteHistory"];
|
|
84
|
-
if (!Array.isArray(value)) return [];
|
|
85
|
-
return value.filter((entry): entry is NoteHistoryEvent => typeof entry === "object" && entry !== null && !Array.isArray(entry));
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function appendHistory(artifact: Artifact, event: NoteHistoryEvent): Record<string, unknown> {
|
|
89
|
-
return {
|
|
90
|
-
...artifact.extra,
|
|
91
|
-
noteHistory: [...history(artifact), event].slice(-NOTE_HISTORY_MAX_EVENTS),
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function event(action: NoteHistoryEvent["action"], input: NoteProvenance, extra: Partial<NoteHistoryEvent> = {}): NoteHistoryEvent {
|
|
96
|
-
return {
|
|
97
|
-
action,
|
|
98
|
-
at: new Date().toISOString(),
|
|
99
|
-
...provenance(input, { actor: action === "captured" ? "human" : "agent", source: "notes" }),
|
|
100
|
-
...extra,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
72
|
export class Notes {
|
|
105
|
-
constructor(
|
|
73
|
+
constructor(
|
|
74
|
+
private readonly artifacts: ArtifactStore,
|
|
75
|
+
private readonly events: NoteEventStore = new InMemoryNoteEventStore(),
|
|
76
|
+
) {}
|
|
106
77
|
|
|
107
78
|
capture(input: CaptureNoteInput): Artifact {
|
|
108
79
|
const projectRoot = requiredBounded(input.projectRoot, "project_root", TASK_PROJECT_ROOT_MAX_LENGTH);
|
|
109
80
|
const body = requiredBounded(input.body, "note body", NOTE_BODY_MAX_CHARACTERS);
|
|
110
|
-
const
|
|
111
|
-
return this.artifacts.create({
|
|
81
|
+
const created = this.artifacts.create({
|
|
112
82
|
kind: "doc",
|
|
113
83
|
subtype: NOTE_SUBTYPE,
|
|
114
84
|
status: "draft",
|
|
115
85
|
title: noteTitle(body, input.title),
|
|
116
86
|
body,
|
|
117
87
|
labels: ["note", "inbox"],
|
|
118
|
-
extra: { projectRoot
|
|
88
|
+
extra: { projectRoot },
|
|
119
89
|
});
|
|
90
|
+
this.appendEvent(created.id, "captured", input, { actor: "human", source: "notes" });
|
|
91
|
+
return created;
|
|
120
92
|
}
|
|
121
93
|
|
|
122
94
|
list(input: ListNotesInput): Artifact[] {
|
|
@@ -141,6 +113,13 @@ export class Notes {
|
|
|
141
113
|
return this.artifacts.get(id, { tree: true })!;
|
|
142
114
|
}
|
|
143
115
|
|
|
116
|
+
/** Real append-only event history for this note -- see domain/note-event.ts. */
|
|
117
|
+
history(id: string, projectRoot: string, query?: NoteHistoryQuery): NoteHistoryPage {
|
|
118
|
+
const note = this.requireNote(id);
|
|
119
|
+
this.requireProject(note, projectRoot);
|
|
120
|
+
return this.events.history(id, query);
|
|
121
|
+
}
|
|
122
|
+
|
|
144
123
|
consume(id: string, input: NoteProvenance & { projectRoot: string }): Artifact {
|
|
145
124
|
const atomic = requireAtomicArtifactStore(this.artifacts);
|
|
146
125
|
return atomic.atomic(() => {
|
|
@@ -148,7 +127,7 @@ export class Notes {
|
|
|
148
127
|
this.requireProject(note, input.projectRoot);
|
|
149
128
|
if (note.status === "archived") throw new Error("cannot consume an archived note");
|
|
150
129
|
if (note.status === "active") return this.artifacts.get(id, { tree: true })!;
|
|
151
|
-
this.
|
|
130
|
+
this.appendEvent(id, "consumed", input, { actor: "agent", source: "notes" });
|
|
152
131
|
this.artifacts.setStatus(id, "active");
|
|
153
132
|
return this.artifacts.get(id, { tree: true })!;
|
|
154
133
|
});
|
|
@@ -162,12 +141,12 @@ export class Notes {
|
|
|
162
141
|
if (note.status === "archived") throw new Error("cannot promote an archived note");
|
|
163
142
|
if (targetId === id) throw new Error("a note cannot promote to itself");
|
|
164
143
|
if (!this.artifacts.get(targetId)) throw new Error(`promotion target "${targetId}" not found`);
|
|
165
|
-
const
|
|
166
|
-
const disposition = { kind: "promoted", targetId, ...(promoted.reason ? { reason: promoted.reason } : {}) };
|
|
144
|
+
const reason = optionalBounded(input.reason, "note reason", NOTE_REASON_MAX_CHARACTERS);
|
|
167
145
|
this.artifacts.link({ from: id, relation: "relates_to", to: targetId });
|
|
146
|
+
this.appendEvent(id, "promoted", input, { actor: "agent", source: "notes" }, { relatedId: targetId, disposition: "promoted" });
|
|
168
147
|
this.artifacts.setExtra(id, {
|
|
169
|
-
...
|
|
170
|
-
disposition,
|
|
148
|
+
...note.extra,
|
|
149
|
+
disposition: { kind: "promoted", targetId, ...(reason ? { reason } : {}) },
|
|
171
150
|
});
|
|
172
151
|
this.artifacts.setStatus(id, "archived");
|
|
173
152
|
return this.artifacts.get(id, { tree: true })!;
|
|
@@ -181,17 +160,27 @@ export class Notes {
|
|
|
181
160
|
const note = this.requireNote(id);
|
|
182
161
|
this.requireProject(note, input.projectRoot);
|
|
183
162
|
if (note.status === "archived") throw new Error("note is already archived");
|
|
184
|
-
const
|
|
185
|
-
|
|
163
|
+
const reason = optionalBounded(input.reason, "note reason", NOTE_REASON_MAX_CHARACTERS);
|
|
164
|
+
this.appendEvent(id, "archived", input, { actor: "agent", source: "notes" }, { disposition: input.disposition });
|
|
186
165
|
this.artifacts.setExtra(id, {
|
|
187
|
-
...
|
|
188
|
-
disposition:
|
|
166
|
+
...note.extra,
|
|
167
|
+
disposition: { kind: input.disposition, ...(reason ? { reason } : {}) },
|
|
189
168
|
});
|
|
190
169
|
this.artifacts.setStatus(id, "archived");
|
|
191
170
|
return this.artifacts.get(id, { tree: true })!;
|
|
192
171
|
});
|
|
193
172
|
}
|
|
194
173
|
|
|
174
|
+
private appendEvent(
|
|
175
|
+
noteId: string,
|
|
176
|
+
type: NoteEventType,
|
|
177
|
+
input: NoteProvenance,
|
|
178
|
+
defaults: { actor: string; source: string },
|
|
179
|
+
extra: Partial<Pick<AppendNoteEvent, "relatedId" | "disposition">> = {},
|
|
180
|
+
): void {
|
|
181
|
+
this.events.append({ noteId, type, ...provenance(input, defaults), ...extra });
|
|
182
|
+
}
|
|
183
|
+
|
|
195
184
|
private requireNote(id: string): Artifact {
|
|
196
185
|
const artifact = this.artifacts.get(id);
|
|
197
186
|
if (!artifact || artifact.kind !== "doc" || artifact.subtype !== NOTE_SUBTYPE) throw new Error(`note "${id}" not found`);
|
package/src/ops.ts
CHANGED
|
@@ -391,10 +391,10 @@ export function restoreArtifact(db: Db, id: string, context?: ArtifactEventConte
|
|
|
391
391
|
* Deletes, in FK-safe order, every row across every table that can reference artifacts(id)
|
|
392
392
|
* (see the grep-verified list in domain/artifact-trash.ts's design comment): edges (both
|
|
393
393
|
* directions), task_focus, task_scopes, task_views (by root_task_id), graph_projection_
|
|
394
|
-
* identities, artifact_scopes, then task_events and artifact_events -- the
|
|
395
|
-
* succeed only because the artifact_trash row placed here by trashArtifact
|
|
396
|
-
* with an elapsed purge_after, which is exactly what db.ts's task_events_no_delete /
|
|
397
|
-
* artifact_events_no_delete trigger carve-outs check themselves. Only THEN artifact_trash's
|
|
394
|
+
* identities, artifact_scopes, then task_events, note_events, and artifact_events -- the
|
|
395
|
+
* latter three succeed only because the artifact_trash row placed here by trashArtifact
|
|
396
|
+
* still exists with an elapsed purge_after, which is exactly what db.ts's task_events_no_delete /
|
|
397
|
+
* note_events_no_delete / artifact_events_no_delete trigger carve-outs check themselves. Only THEN artifact_trash's
|
|
398
398
|
* own row (it is itself a child of artifacts via a real FK, so it must go before artifacts,
|
|
399
399
|
* but only after the event tables that depend on its continued presence), and artifacts
|
|
400
400
|
* itself last of all. One artifact at a time in its own transaction, so one failure never
|
|
@@ -413,6 +413,7 @@ export function purgeDueArtifacts(db: Db, now: () => string = () => new Date().t
|
|
|
413
413
|
db.prepare("DELETE FROM graph_projection_identities WHERE artifact_id = ?").run(id);
|
|
414
414
|
db.prepare("DELETE FROM artifact_scopes WHERE artifact_id = ?").run(id);
|
|
415
415
|
db.prepare("DELETE FROM task_events WHERE task_id = ?").run(id);
|
|
416
|
+
db.prepare("DELETE FROM note_events WHERE note_id = ?").run(id);
|
|
416
417
|
db.prepare("DELETE FROM artifact_events WHERE artifact_id = ?").run(id);
|
|
417
418
|
db.prepare("DELETE FROM artifact_trash WHERE artifact_id = ?").run(id);
|
|
418
419
|
db.prepare("DELETE FROM artifacts WHERE id = ?").run(id);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeNoteHistoryQuery,
|
|
3
|
+
validateNoteEvent,
|
|
4
|
+
type AppendNoteEvent,
|
|
5
|
+
type NoteEvent,
|
|
6
|
+
type NoteHistoryPage,
|
|
7
|
+
type NoteHistoryQuery,
|
|
8
|
+
} from "../domain/note-event.ts";
|
|
9
|
+
|
|
10
|
+
export interface NoteEventStore {
|
|
11
|
+
append(event: AppendNoteEvent): NoteEvent;
|
|
12
|
+
history(noteId: string, query?: NoteHistoryQuery): NoteHistoryPage;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class InMemoryNoteEventStore implements NoteEventStore {
|
|
16
|
+
private events: NoteEvent[] = [];
|
|
17
|
+
private nextId = 1;
|
|
18
|
+
|
|
19
|
+
append(event: AppendNoteEvent): NoteEvent {
|
|
20
|
+
const stored: NoteEvent = {
|
|
21
|
+
...validateNoteEvent(event),
|
|
22
|
+
id: this.nextId++,
|
|
23
|
+
occurredAt: new Date().toISOString(),
|
|
24
|
+
schemaVersion: 1,
|
|
25
|
+
};
|
|
26
|
+
this.events.push(stored);
|
|
27
|
+
return stored;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
history(noteId: string, query: NoteHistoryQuery = {}): NoteHistoryPage {
|
|
31
|
+
const { direction, limit, cursor } = normalizeNoteHistoryQuery(query);
|
|
32
|
+
const ordered = this.events
|
|
33
|
+
.filter((event) => event.noteId === noteId && (cursor === undefined || (direction === "desc" ? event.id < cursor : event.id > cursor)))
|
|
34
|
+
.sort((left, right) => direction === "desc" ? right.id - left.id : left.id - right.id);
|
|
35
|
+
const events = ordered.slice(0, limit);
|
|
36
|
+
return { events, ...(ordered.length > limit ? { nextCursor: events.at(-1)!.id } : {}) };
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { SQLiteGraphProjectionStore } from "./adapters/sqlite-graph-projection-s
|
|
|
8
8
|
import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
|
|
9
9
|
import { SQLiteTaskLeaseStore } from "./adapters/sqlite-task-lease-store.ts";
|
|
10
10
|
import { SQLiteTaskEventStore } from "./adapters/sqlite-task-event-store.ts";
|
|
11
|
+
import { SQLiteNoteEventStore } from "./adapters/sqlite-note-event-store.ts";
|
|
11
12
|
import { SQLiteTaskScopeStore } from "./adapters/sqlite-task-scope-store.ts";
|
|
12
13
|
import { SQLiteSessionIdentityStore } from "./adapters/sqlite-session-identity-store.ts";
|
|
13
14
|
import type { CreateArtifactInput } from "./domain/artifact.ts";
|
|
@@ -381,6 +382,7 @@ function handlers(
|
|
|
381
382
|
"notes.capture": forwardToModule("notes.capture"),
|
|
382
383
|
"notes.list": forwardToModule("notes.list"),
|
|
383
384
|
"notes.show": forwardToModule("notes.show"),
|
|
385
|
+
"notes.history": forwardToModule("notes.history"),
|
|
384
386
|
"notes.consume": forwardToModule("notes.consume"),
|
|
385
387
|
"notes.promote": forwardToModule("notes.promote"),
|
|
386
388
|
"notes.archive": forwardToModule("notes.archive"),
|
|
@@ -457,7 +459,8 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
457
459
|
const scopes = new SQLiteTaskScopeStore(db);
|
|
458
460
|
const leases = new SQLiteTaskLeaseStore(db);
|
|
459
461
|
const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases);
|
|
460
|
-
const
|
|
462
|
+
const noteEvents = new SQLiteNoteEventStore(db);
|
|
463
|
+
const notes = new Notes(artifacts, noteEvents);
|
|
461
464
|
const projections = new SQLiteGraphProjectionStore(db);
|
|
462
465
|
const artifactScopes = new SQLiteArtifactScopeStore(db);
|
|
463
466
|
const logs = new Logs(new SQLiteLogStore(db));
|