@danypops/papyrus 0.11.4 → 0.13.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/artifact-browser.ts +13 -7
- package/extension/src/artifact-status-presentation.ts +53 -0
- package/extension/src/context-budget.ts +173 -0
- package/extension/src/context-view.ts +172 -0
- package/extension/src/docs.ts +6 -5
- package/extension/src/domain-tools.ts +108 -52
- package/extension/src/index.ts +124 -38
- package/extension/src/notes.ts +16 -4
- package/extension/src/rules.ts +7 -7
- package/extension/src/skill-catalog-footprint.ts +183 -0
- package/extension/src/skills.ts +2 -3
- package/extension/src/task-focus-events.ts +57 -0
- package/extension/src/task-widget.ts +13 -1
- 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 +77 -5
- package/src/conversation-journal-service.ts +87 -0
- package/src/db.ts +285 -33
- 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/skill-definition.ts +57 -8
- package/src/domain/task-event.ts +4 -0
- package/src/domain-services.ts +201 -40
- 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/skill-execution.ts +169 -75
- 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,8 +25,28 @@ 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
|
|
|
33
|
+
/**
|
|
34
|
+
* A Papyrus Rule's condition+action+body is injected into EVERY relevant turn's system
|
|
35
|
+
* prompt for the lifetime of the rule -- the same permanent, always-on-context role as an
|
|
36
|
+
* Agent Skill's name+description (per the Agent Skills spec's progressive-disclosure model:
|
|
37
|
+
* metadata ~100 tokens, always loaded; full instructions <5000 tokens, loaded only on
|
|
38
|
+
* activation). Anthropic's own context-engineering guidance is not a hard length rule but a
|
|
39
|
+
* signal-density principle -- "the smallest possible set of high-signal tokens", explicitly
|
|
40
|
+
* NOT "minimal means short" -- so RULE_TEXT_SOFT_TARGET_CHARACTERS is a target to aim for,
|
|
41
|
+
* not a rejection threshold. RULE_TEXT_HARD_LIMIT_CHARACTERS is the actual enforced ceiling,
|
|
42
|
+
* generous enough to allow a real rule to breathe, but catching genuinely runaway bloat that
|
|
43
|
+
* would tax every single turn. Above the hard limit, split into a short Rule (condition +
|
|
44
|
+
* the invariant) plus a linked Doc for full reasoning -- the pattern this codebase's own
|
|
45
|
+
* active rules already use via "Source: Lexicon <path>" references.
|
|
46
|
+
*/
|
|
47
|
+
export const RULE_TEXT_SOFT_TARGET_CHARACTERS = 600;
|
|
48
|
+
export const RULE_TEXT_HARD_LIMIT_CHARACTERS = 4000;
|
|
49
|
+
|
|
24
50
|
/** Compact task-context limits keep recurring prompt injection bounded. */
|
|
25
51
|
export const TASK_CONTEXT_CURRENT_LIMIT = 3;
|
|
26
52
|
export const TASK_CONTEXT_REJECTED_LIMIT = 3;
|
|
@@ -48,6 +74,28 @@ export const SKILL_MAX_ENUM_VALUES = 32;
|
|
|
48
74
|
export const SKILL_MAX_BLUEPRINTS = 100;
|
|
49
75
|
export const SKILL_MAX_LINKS = 500;
|
|
50
76
|
export const SKILL_MAX_RENDERED_BYTES = 1_048_576;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Skills are special: invoking one queries Papyrus for whatever it's actually graph-linked
|
|
80
|
+
* to (existing Tasks/Rules/Docs via ordinary edges, not just its own static body/extra
|
|
81
|
+
* fields), and a Skill can link to and invoke other Skills. Both traversals are bounded and
|
|
82
|
+
* cycle-safe -- a skill-calls-skill edge cycle must not infinite-loop invocation, matching
|
|
83
|
+
* the cycle-safety discipline already established for ConversationJournal reply chains and
|
|
84
|
+
* task dependency graphs.
|
|
85
|
+
*/
|
|
86
|
+
export const SKILL_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
|
|
87
|
+
export const SKILL_INVOCATION_MAX_CALL_DEPTH = 4;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* At the core, a workflow Skill creates Tasks and begins a pipeline -- an Ansible playbook or
|
|
91
|
+
* a Jenkins job, not just a text prompt. A pipeline step can itself trigger another workflow
|
|
92
|
+
* Skill's run (a nested sub-pipeline, like a Jenkins job triggering a downstream job and
|
|
93
|
+
* waiting for it), bounded and cycle-safe: a real skill-calls-skill cycle during EXECUTION
|
|
94
|
+
* (not just invocation preview) must fail loudly and roll back the whole atomic run, not
|
|
95
|
+
* silently truncate, since a silently-truncated pipeline would leave a confusing partial
|
|
96
|
+
* Task graph behind.
|
|
97
|
+
*/
|
|
98
|
+
export const SKILL_WORKFLOW_MAX_NESTING_DEPTH = 4;
|
|
51
99
|
export const SKILL_RUN_ID_MAX_LENGTH = 64;
|
|
52
100
|
/** Bounded automatic Pi continuations while a focused Papyrus Task remains. */
|
|
53
101
|
export const TASK_DRIVER_MAX_TURNS = 20;
|
|
@@ -71,8 +119,23 @@ export const NOTE_LIST_MAX_LIMIT = 200;
|
|
|
71
119
|
export const NOTE_HISTORY_MAX_EVENTS = 20;
|
|
72
120
|
export const NOTE_PROVENANCE_MAX_LENGTH = 128;
|
|
73
121
|
export const NOTE_REASON_MAX_CHARACTERS = 2_000;
|
|
122
|
+
/** Generic, kind-agnostic mutation event log bounds (doc/task/rule/skill share one log). */
|
|
123
|
+
export const ARTIFACT_EVENT_ACTOR_MAX_LENGTH = 128;
|
|
124
|
+
export const ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT = 25;
|
|
125
|
+
export const ARTIFACT_EVENT_HISTORY_MAX_LIMIT = 200;
|
|
126
|
+
/** Bounds for the generic graph projection protocol (external bounded contexts). */
|
|
127
|
+
export const GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH = 500;
|
|
128
|
+
export const GRAPH_PROJECTION_MAX_EDGES_PER_BATCH = 1_000;
|
|
129
|
+
export const GRAPH_PROJECTION_ID_MAX_LENGTH = 256;
|
|
130
|
+
/** Per-agent-session Task Focus scoping. "global" is the default scope for callers that don't supply a session id (CLI, legacy behavior). */
|
|
131
|
+
export const TASK_FOCUS_DEFAULT_SCOPE = "global";
|
|
132
|
+
export const TASK_FOCUS_SCOPE_MAX_LENGTH = 128;
|
|
133
|
+
/** Hard cap on distinct concurrent focus scopes (sessions); oldest-updated scope is evicted beyond this. */
|
|
134
|
+
export const TASK_FOCUS_MAX_SCOPES = 500;
|
|
74
135
|
/** Persisted project and focused-graph Task view bounds. */
|
|
75
136
|
export const TASK_SCOPE_MAX_TASKS = 1_000;
|
|
137
|
+
/** Docs/Rules/Skills project scope listing bound, mirroring TASK_SCOPE_MAX_TASKS. */
|
|
138
|
+
export const ARTIFACT_SCOPE_MAX_ARTIFACTS = 1_000;
|
|
76
139
|
export const TASK_PROJECT_ROOT_MAX_LENGTH = 4_096;
|
|
77
140
|
export const GRAPH_RENDER_PADDING_X = 2;
|
|
78
141
|
export const GRAPH_RENDER_PADDING_Y = 1;
|
|
@@ -93,14 +156,23 @@ export const DEFAULT_METADATA_DEPTH = 6;
|
|
|
93
156
|
export const DEFAULT_METADATA_ITEMS = 100;
|
|
94
157
|
export const MAX_METADATA_DEPTH = 12;
|
|
95
158
|
export const MAX_METADATA_ITEMS = 500;
|
|
159
|
+
/** Independent bounds for model-facing tool content and persisted renderer details. */
|
|
160
|
+
export const TOOL_MODEL_CONTENT_MAX_CHARACTERS = 12_000;
|
|
161
|
+
export const TOOL_DETAILS_BODY_MAX_CHARACTERS = 20_000;
|
|
162
|
+
export const TOOL_DETAILS_FIELD_MAX_CHARACTERS = 1_000;
|
|
163
|
+
export const TOOL_DETAILS_ROW_OUTPUT_MAX_CHARACTERS = 1_000;
|
|
164
|
+
export const TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS = 131_072;
|
|
165
|
+
export const TOOL_COLLAPSED_ROW_LIMIT = 5;
|
|
166
|
+
export const TOOL_DETAILS_MAX_ITEMS = 100;
|
|
167
|
+
export const TOOL_DETAILS_MAX_EDGES = 200;
|
|
96
168
|
|
|
97
169
|
/** Reconciliation instruction appended whenever Papyrus has open work. */
|
|
98
170
|
export const TASK_RECONCILIATION_INSTRUCTION = [
|
|
99
171
|
"Reconcile before concluding or moving on:",
|
|
100
172
|
'• 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.",
|
|
173
|
+
"• 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.",
|
|
174
|
+
"• 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.",
|
|
175
|
+
"• 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
176
|
].join("\n");
|
|
105
177
|
|
|
106
178
|
/** $XDG_DATA_HOME/papyrus/papyrus.db */
|
|
@@ -175,5 +247,5 @@ export const DEFAULT_STATUS_BY_KIND: Readonly<Record<string, string>> = {
|
|
|
175
247
|
export const SEED_RELATIONS = [
|
|
176
248
|
"references", "implements", "follows", "depends_on",
|
|
177
249
|
"documents", "blocks", "supersedes", "relates_to",
|
|
178
|
-
"gates", "triggers", "contains", "part_of",
|
|
250
|
+
"gates", "triggers", "contains", "part_of", "reply_to", "discusses",
|
|
179
251
|
] 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
|
@@ -107,8 +107,8 @@ CREATE TABLE IF NOT EXISTS relation_names (
|
|
|
107
107
|
description TEXT
|
|
108
108
|
);
|
|
109
109
|
CREATE TABLE IF NOT EXISTS task_focus (
|
|
110
|
-
scope TEXT PRIMARY KEY
|
|
111
|
-
task_id TEXT NOT NULL
|
|
110
|
+
scope TEXT PRIMARY KEY,
|
|
111
|
+
task_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
112
112
|
status TEXT NOT NULL CHECK (status IN ('active', 'paused')),
|
|
113
113
|
pause_reason TEXT,
|
|
114
114
|
updated_at TEXT NOT NULL
|
|
@@ -147,6 +147,109 @@ CREATE TABLE IF NOT EXISTS task_views (
|
|
|
147
147
|
updated_at TEXT NOT NULL,
|
|
148
148
|
CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
|
|
149
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);
|
|
150
253
|
`;
|
|
151
254
|
|
|
152
255
|
const SEED_SQL = `
|
|
@@ -179,6 +282,8 @@ INSERT OR IGNORE INTO relation_names VALUES ('gates','This rule gates that task
|
|
|
179
282
|
INSERT OR IGNORE INTO relation_names VALUES ('triggers','This skill applies to that work (skill→task)');
|
|
180
283
|
INSERT OR IGNORE INTO relation_names VALUES ('contains','Parent contains a nested artifact (any→any)');
|
|
181
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');
|
|
182
287
|
CREATE INDEX IF NOT EXISTS edges_to_id_idx ON edges(to_id);
|
|
183
288
|
`;
|
|
184
289
|
|
|
@@ -194,11 +299,7 @@ export function schemaVersion(db: Db): number {
|
|
|
194
299
|
|
|
195
300
|
/**
|
|
196
301
|
* One row per (module_id, version) applied migration, checksummed so a since-edited
|
|
197
|
-
* definition is detected rather than silently trusted.
|
|
198
|
-
* repository's entire pre-ledger migration history (every schemaVersion 1..CURRENT
|
|
199
|
-
* branch below) into one baseline, checked against the exact SCHEMA+SEED_SQL text those
|
|
200
|
-
* branches converge on -- new modules going forward register their own migrations
|
|
201
|
-
* independently, without touching "core" or duplicating a separate bootstrap path.
|
|
302
|
+
* definition is detected rather than silently trusted.
|
|
202
303
|
*/
|
|
203
304
|
export interface ModuleMigrationRow {
|
|
204
305
|
readonly moduleId: string;
|
|
@@ -208,7 +309,25 @@ export interface ModuleMigrationRow {
|
|
|
208
309
|
readonly appliedAt: string;
|
|
209
310
|
}
|
|
210
311
|
|
|
211
|
-
|
|
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
|
+
];
|
|
212
331
|
|
|
213
332
|
export function migrationLedger(db: Db): ModuleMigrationRow[] {
|
|
214
333
|
// A database that has never reached current schema (still awaiting explicit migrateDb())
|
|
@@ -222,15 +341,19 @@ export function migrationLedger(db: Db): ModuleMigrationRow[] {
|
|
|
222
341
|
}
|
|
223
342
|
|
|
224
343
|
/**
|
|
225
|
-
* Ensures the ledger correctly reflects
|
|
226
|
-
* current schema, however it got there: a truly empty
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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.
|
|
232
355
|
*/
|
|
233
|
-
function
|
|
356
|
+
function ensureCoreLedger(db: Db, alreadyAtCurrentSchema: boolean): void {
|
|
234
357
|
// Idempotent and standalone: must succeed even on a truly empty database, before the rest
|
|
235
358
|
// of SCHEMA (which also declares this table) has run.
|
|
236
359
|
db.exec(`
|
|
@@ -243,21 +366,24 @@ function ensureCoreBaseline(db: Db, alreadyAtCurrentSchema: boolean): void {
|
|
|
243
366
|
PRIMARY KEY (module_id, version)
|
|
244
367
|
);
|
|
245
368
|
`);
|
|
246
|
-
const existingRow = db.prepare("SELECT checksum FROM module_migrations WHERE module_id = 'core' AND version = 1").get() as { checksum: string } | null;
|
|
247
|
-
if (existingRow != null) {
|
|
248
|
-
if (existingRow.checksum !== CORE_BASELINE_CHECKSUM) {
|
|
249
|
-
throw new Error('module migration "core" version 1 checksum mismatch: the baseline definition changed since it was applied');
|
|
250
|
-
}
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
369
|
inTransaction(db, () => {
|
|
254
|
-
|
|
255
|
-
db.
|
|
256
|
-
|
|
257
|
-
|
|
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());
|
|
258
386
|
}
|
|
259
|
-
db.prepare("INSERT INTO module_migrations (module_id, version, name, checksum, applied_at) VALUES ('core', 1, 'baseline', ?, ?)")
|
|
260
|
-
.run(CORE_BASELINE_CHECKSUM, new Date().toISOString());
|
|
261
387
|
});
|
|
262
388
|
}
|
|
263
389
|
|
|
@@ -266,7 +392,7 @@ function bootstrapEmptyDatabase(db: Db): void {
|
|
|
266
392
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1")
|
|
267
393
|
.get();
|
|
268
394
|
if (existing) throw new Error("database schema is unversioned; refusing to migrate existing data during boot");
|
|
269
|
-
|
|
395
|
+
ensureCoreLedger(db, false);
|
|
270
396
|
}
|
|
271
397
|
|
|
272
398
|
export function migrateDb(db: Db): MigrationResult {
|
|
@@ -275,7 +401,7 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
275
401
|
throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
276
402
|
}
|
|
277
403
|
if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
|
|
278
|
-
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}`);
|
|
279
405
|
const applied: string[] = [];
|
|
280
406
|
|
|
281
407
|
inTransaction(db, () => {
|
|
@@ -364,8 +490,134 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
364
490
|
`);
|
|
365
491
|
applied.push("task-focus-continuation");
|
|
366
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
|
+
}
|
|
367
619
|
});
|
|
368
|
-
if (schemaVersion(db) === SQLITE_SCHEMA_VERSION)
|
|
620
|
+
if (schemaVersion(db) === SQLITE_SCHEMA_VERSION) ensureCoreLedger(db, true);
|
|
369
621
|
return { from, to: schemaVersion(db), applied };
|
|
370
622
|
}
|
|
371
623
|
|
|
@@ -381,7 +633,7 @@ export function openDb(path: string): Db {
|
|
|
381
633
|
throw new Error(`database schema ${current} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
382
634
|
}
|
|
383
635
|
if (current === 0) bootstrapEmptyDatabase(db);
|
|
384
|
-
else if (current === SQLITE_SCHEMA_VERSION)
|
|
636
|
+
else if (current === SQLITE_SCHEMA_VERSION) ensureCoreLedger(db, true);
|
|
385
637
|
db.exec("PRAGMA optimize=0x10002");
|
|
386
638
|
return db;
|
|
387
639
|
}
|