@danypops/papyrus 0.11.3 → 0.12.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/README.md +16 -2
- package/extension/src/active-task-continuation.ts +6 -0
- package/extension/src/domain-tools.ts +108 -52
- package/extension/src/index.ts +90 -37
- package/extension/src/notes.ts +14 -1
- package/extension/src/task-focus-events.ts +57 -0
- package/extension/src/tasks.ts +51 -15
- package/extension/src/tool-rendering/artifact-card.ts +117 -0
- package/extension/src/tool-rendering/artifact-list.ts +179 -0
- package/extension/src/tool-rendering/index.ts +107 -0
- package/extension/src/tool-rendering/render-model.ts +406 -0
- package/package.json +4 -2
- package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
- package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
- package/src/adapters/sqlite-artifact-store.ts +20 -11
- package/src/adapters/sqlite-discourse-store.ts +325 -0
- package/src/adapters/sqlite-graph-projection-store.ts +41 -0
- package/src/adapters/sqlite-task-focus-store.ts +34 -15
- package/src/authority-registry.ts +115 -0
- package/src/cli.ts +904 -124
- package/src/constants.ts +38 -5
- package/src/conversation-journal-service.ts +87 -0
- package/src/db.ts +336 -8
- package/src/domain/artifact-event.ts +99 -0
- package/src/domain/conversation-journal.ts +168 -0
- package/src/domain/discourse-store.ts +142 -0
- package/src/domain/graph-projection.ts +74 -0
- package/src/domain/task-event.ts +4 -0
- package/src/domain-services.ts +133 -38
- package/src/graph-projection-service.ts +103 -0
- package/src/id-migration.ts +200 -0
- package/src/module-registry.ts +53 -0
- package/src/modules/docs.ts +77 -0
- package/src/modules/graph-projection.ts +82 -0
- package/src/modules/notes.ts +76 -0
- package/src/modules/rules.ts +81 -0
- package/src/modules/skills.ts +113 -0
- package/src/modules/tasks.ts +164 -0
- package/src/ops.ts +142 -15
- package/src/ports/artifact-scope-store.ts +20 -0
- package/src/ports/artifact-store.ts +10 -5
- package/src/ports/conversation-journal-store.ts +17 -0
- package/src/ports/graph-projection-store.ts +15 -0
- package/src/ports/task-focus-store.ts +62 -20
- package/src/service.ts +218 -223
- package/src/task-service.ts +70 -38
package/src/constants.ts
CHANGED
|
@@ -7,8 +7,14 @@ 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 = 10;
|
|
11
11
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
12
|
+
/** Bounded forum persistence behind the Discourse mutation authority. */
|
|
13
|
+
export const DISCOURSE_QUERY_MAX_LIMIT = 100;
|
|
14
|
+
export const DISCOURSE_CONTENT_MAX_BYTES = 65_536;
|
|
15
|
+
export const DISCOURSE_EVENT_RETENTION_DEFAULT = 1_000;
|
|
16
|
+
export const DISCOURSE_EVENT_RETENTION_MAX = 10_000;
|
|
17
|
+
export const DISCOURSE_PARTICIPANT_MAX_COUNT = 100;
|
|
12
18
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
13
19
|
export const DB_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60_000;
|
|
14
20
|
export const GATE_COMMAND_TIMEOUT_MS = 30_000;
|
|
@@ -19,6 +25,9 @@ export const GATE_FILE_MAX_BYTES = 1_048_576;
|
|
|
19
25
|
|
|
20
26
|
export const PAPYRUS_CONTEXT_INJECTION_CHANNEL = "papyrus.context-injection.v1";
|
|
21
27
|
export const PAPYRUS_CONTEXT_INJECTION_SCHEMA = "papyrus.context-injection/v1";
|
|
28
|
+
/** Broadcasts which task is focused, content-free (taskId/sessionId/status/timestamp only), so other extensions (e.g. a token-cost router) can correlate their own telemetry without Papyrus depending on them. */
|
|
29
|
+
export const PAPYRUS_TASK_FOCUS_CHANNEL = "papyrus.task-focus.v1";
|
|
30
|
+
export const PAPYRUS_TASK_FOCUS_SCHEMA = "papyrus.task-focus/v1";
|
|
22
31
|
export const CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN = 4;
|
|
23
32
|
|
|
24
33
|
/** Compact task-context limits keep recurring prompt injection bounded. */
|
|
@@ -71,8 +80,23 @@ export const NOTE_LIST_MAX_LIMIT = 200;
|
|
|
71
80
|
export const NOTE_HISTORY_MAX_EVENTS = 20;
|
|
72
81
|
export const NOTE_PROVENANCE_MAX_LENGTH = 128;
|
|
73
82
|
export const NOTE_REASON_MAX_CHARACTERS = 2_000;
|
|
83
|
+
/** Generic, kind-agnostic mutation event log bounds (doc/task/rule/skill share one log). */
|
|
84
|
+
export const ARTIFACT_EVENT_ACTOR_MAX_LENGTH = 128;
|
|
85
|
+
export const ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT = 25;
|
|
86
|
+
export const ARTIFACT_EVENT_HISTORY_MAX_LIMIT = 200;
|
|
87
|
+
/** Bounds for the generic graph projection protocol (external bounded contexts). */
|
|
88
|
+
export const GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH = 500;
|
|
89
|
+
export const GRAPH_PROJECTION_MAX_EDGES_PER_BATCH = 1_000;
|
|
90
|
+
export const GRAPH_PROJECTION_ID_MAX_LENGTH = 256;
|
|
91
|
+
/** Per-agent-session Task Focus scoping. "global" is the default scope for callers that don't supply a session id (CLI, legacy behavior). */
|
|
92
|
+
export const TASK_FOCUS_DEFAULT_SCOPE = "global";
|
|
93
|
+
export const TASK_FOCUS_SCOPE_MAX_LENGTH = 128;
|
|
94
|
+
/** Hard cap on distinct concurrent focus scopes (sessions); oldest-updated scope is evicted beyond this. */
|
|
95
|
+
export const TASK_FOCUS_MAX_SCOPES = 500;
|
|
74
96
|
/** Persisted project and focused-graph Task view bounds. */
|
|
75
97
|
export const TASK_SCOPE_MAX_TASKS = 1_000;
|
|
98
|
+
/** Docs/Rules/Skills project scope listing bound, mirroring TASK_SCOPE_MAX_TASKS. */
|
|
99
|
+
export const ARTIFACT_SCOPE_MAX_ARTIFACTS = 1_000;
|
|
76
100
|
export const TASK_PROJECT_ROOT_MAX_LENGTH = 4_096;
|
|
77
101
|
export const GRAPH_RENDER_PADDING_X = 2;
|
|
78
102
|
export const GRAPH_RENDER_PADDING_Y = 1;
|
|
@@ -93,14 +117,23 @@ export const DEFAULT_METADATA_DEPTH = 6;
|
|
|
93
117
|
export const DEFAULT_METADATA_ITEMS = 100;
|
|
94
118
|
export const MAX_METADATA_DEPTH = 12;
|
|
95
119
|
export const MAX_METADATA_ITEMS = 500;
|
|
120
|
+
/** Independent bounds for model-facing tool content and persisted renderer details. */
|
|
121
|
+
export const TOOL_MODEL_CONTENT_MAX_CHARACTERS = 12_000;
|
|
122
|
+
export const TOOL_DETAILS_BODY_MAX_CHARACTERS = 20_000;
|
|
123
|
+
export const TOOL_DETAILS_FIELD_MAX_CHARACTERS = 1_000;
|
|
124
|
+
export const TOOL_DETAILS_ROW_OUTPUT_MAX_CHARACTERS = 1_000;
|
|
125
|
+
export const TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS = 131_072;
|
|
126
|
+
export const TOOL_COLLAPSED_ROW_LIMIT = 5;
|
|
127
|
+
export const TOOL_DETAILS_MAX_ITEMS = 100;
|
|
128
|
+
export const TOOL_DETAILS_MAX_EDGES = 200;
|
|
96
129
|
|
|
97
130
|
/** Reconciliation instruction appended whenever Papyrus has open work. */
|
|
98
131
|
export const TASK_RECONCILIATION_INSTRUCTION = [
|
|
99
132
|
"Reconcile before concluding or moving on:",
|
|
100
133
|
'• For each current task, ask: "Did we accomplish this task?"',
|
|
101
|
-
"• If yes, run its gates before marking it done; a claim is not verification.",
|
|
102
|
-
"• If no, continue with the next concrete action toward its desired state.",
|
|
103
|
-
"• Address blocked work or explicitly move failed review to rejected with the reason.",
|
|
134
|
+
"• If yes, run its gates before marking it done; a claim is not verification. A written summary is not evidence -- identify what would actually prove each requirement in the task's desired state and checklist, and treat indirect or merely-plausible signals as not sufficient.",
|
|
135
|
+
"• If no, continue with the next concrete action toward its desired state. Do not shrink the task's scope to whatever fits in this turn, and do not substitute a narrower, easier, or merely-passing-looking change for the actual desired outcome.",
|
|
136
|
+
"• Address blocked work or explicitly move failed review to rejected with the reason. Do not reject or call something blocked on the first obstacle -- only after the same blocking condition genuinely recurs, and only when the task truly cannot proceed without external input or a change outside the agent's control.",
|
|
104
137
|
].join("\n");
|
|
105
138
|
|
|
106
139
|
/** $XDG_DATA_HOME/papyrus/papyrus.db */
|
|
@@ -175,5 +208,5 @@ export const DEFAULT_STATUS_BY_KIND: Readonly<Record<string, string>> = {
|
|
|
175
208
|
export const SEED_RELATIONS = [
|
|
176
209
|
"references", "implements", "follows", "depends_on",
|
|
177
210
|
"documents", "blocks", "supersedes", "relates_to",
|
|
178
|
-
"gates", "triggers", "contains", "part_of",
|
|
211
|
+
"gates", "triggers", "contains", "part_of", "reply_to", "discusses",
|
|
179
212
|
] as const;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation-journal-service.ts — host-neutral ConversationJournal application layer.
|
|
3
|
+
*
|
|
4
|
+
* Owns idempotency (checking operationId before insert) and bounds enforcement; the
|
|
5
|
+
* ConversationJournalStore port underneath is dumb storage. See
|
|
6
|
+
* src/domain/conversation-journal.ts for the domain shapes and the design rationale.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
ancestorChain,
|
|
10
|
+
boundContent,
|
|
11
|
+
buildThreadTree,
|
|
12
|
+
CONVERSATION_JOURNAL_READ_MAX_POSTS,
|
|
13
|
+
validateAppendPostCommand,
|
|
14
|
+
type AppendPostCommand,
|
|
15
|
+
type AppendPostResult,
|
|
16
|
+
type JournalPost,
|
|
17
|
+
type JournalThread,
|
|
18
|
+
type ReadThreadQuery,
|
|
19
|
+
type ThreadPage,
|
|
20
|
+
type ThreadTreeNode,
|
|
21
|
+
} from "./domain/conversation-journal.ts";
|
|
22
|
+
import type { ConversationJournalStore } from "./ports/conversation-journal-store.ts";
|
|
23
|
+
|
|
24
|
+
export class ConversationJournalService {
|
|
25
|
+
constructor(private readonly store: ConversationJournalStore) {}
|
|
26
|
+
|
|
27
|
+
appendPost(command: AppendPostCommand): AppendPostResult {
|
|
28
|
+
validateAppendPostCommand(command);
|
|
29
|
+
|
|
30
|
+
const existing = this.store.findPostByOperationId(command.operationId);
|
|
31
|
+
if (existing) return { post: existing, replayed: true };
|
|
32
|
+
|
|
33
|
+
if (command.replyToPostId !== undefined && !this.store.getPost(command.replyToPostId)) {
|
|
34
|
+
throw new Error(`replyToPostId "${command.replyToPostId}" not found`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.store.ensureThread(command.threadId);
|
|
38
|
+
const { content, truncated } = boundContent(command.content);
|
|
39
|
+
const post: JournalPost = {
|
|
40
|
+
id: crypto.randomUUID(),
|
|
41
|
+
threadId: command.threadId,
|
|
42
|
+
...(command.replyToPostId !== undefined ? { replyToPostId: command.replyToPostId } : {}),
|
|
43
|
+
authorId: command.authorId,
|
|
44
|
+
content,
|
|
45
|
+
truncated,
|
|
46
|
+
timestamp: new Date().toISOString(),
|
|
47
|
+
sourceSessionId: command.sourceSessionId,
|
|
48
|
+
operationId: command.operationId,
|
|
49
|
+
references: command.references ? [...command.references] : [],
|
|
50
|
+
};
|
|
51
|
+
this.store.insertPost(post);
|
|
52
|
+
return { post, replayed: false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getThread(threadId: string): JournalThread | undefined {
|
|
56
|
+
return this.store.getThread(threadId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getPost(id: string): JournalPost | undefined {
|
|
60
|
+
return this.store.getPost(id);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Bounded, explicit-completeness thread read. Never silently drops posts past the bound. */
|
|
64
|
+
readThread(query: ReadThreadQuery): ThreadPage {
|
|
65
|
+
const limit = query.limit;
|
|
66
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > CONVERSATION_JOURNAL_READ_MAX_POSTS) {
|
|
67
|
+
throw new Error(`readThread limit must be between 1 and ${CONVERSATION_JOURNAL_READ_MAX_POSTS}`);
|
|
68
|
+
}
|
|
69
|
+
const all = [...this.store.postsForThread(query.threadId)].sort(
|
|
70
|
+
(left, right) => left.timestamp.localeCompare(right.timestamp) || left.id.localeCompare(right.id),
|
|
71
|
+
);
|
|
72
|
+
const truncated = all.length > limit;
|
|
73
|
+
return { posts: all.slice(0, limit), truncated };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Reconstructed reply tree for a thread, bounded and orphan/cycle-safe -- see buildThreadTree. */
|
|
77
|
+
readThreadTree(threadId: string): ThreadTreeNode[] {
|
|
78
|
+
return buildThreadTree(this.store.postsForThread(threadId));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Root-first ancestor chain for one post -- the host-neutral equivalent of Pi's getBranch(). */
|
|
82
|
+
ancestorsOf(postId: string): JournalPost[] {
|
|
83
|
+
const posts = this.store.postsForThread(this.store.getPost(postId)?.threadId ?? "");
|
|
84
|
+
const byId = new Map(posts.map((post) => [post.id, post]));
|
|
85
|
+
return ancestorChain(postId, byId);
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Dual-runtime: bun:sqlite (Bun) / node:sqlite (Node/pi host).
|
|
4
4
|
* Four kinds (doc/task/rule/skill) are FK-enforced; relations are universal (any→any).
|
|
5
5
|
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
import { createRequire } from "node:module";
|
|
7
8
|
import { mkdirSync } from "node:fs";
|
|
8
9
|
import { join, dirname } from "node:path";
|
|
@@ -65,6 +66,14 @@ export function inTransaction<T>(db: Db, fn: () => T): T {
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
const SCHEMA = `
|
|
69
|
+
CREATE TABLE IF NOT EXISTS module_migrations (
|
|
70
|
+
module_id TEXT NOT NULL,
|
|
71
|
+
version INTEGER NOT NULL,
|
|
72
|
+
name TEXT NOT NULL,
|
|
73
|
+
checksum TEXT NOT NULL,
|
|
74
|
+
applied_at TEXT NOT NULL,
|
|
75
|
+
PRIMARY KEY (module_id, version)
|
|
76
|
+
);
|
|
68
77
|
CREATE TABLE IF NOT EXISTS kinds (
|
|
69
78
|
name TEXT PRIMARY KEY,
|
|
70
79
|
description TEXT
|
|
@@ -98,8 +107,8 @@ CREATE TABLE IF NOT EXISTS relation_names (
|
|
|
98
107
|
description TEXT
|
|
99
108
|
);
|
|
100
109
|
CREATE TABLE IF NOT EXISTS task_focus (
|
|
101
|
-
scope TEXT PRIMARY KEY
|
|
102
|
-
task_id TEXT NOT NULL
|
|
110
|
+
scope TEXT PRIMARY KEY,
|
|
111
|
+
task_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
103
112
|
status TEXT NOT NULL CHECK (status IN ('active', 'paused')),
|
|
104
113
|
pause_reason TEXT,
|
|
105
114
|
updated_at TEXT NOT NULL
|
|
@@ -138,6 +147,109 @@ CREATE TABLE IF NOT EXISTS task_views (
|
|
|
138
147
|
updated_at TEXT NOT NULL,
|
|
139
148
|
CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
|
|
140
149
|
);
|
|
150
|
+
CREATE TABLE IF NOT EXISTS discourse_threads (
|
|
151
|
+
store_id TEXT NOT NULL,
|
|
152
|
+
forum_id TEXT NOT NULL,
|
|
153
|
+
topic_id TEXT NOT NULL,
|
|
154
|
+
thread_id TEXT NOT NULL,
|
|
155
|
+
artifact_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
|
|
156
|
+
PRIMARY KEY (store_id, forum_id, topic_id, thread_id)
|
|
157
|
+
);
|
|
158
|
+
CREATE TABLE IF NOT EXISTS discourse_posts (
|
|
159
|
+
store_id TEXT NOT NULL,
|
|
160
|
+
sequence INTEGER NOT NULL,
|
|
161
|
+
id TEXT NOT NULL,
|
|
162
|
+
artifact_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
|
|
163
|
+
operation_id TEXT NOT NULL,
|
|
164
|
+
command_json TEXT NOT NULL,
|
|
165
|
+
forum_id TEXT NOT NULL,
|
|
166
|
+
topic_id TEXT NOT NULL,
|
|
167
|
+
thread_id TEXT NOT NULL,
|
|
168
|
+
author_id TEXT NOT NULL,
|
|
169
|
+
content_json TEXT NOT NULL,
|
|
170
|
+
timestamp INTEGER NOT NULL,
|
|
171
|
+
correlation_id TEXT,
|
|
172
|
+
causation_id TEXT,
|
|
173
|
+
reply_to_post_id TEXT,
|
|
174
|
+
references_json TEXT NOT NULL,
|
|
175
|
+
question_type TEXT CHECK (question_type IN ('question', 'answer')),
|
|
176
|
+
response_id TEXT,
|
|
177
|
+
target_id TEXT,
|
|
178
|
+
PRIMARY KEY (store_id, id),
|
|
179
|
+
UNIQUE (store_id, operation_id),
|
|
180
|
+
UNIQUE (store_id, sequence)
|
|
181
|
+
);
|
|
182
|
+
CREATE INDEX IF NOT EXISTS discourse_posts_thread_idx ON discourse_posts(store_id, forum_id, topic_id, thread_id, sequence);
|
|
183
|
+
CREATE TABLE IF NOT EXISTS discourse_events (
|
|
184
|
+
store_id TEXT NOT NULL,
|
|
185
|
+
sequence INTEGER NOT NULL,
|
|
186
|
+
event_json TEXT NOT NULL,
|
|
187
|
+
PRIMARY KEY (store_id, sequence)
|
|
188
|
+
);
|
|
189
|
+
CREATE TABLE IF NOT EXISTS discourse_cursors (
|
|
190
|
+
store_id TEXT NOT NULL,
|
|
191
|
+
consumer_id TEXT NOT NULL,
|
|
192
|
+
sequence INTEGER NOT NULL,
|
|
193
|
+
PRIMARY KEY (store_id, consumer_id)
|
|
194
|
+
);
|
|
195
|
+
CREATE TABLE IF NOT EXISTS discourse_projection_cursors (
|
|
196
|
+
store_id TEXT NOT NULL,
|
|
197
|
+
projection_id TEXT NOT NULL,
|
|
198
|
+
sequence INTEGER NOT NULL,
|
|
199
|
+
PRIMARY KEY (store_id, projection_id)
|
|
200
|
+
);
|
|
201
|
+
CREATE TRIGGER IF NOT EXISTS discourse_threads_artifact_type BEFORE INSERT ON discourse_threads
|
|
202
|
+
WHEN NOT EXISTS (SELECT 1 FROM artifacts WHERE id = NEW.artifact_id AND kind = 'doc' AND subtype = 'context-thread')
|
|
203
|
+
BEGIN SELECT RAISE(ABORT, 'discourse thread artifact must be a context-thread Doc'); END;
|
|
204
|
+
CREATE TRIGGER IF NOT EXISTS discourse_posts_artifact_type BEFORE INSERT ON discourse_posts
|
|
205
|
+
WHEN NOT EXISTS (SELECT 1 FROM artifacts WHERE id = NEW.artifact_id AND kind = 'doc' AND subtype = 'context-message')
|
|
206
|
+
BEGIN SELECT RAISE(ABORT, 'discourse post artifact must be a context-message Doc'); END;
|
|
207
|
+
CREATE TRIGGER IF NOT EXISTS discourse_artifact_type_immutable BEFORE UPDATE OF kind, subtype ON artifacts
|
|
208
|
+
WHEN (EXISTS (SELECT 1 FROM discourse_threads WHERE artifact_id = OLD.id) AND (NEW.kind != 'doc' OR NEW.subtype != 'context-thread'))
|
|
209
|
+
OR (EXISTS (SELECT 1 FROM discourse_posts WHERE artifact_id = OLD.id) AND (NEW.kind != 'doc' OR NEW.subtype != 'context-message'))
|
|
210
|
+
BEGIN SELECT RAISE(ABORT, 'discourse Context Mesh artifact type is immutable'); END;
|
|
211
|
+
CREATE TABLE IF NOT EXISTS artifact_events (
|
|
212
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
213
|
+
artifact_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
214
|
+
occurred_at TEXT NOT NULL,
|
|
215
|
+
event_type TEXT NOT NULL,
|
|
216
|
+
actor TEXT NOT NULL,
|
|
217
|
+
source TEXT NOT NULL,
|
|
218
|
+
session_id TEXT,
|
|
219
|
+
from_status TEXT,
|
|
220
|
+
to_status TEXT,
|
|
221
|
+
relation TEXT,
|
|
222
|
+
related_id TEXT,
|
|
223
|
+
event_schema_version INTEGER NOT NULL DEFAULT 1
|
|
224
|
+
);
|
|
225
|
+
CREATE INDEX IF NOT EXISTS artifact_events_artifact_idx ON artifact_events(artifact_id, occurred_at, id);
|
|
226
|
+
CREATE INDEX IF NOT EXISTS artifact_events_related_idx ON artifact_events(related_id, occurred_at, id);
|
|
227
|
+
CREATE INDEX IF NOT EXISTS artifact_events_actor_idx ON artifact_events(actor, occurred_at, id);
|
|
228
|
+
CREATE INDEX IF NOT EXISTS artifact_events_session_idx ON artifact_events(session_id, occurred_at, id);
|
|
229
|
+
CREATE TRIGGER IF NOT EXISTS artifact_events_no_update BEFORE UPDATE ON artifact_events
|
|
230
|
+
BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only'); END;
|
|
231
|
+
CREATE TRIGGER IF NOT EXISTS artifact_events_no_delete BEFORE DELETE ON artifact_events
|
|
232
|
+
BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only'); END;
|
|
233
|
+
CREATE TABLE IF NOT EXISTS graph_projection_checkpoints (
|
|
234
|
+
producer_id TEXT PRIMARY KEY,
|
|
235
|
+
last_sequence INTEGER NOT NULL,
|
|
236
|
+
last_batch_id TEXT NOT NULL,
|
|
237
|
+
applied_at TEXT NOT NULL
|
|
238
|
+
);
|
|
239
|
+
CREATE TABLE IF NOT EXISTS graph_projection_identities (
|
|
240
|
+
producer_id TEXT NOT NULL,
|
|
241
|
+
external_id TEXT NOT NULL,
|
|
242
|
+
artifact_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
243
|
+
PRIMARY KEY (producer_id, external_id)
|
|
244
|
+
);
|
|
245
|
+
CREATE INDEX IF NOT EXISTS graph_projection_identities_artifact_idx ON graph_projection_identities(artifact_id);
|
|
246
|
+
CREATE TABLE IF NOT EXISTS artifact_scopes (
|
|
247
|
+
artifact_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
248
|
+
project_root TEXT,
|
|
249
|
+
source TEXT NOT NULL CHECK (source IN ('cwd', 'explicit', 'unscoped')),
|
|
250
|
+
assigned_at TEXT NOT NULL
|
|
251
|
+
);
|
|
252
|
+
CREATE INDEX IF NOT EXISTS artifact_scopes_project_idx ON artifact_scopes(project_root, artifact_id);
|
|
141
253
|
`;
|
|
142
254
|
|
|
143
255
|
const SEED_SQL = `
|
|
@@ -170,6 +282,8 @@ INSERT OR IGNORE INTO relation_names VALUES ('gates','This rule gates that task
|
|
|
170
282
|
INSERT OR IGNORE INTO relation_names VALUES ('triggers','This skill applies to that work (skill→task)');
|
|
171
283
|
INSERT OR IGNORE INTO relation_names VALUES ('contains','Parent contains a nested artifact (any→any)');
|
|
172
284
|
INSERT OR IGNORE INTO relation_names VALUES ('part_of','Artifact belongs to a parent artifact (any→any)');
|
|
285
|
+
INSERT OR IGNORE INTO relation_names VALUES ('reply_to','Append-only message replies to another message in the same thread');
|
|
286
|
+
INSERT OR IGNORE INTO relation_names VALUES ('discusses','Message or turn concerns a verified artifact');
|
|
173
287
|
CREATE INDEX IF NOT EXISTS edges_to_id_idx ON edges(to_id);
|
|
174
288
|
`;
|
|
175
289
|
|
|
@@ -183,16 +297,102 @@ export function schemaVersion(db: Db): number {
|
|
|
183
297
|
return (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
|
|
184
298
|
}
|
|
185
299
|
|
|
300
|
+
/**
|
|
301
|
+
* One row per (module_id, version) applied migration, checksummed so a since-edited
|
|
302
|
+
* definition is detected rather than silently trusted.
|
|
303
|
+
*/
|
|
304
|
+
export interface ModuleMigrationRow {
|
|
305
|
+
readonly moduleId: string;
|
|
306
|
+
readonly version: number;
|
|
307
|
+
readonly name: string;
|
|
308
|
+
readonly checksum: string;
|
|
309
|
+
readonly appliedAt: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* "core"'s ledger history. Each entry's checksum is a FROZEN, hardcoded literal, computed
|
|
314
|
+
* once at the moment that version was introduced and embedded here permanently -- never
|
|
315
|
+
* recomputed from the live, evolving SCHEMA/SEED_SQL constants. That distinction is the
|
|
316
|
+
* entire point: an earlier version of this file computed the checksum from the current
|
|
317
|
+
* SCHEMA text every time, which meant "core version 1" silently redefined itself every
|
|
318
|
+
* time SCHEMA grew, and would have thrown a checksum-mismatch error against every
|
|
319
|
+
* database that had already recorded the OLDER value -- including a real, already-
|
|
320
|
+
* deployed production database. Only the LAST entry's DDL actually runs (SCHEMA+SEED_SQL,
|
|
321
|
+
* the full current shape) for a truly fresh database; every earlier entry is pure
|
|
322
|
+
* historical bookkeeping, backfilled alongside it without being replayed, so a new
|
|
323
|
+
* database is not forced to pass through migrations it never structurally needed. A
|
|
324
|
+
* database that already recorded an earlier version keeps that exact checksum forever;
|
|
325
|
+
* adding schema later means appending a new entry here, never editing an existing one.
|
|
326
|
+
*/
|
|
327
|
+
const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; checksum: string }> = [
|
|
328
|
+
{ version: 1, name: "baseline", checksum: "af81e9f51d915ba538af3f468dc044bda5e2c5a5f5037e9c7c01540f87288763" },
|
|
329
|
+
{ version: 2, name: "docs-rules-skills-project-scope", checksum: "8b16d8f631ad628f4799ff09b1ebe8be28343e4f677d52bf2a39a8bedc19e64e" },
|
|
330
|
+
];
|
|
331
|
+
|
|
332
|
+
export function migrationLedger(db: Db): ModuleMigrationRow[] {
|
|
333
|
+
// A database that has never reached current schema (still awaiting explicit migrateDb())
|
|
334
|
+
// has no ledger table yet -- "nothing recorded" is the correct answer, not an error.
|
|
335
|
+
const tableExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'module_migrations'").get() != null;
|
|
336
|
+
if (!tableExists) return [];
|
|
337
|
+
const rows = db.prepare("SELECT module_id, version, name, checksum, applied_at FROM module_migrations ORDER BY module_id, version").all() as Array<{
|
|
338
|
+
module_id: string; version: number; name: string; checksum: string; applied_at: string;
|
|
339
|
+
}>;
|
|
340
|
+
return rows.map((row) => ({ moduleId: row.module_id, version: row.version, name: row.name, checksum: row.checksum, appliedAt: row.applied_at }));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Ensures the ledger correctly reflects every entry in CORE_LEDGER_VERSIONS once a
|
|
345
|
+
* database is confirmed at the full current schema, however it got there: a truly empty
|
|
346
|
+
* database runs the current bootstrap DDL once (for the last/current entry only) and
|
|
347
|
+
* backfills every entry as historical bookkeeping; a database that already reached
|
|
348
|
+
* current shape (a fresh bootstrap from a past release, or a full upgrade through the
|
|
349
|
+
* pre-ledger sequential migrateDb() chain below) is backfilled without re-running any DDL
|
|
350
|
+
* against data that already exists. Verifies every already-recorded entry's stored
|
|
351
|
+
* checksum on every open so a since-edited definition is caught, not silently trusted --
|
|
352
|
+
* and because each entry's checksum is frozen at introduction (see the constant's own
|
|
353
|
+
* comment), an already-recorded entry can never spuriously mismatch just because a later
|
|
354
|
+
* entry was appended.
|
|
355
|
+
*/
|
|
356
|
+
function ensureCoreLedger(db: Db, alreadyAtCurrentSchema: boolean): void {
|
|
357
|
+
// Idempotent and standalone: must succeed even on a truly empty database, before the rest
|
|
358
|
+
// of SCHEMA (which also declares this table) has run.
|
|
359
|
+
db.exec(`
|
|
360
|
+
CREATE TABLE IF NOT EXISTS module_migrations (
|
|
361
|
+
module_id TEXT NOT NULL,
|
|
362
|
+
version INTEGER NOT NULL,
|
|
363
|
+
name TEXT NOT NULL,
|
|
364
|
+
checksum TEXT NOT NULL,
|
|
365
|
+
applied_at TEXT NOT NULL,
|
|
366
|
+
PRIMARY KEY (module_id, version)
|
|
367
|
+
);
|
|
368
|
+
`);
|
|
369
|
+
inTransaction(db, () => {
|
|
370
|
+
for (const [index, entry] of CORE_LEDGER_VERSIONS.entries()) {
|
|
371
|
+
const existingRow = db.prepare("SELECT checksum FROM module_migrations WHERE module_id = 'core' AND version = ?").get(entry.version) as { checksum: string } | null;
|
|
372
|
+
if (existingRow != null) {
|
|
373
|
+
if (existingRow.checksum !== entry.checksum) {
|
|
374
|
+
throw new Error(`module migration "core" version ${entry.version} checksum mismatch: the frozen definition for this version was edited after it was applied`);
|
|
375
|
+
}
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const isLast = index === CORE_LEDGER_VERSIONS.length - 1;
|
|
379
|
+
if (isLast && !alreadyAtCurrentSchema) {
|
|
380
|
+
db.exec(SCHEMA);
|
|
381
|
+
db.exec(SEED_SQL);
|
|
382
|
+
db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
|
|
383
|
+
}
|
|
384
|
+
db.prepare("INSERT INTO module_migrations (module_id, version, name, checksum, applied_at) VALUES ('core', ?, ?, ?, ?)")
|
|
385
|
+
.run(entry.version, entry.name, entry.checksum, new Date().toISOString());
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
186
390
|
function bootstrapEmptyDatabase(db: Db): void {
|
|
187
391
|
const existing = db
|
|
188
392
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1")
|
|
189
393
|
.get();
|
|
190
394
|
if (existing) throw new Error("database schema is unversioned; refusing to migrate existing data during boot");
|
|
191
|
-
|
|
192
|
-
db.exec(SCHEMA);
|
|
193
|
-
db.exec(SEED_SQL);
|
|
194
|
-
db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
|
|
195
|
-
});
|
|
395
|
+
ensureCoreLedger(db, false);
|
|
196
396
|
}
|
|
197
397
|
|
|
198
398
|
export function migrateDb(db: Db): MigrationResult {
|
|
@@ -201,7 +401,7 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
201
401
|
throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
202
402
|
}
|
|
203
403
|
if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
|
|
204
|
-
if (from !== 1 && from !== 2 && from !== 3 && from !== 4) throw new Error(`no explicit migration path from database schema ${from}`);
|
|
404
|
+
if (from !== 1 && from !== 2 && from !== 3 && from !== 4 && from !== 5 && from !== 6 && from !== 7) throw new Error(`no explicit migration path from database schema ${from}`);
|
|
205
405
|
const applied: string[] = [];
|
|
206
406
|
|
|
207
407
|
inTransaction(db, () => {
|
|
@@ -290,7 +490,134 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
290
490
|
`);
|
|
291
491
|
applied.push("task-focus-continuation");
|
|
292
492
|
}
|
|
493
|
+
if (schemaVersion(db) === 5) {
|
|
494
|
+
db.exec(`
|
|
495
|
+
INSERT OR IGNORE INTO relation_names VALUES ('reply_to','Append-only message replies to another message in the same thread');
|
|
496
|
+
INSERT OR IGNORE INTO relation_names VALUES ('discusses','Message or turn concerns a verified artifact');
|
|
497
|
+
CREATE TABLE discourse_threads (
|
|
498
|
+
store_id TEXT NOT NULL, forum_id TEXT NOT NULL, topic_id TEXT NOT NULL, thread_id TEXT NOT NULL,
|
|
499
|
+
artifact_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
|
|
500
|
+
PRIMARY KEY (store_id, forum_id, topic_id, thread_id)
|
|
501
|
+
);
|
|
502
|
+
CREATE TABLE discourse_posts (
|
|
503
|
+
store_id TEXT NOT NULL, sequence INTEGER NOT NULL, id TEXT NOT NULL,
|
|
504
|
+
artifact_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id), operation_id TEXT NOT NULL,
|
|
505
|
+
command_json TEXT NOT NULL, forum_id TEXT NOT NULL, topic_id TEXT NOT NULL, thread_id TEXT NOT NULL,
|
|
506
|
+
author_id TEXT NOT NULL, content_json TEXT NOT NULL, timestamp INTEGER NOT NULL,
|
|
507
|
+
correlation_id TEXT, causation_id TEXT, reply_to_post_id TEXT, references_json TEXT NOT NULL,
|
|
508
|
+
question_type TEXT CHECK (question_type IN ('question', 'answer')), response_id TEXT, target_id TEXT,
|
|
509
|
+
PRIMARY KEY (store_id, id), UNIQUE (store_id, operation_id), UNIQUE (store_id, sequence)
|
|
510
|
+
);
|
|
511
|
+
CREATE INDEX discourse_posts_thread_idx ON discourse_posts(store_id, forum_id, topic_id, thread_id, sequence);
|
|
512
|
+
CREATE TABLE discourse_events (
|
|
513
|
+
store_id TEXT NOT NULL, sequence INTEGER NOT NULL, event_json TEXT NOT NULL,
|
|
514
|
+
PRIMARY KEY (store_id, sequence)
|
|
515
|
+
);
|
|
516
|
+
CREATE TABLE discourse_cursors (
|
|
517
|
+
store_id TEXT NOT NULL, consumer_id TEXT NOT NULL, sequence INTEGER NOT NULL,
|
|
518
|
+
PRIMARY KEY (store_id, consumer_id)
|
|
519
|
+
);
|
|
520
|
+
CREATE TABLE discourse_projection_cursors (
|
|
521
|
+
store_id TEXT NOT NULL, projection_id TEXT NOT NULL, sequence INTEGER NOT NULL,
|
|
522
|
+
PRIMARY KEY (store_id, projection_id)
|
|
523
|
+
);
|
|
524
|
+
CREATE TRIGGER discourse_threads_artifact_type BEFORE INSERT ON discourse_threads
|
|
525
|
+
WHEN NOT EXISTS (SELECT 1 FROM artifacts WHERE id = NEW.artifact_id AND kind = 'doc' AND subtype = 'context-thread')
|
|
526
|
+
BEGIN SELECT RAISE(ABORT, 'discourse thread artifact must be a context-thread Doc'); END;
|
|
527
|
+
CREATE TRIGGER discourse_posts_artifact_type BEFORE INSERT ON discourse_posts
|
|
528
|
+
WHEN NOT EXISTS (SELECT 1 FROM artifacts WHERE id = NEW.artifact_id AND kind = 'doc' AND subtype = 'context-message')
|
|
529
|
+
BEGIN SELECT RAISE(ABORT, 'discourse post artifact must be a context-message Doc'); END;
|
|
530
|
+
CREATE TRIGGER discourse_artifact_type_immutable BEFORE UPDATE OF kind, subtype ON artifacts
|
|
531
|
+
WHEN (EXISTS (SELECT 1 FROM discourse_threads WHERE artifact_id = OLD.id) AND (NEW.kind != 'doc' OR NEW.subtype != 'context-thread'))
|
|
532
|
+
OR (EXISTS (SELECT 1 FROM discourse_posts WHERE artifact_id = OLD.id) AND (NEW.kind != 'doc' OR NEW.subtype != 'context-message'))
|
|
533
|
+
BEGIN SELECT RAISE(ABORT, 'discourse Context Mesh artifact type is immutable'); END;
|
|
534
|
+
PRAGMA user_version = 6;
|
|
535
|
+
`);
|
|
536
|
+
applied.push("discourse-context-mesh");
|
|
537
|
+
}
|
|
538
|
+
if (schemaVersion(db) === 6) {
|
|
539
|
+
db.exec(`
|
|
540
|
+
CREATE TABLE artifact_events (
|
|
541
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
542
|
+
artifact_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
543
|
+
occurred_at TEXT NOT NULL,
|
|
544
|
+
event_type TEXT NOT NULL,
|
|
545
|
+
actor TEXT NOT NULL,
|
|
546
|
+
source TEXT NOT NULL,
|
|
547
|
+
session_id TEXT,
|
|
548
|
+
from_status TEXT,
|
|
549
|
+
to_status TEXT,
|
|
550
|
+
relation TEXT,
|
|
551
|
+
related_id TEXT,
|
|
552
|
+
event_schema_version INTEGER NOT NULL DEFAULT 1
|
|
553
|
+
);
|
|
554
|
+
CREATE INDEX artifact_events_artifact_idx ON artifact_events(artifact_id, occurred_at, id);
|
|
555
|
+
CREATE INDEX artifact_events_related_idx ON artifact_events(related_id, occurred_at, id);
|
|
556
|
+
CREATE INDEX artifact_events_actor_idx ON artifact_events(actor, occurred_at, id);
|
|
557
|
+
CREATE INDEX artifact_events_session_idx ON artifact_events(session_id, occurred_at, id);
|
|
558
|
+
CREATE TRIGGER artifact_events_no_update BEFORE UPDATE ON artifact_events
|
|
559
|
+
BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only'); END;
|
|
560
|
+
CREATE TRIGGER artifact_events_no_delete BEFORE DELETE ON artifact_events
|
|
561
|
+
BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only'); END;
|
|
562
|
+
PRAGMA user_version = 7;
|
|
563
|
+
`);
|
|
564
|
+
applied.push("artifact-event-log");
|
|
565
|
+
}
|
|
566
|
+
if (schemaVersion(db) === 7) {
|
|
567
|
+
db.exec(`
|
|
568
|
+
CREATE TABLE task_focus_v7 (
|
|
569
|
+
scope TEXT PRIMARY KEY,
|
|
570
|
+
task_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
571
|
+
status TEXT NOT NULL CHECK (status IN ('active', 'paused')),
|
|
572
|
+
pause_reason TEXT,
|
|
573
|
+
updated_at TEXT NOT NULL
|
|
574
|
+
);
|
|
575
|
+
INSERT INTO task_focus_v7 SELECT scope, task_id, status, pause_reason, updated_at FROM task_focus;
|
|
576
|
+
DROP TABLE task_focus;
|
|
577
|
+
ALTER TABLE task_focus_v7 RENAME TO task_focus;
|
|
578
|
+
PRAGMA user_version = 8;
|
|
579
|
+
`);
|
|
580
|
+
applied.push("task-focus-session-scope");
|
|
581
|
+
}
|
|
582
|
+
if (schemaVersion(db) === 8) {
|
|
583
|
+
// IF NOT EXISTS here, unlike earlier migration branches: a fully-bootstrapped
|
|
584
|
+
// :memory: fixture (used by unrelated tests that only roll user_version back to
|
|
585
|
+
// simulate an older *file* database) already has every table the current bootstrap
|
|
586
|
+
// DDL declares, this one included -- so this branch must be safe to run whether or
|
|
587
|
+
// not that already happened, not assume a truly-old database created it first.
|
|
588
|
+
db.exec(`
|
|
589
|
+
CREATE TABLE IF NOT EXISTS graph_projection_checkpoints (
|
|
590
|
+
producer_id TEXT PRIMARY KEY,
|
|
591
|
+
last_sequence INTEGER NOT NULL,
|
|
592
|
+
last_batch_id TEXT NOT NULL,
|
|
593
|
+
applied_at TEXT NOT NULL
|
|
594
|
+
);
|
|
595
|
+
CREATE TABLE IF NOT EXISTS graph_projection_identities (
|
|
596
|
+
producer_id TEXT NOT NULL,
|
|
597
|
+
external_id TEXT NOT NULL,
|
|
598
|
+
artifact_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
599
|
+
PRIMARY KEY (producer_id, external_id)
|
|
600
|
+
);
|
|
601
|
+
CREATE INDEX IF NOT EXISTS graph_projection_identities_artifact_idx ON graph_projection_identities(artifact_id);
|
|
602
|
+
PRAGMA user_version = 9;
|
|
603
|
+
`);
|
|
604
|
+
applied.push("graph-projection-protocol");
|
|
605
|
+
}
|
|
606
|
+
if (schemaVersion(db) === 9) {
|
|
607
|
+
db.exec(`
|
|
608
|
+
CREATE TABLE IF NOT EXISTS artifact_scopes (
|
|
609
|
+
artifact_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
610
|
+
project_root TEXT,
|
|
611
|
+
source TEXT NOT NULL CHECK (source IN ('cwd', 'explicit', 'unscoped')),
|
|
612
|
+
assigned_at TEXT NOT NULL
|
|
613
|
+
);
|
|
614
|
+
CREATE INDEX IF NOT EXISTS artifact_scopes_project_idx ON artifact_scopes(project_root, artifact_id);
|
|
615
|
+
PRAGMA user_version = 10;
|
|
616
|
+
`);
|
|
617
|
+
applied.push("docs-rules-skills-project-scope");
|
|
618
|
+
}
|
|
293
619
|
});
|
|
620
|
+
if (schemaVersion(db) === SQLITE_SCHEMA_VERSION) ensureCoreLedger(db, true);
|
|
294
621
|
return { from, to: schemaVersion(db), applied };
|
|
295
622
|
}
|
|
296
623
|
|
|
@@ -306,6 +633,7 @@ export function openDb(path: string): Db {
|
|
|
306
633
|
throw new Error(`database schema ${current} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
307
634
|
}
|
|
308
635
|
if (current === 0) bootstrapEmptyDatabase(db);
|
|
636
|
+
else if (current === SQLITE_SCHEMA_VERSION) ensureCoreLedger(db, true);
|
|
309
637
|
db.exec("PRAGMA optimize=0x10002");
|
|
310
638
|
return db;
|
|
311
639
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic, kind-agnostic mutation event log — the "who did what, when" answer
|
|
3
|
+
* shared by every artifact kind (doc, task, rule, skill), not reinvented per domain.
|
|
4
|
+
*
|
|
5
|
+
* Modeled after scribe's parchment.Event/EventFilter/GetEvents shape, but avoids its
|
|
6
|
+
* known gap: there, the Actor column is defined and filterable yet never populated by
|
|
7
|
+
* any caller. Here, actor/source default to explicit sentinels ("system"/"unknown")
|
|
8
|
+
* rather than being silently blank, and the event is appended by the same choke point
|
|
9
|
+
* that performs the mutation (src/ops.ts), so no domain call site can skip it.
|
|
10
|
+
*/
|
|
11
|
+
import { ARTIFACT_EVENT_ACTOR_MAX_LENGTH, ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT, ARTIFACT_EVENT_HISTORY_MAX_LIMIT } from "../constants.ts";
|
|
12
|
+
|
|
13
|
+
export const ARTIFACT_EVENT_TYPES = ["created", "updated", "status_changed", "extra_set", "linked", "unlinked"] as const;
|
|
14
|
+
export type ArtifactEventType = typeof ARTIFACT_EVENT_TYPES[number];
|
|
15
|
+
export type ArtifactEventDirection = "asc" | "desc";
|
|
16
|
+
|
|
17
|
+
/** Caller-supplied identity for a mutation. All fields are advisory (self-reported), not cryptographically verified. */
|
|
18
|
+
export interface ArtifactEventContext {
|
|
19
|
+
actor?: string;
|
|
20
|
+
source?: string;
|
|
21
|
+
sessionId?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ArtifactEvent {
|
|
25
|
+
id: number;
|
|
26
|
+
artifactId: string;
|
|
27
|
+
occurredAt: string;
|
|
28
|
+
type: ArtifactEventType;
|
|
29
|
+
actor: string;
|
|
30
|
+
source: string;
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
fromStatus?: string;
|
|
33
|
+
toStatus?: string;
|
|
34
|
+
relation?: string;
|
|
35
|
+
relatedId?: string;
|
|
36
|
+
schemaVersion: 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AppendArtifactEvent {
|
|
40
|
+
artifactId: string;
|
|
41
|
+
type: ArtifactEventType;
|
|
42
|
+
actor?: string;
|
|
43
|
+
source?: string;
|
|
44
|
+
sessionId?: string;
|
|
45
|
+
fromStatus?: string;
|
|
46
|
+
toStatus?: string;
|
|
47
|
+
relation?: string;
|
|
48
|
+
relatedId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ArtifactEventQuery {
|
|
52
|
+
artifactId?: string;
|
|
53
|
+
actor?: string;
|
|
54
|
+
sessionId?: string;
|
|
55
|
+
since?: string;
|
|
56
|
+
limit?: number;
|
|
57
|
+
cursor?: number;
|
|
58
|
+
direction?: ArtifactEventDirection;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ArtifactEventPage {
|
|
62
|
+
events: ArtifactEvent[];
|
|
63
|
+
nextCursor?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** No caller-supplied identity defaults to these explicit sentinels — never a silent blank. */
|
|
67
|
+
export const ARTIFACT_EVENT_DEFAULT_ACTOR = "system";
|
|
68
|
+
export const ARTIFACT_EVENT_DEFAULT_SOURCE = "unknown";
|
|
69
|
+
|
|
70
|
+
function boundedString(value: string, field: string, maximum: number): string {
|
|
71
|
+
if (value.length === 0 || value.length > maximum) throw new Error(`${field} must be between 1 and ${maximum} characters`);
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Fills defaults and enforces bounds. The one place every appended event is normalized. */
|
|
76
|
+
export function resolveArtifactEvent(input: AppendArtifactEvent): Required<Pick<AppendArtifactEvent, "actor" | "source">> & AppendArtifactEvent {
|
|
77
|
+
if (!input.artifactId) throw new Error("artifactId is required");
|
|
78
|
+
const actor = boundedString(input.actor ?? ARTIFACT_EVENT_DEFAULT_ACTOR, "actor", ARTIFACT_EVENT_ACTOR_MAX_LENGTH);
|
|
79
|
+
const source = boundedString(input.source ?? ARTIFACT_EVENT_DEFAULT_SOURCE, "source", ARTIFACT_EVENT_ACTOR_MAX_LENGTH);
|
|
80
|
+
if (input.sessionId !== undefined) boundedString(input.sessionId, "sessionId", ARTIFACT_EVENT_ACTOR_MAX_LENGTH);
|
|
81
|
+
return { ...input, actor, source };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function normalizeArtifactEventQuery(query: ArtifactEventQuery): Required<Pick<ArtifactEventQuery, "limit" | "direction">> & ArtifactEventQuery {
|
|
85
|
+
if (!query.artifactId && !query.actor && !query.sessionId) {
|
|
86
|
+
throw new Error("artifact event query requires artifactId, actor, or sessionId to stay bounded");
|
|
87
|
+
}
|
|
88
|
+
const limit = query.limit ?? ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT;
|
|
89
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > ARTIFACT_EVENT_HISTORY_MAX_LIMIT) {
|
|
90
|
+
throw new Error(`artifact event limit must be between 1 and ${ARTIFACT_EVENT_HISTORY_MAX_LIMIT}`);
|
|
91
|
+
}
|
|
92
|
+
if (query.cursor !== undefined && (!Number.isInteger(query.cursor) || query.cursor < 1)) {
|
|
93
|
+
throw new Error("artifact event cursor must be a positive integer");
|
|
94
|
+
}
|
|
95
|
+
if (query.direction !== undefined && query.direction !== "asc" && query.direction !== "desc") {
|
|
96
|
+
throw new Error("artifact event direction must be asc or desc");
|
|
97
|
+
}
|
|
98
|
+
return { ...query, limit, direction: query.direction ?? "desc" };
|
|
99
|
+
}
|