@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.
Files changed (46) hide show
  1. package/README.md +16 -2
  2. package/extension/src/active-task-continuation.ts +6 -0
  3. package/extension/src/domain-tools.ts +108 -52
  4. package/extension/src/index.ts +90 -37
  5. package/extension/src/notes.ts +14 -1
  6. package/extension/src/task-focus-events.ts +57 -0
  7. package/extension/src/tasks.ts +51 -15
  8. package/extension/src/tool-rendering/artifact-card.ts +117 -0
  9. package/extension/src/tool-rendering/artifact-list.ts +179 -0
  10. package/extension/src/tool-rendering/index.ts +107 -0
  11. package/extension/src/tool-rendering/render-model.ts +406 -0
  12. package/package.json +4 -2
  13. package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
  14. package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
  15. package/src/adapters/sqlite-artifact-store.ts +20 -11
  16. package/src/adapters/sqlite-discourse-store.ts +325 -0
  17. package/src/adapters/sqlite-graph-projection-store.ts +41 -0
  18. package/src/adapters/sqlite-task-focus-store.ts +34 -15
  19. package/src/authority-registry.ts +115 -0
  20. package/src/cli.ts +904 -124
  21. package/src/constants.ts +38 -5
  22. package/src/conversation-journal-service.ts +87 -0
  23. package/src/db.ts +336 -8
  24. package/src/domain/artifact-event.ts +99 -0
  25. package/src/domain/conversation-journal.ts +168 -0
  26. package/src/domain/discourse-store.ts +142 -0
  27. package/src/domain/graph-projection.ts +74 -0
  28. package/src/domain/task-event.ts +4 -0
  29. package/src/domain-services.ts +133 -38
  30. package/src/graph-projection-service.ts +103 -0
  31. package/src/id-migration.ts +200 -0
  32. package/src/module-registry.ts +53 -0
  33. package/src/modules/docs.ts +77 -0
  34. package/src/modules/graph-projection.ts +82 -0
  35. package/src/modules/notes.ts +76 -0
  36. package/src/modules/rules.ts +81 -0
  37. package/src/modules/skills.ts +113 -0
  38. package/src/modules/tasks.ts +164 -0
  39. package/src/ops.ts +142 -15
  40. package/src/ports/artifact-scope-store.ts +20 -0
  41. package/src/ports/artifact-store.ts +10 -5
  42. package/src/ports/conversation-journal-store.ts +17 -0
  43. package/src/ports/graph-projection-store.ts +15 -0
  44. package/src/ports/task-focus-store.ts +62 -20
  45. package/src/service.ts +218 -223
  46. package/src/task-service.ts +70 -38
@@ -1,12 +1,51 @@
1
+ import { ARTIFACT_SCOPE_MAX_ARTIFACTS } from "./constants.ts";
1
2
  import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
3
+ import type { ArtifactEventContext } from "./domain/artifact-event.ts";
4
+ import { normalizeProjectRoot } from "./domain/task-scope.ts";
2
5
  import { validateSkillDefinition } from "./domain/skill-definition.ts";
3
6
  import type { ArtifactStore } from "./ports/artifact-store.ts";
7
+ import type { ArtifactScopeStore } from "./ports/artifact-scope-store.ts";
4
8
  import { NOTE_SUBTYPE } from "./note-service.ts";
9
+ import type { AuthorityRegistry } from "./authority-registry.ts";
5
10
 
6
11
  export interface ListFilter {
7
12
  status?: string;
8
13
  text?: string;
9
14
  limit?: number;
15
+ /** When supplied, results are limited to artifacts scoped to this project (or the unscoped bucket, for an empty string is not accepted -- use assignArtifactProject's own validation). */
16
+ projectRoot?: string;
17
+ }
18
+
19
+ /**
20
+ * Shared by listDocuments/listRules/listSkills: when filter.projectRoot is given, resolve
21
+ * via ArtifactScopeStore first and post-filter by kind/status/text (mirrors Tasks.list's
22
+ * established scoped-listing shape); otherwise fall back to the existing unscoped query
23
+ * path unchanged, so every caller that predates project scoping keeps working exactly as
24
+ * before.
25
+ */
26
+ function listScoped(artifacts: ArtifactStore, scopes: ArtifactScopeStore, kind: string, filter: ListFilter, excludeSubtype?: string): Artifact[] {
27
+ if (filter.projectRoot === undefined) return artifacts.query({ kind, excludeSubtype, status: filter.status, text: filter.text, limit: filter.limit });
28
+ const limit = filter.limit ?? ARTIFACT_SCOPE_MAX_ARTIFACTS;
29
+ if (!Number.isInteger(limit) || limit < 1 || limit > ARTIFACT_SCOPE_MAX_ARTIFACTS) {
30
+ throw new Error(`list limit must be between 1 and ${ARTIFACT_SCOPE_MAX_ARTIFACTS}`);
31
+ }
32
+ const projectRoot = normalizeProjectRoot(filter.projectRoot);
33
+ const ids = scopes.ids(projectRoot, ARTIFACT_SCOPE_MAX_ARTIFACTS);
34
+ const text = filter.text?.toLowerCase();
35
+ return ids
36
+ .map((id) => artifacts.get(id))
37
+ .filter((artifact): artifact is Artifact => artifact?.kind === kind && artifact.subtype !== excludeSubtype)
38
+ .filter((artifact) => filter.status === undefined || artifact.status === filter.status)
39
+ .filter((artifact) => text === undefined || artifact.title.toLowerCase().includes(text) || artifact.body.toLowerCase().includes(text))
40
+ .sort((left, right) => right.updated_at.localeCompare(left.updated_at) || left.id.localeCompare(right.id))
41
+ .slice(0, limit);
42
+ }
43
+
44
+ /** Shared by assignDocumentProject/assignRuleProject/assignSkillProject. */
45
+ function assignArtifactProject(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, kind: string, projectRoot: string | undefined): Artifact {
46
+ requireKind(artifacts, id, kind);
47
+ scopes.assign(id, projectRoot === undefined ? undefined : normalizeProjectRoot(projectRoot), projectRoot === undefined ? "unscoped" : "explicit");
48
+ return artifacts.get(id)!;
10
49
  }
11
50
 
12
51
  function requireKind(artifacts: ArtifactStore, id: string, kind: string): Artifact {
@@ -25,10 +64,25 @@ function rejectsNoteTemplate(artifacts: ArtifactStore, templateId: string | unde
25
64
  && (defaults as Record<string, unknown>)["subtype"] === NOTE_SUBTYPE;
26
65
  }
27
66
 
28
- function requireNotesFacade(): never {
67
+ /** caller never owns NOTE_SUBTYPE, so requireArtifactAllowed always throws — the trailing throw only satisfies TypeScript's control-flow analysis for a `never`-returning function. */
68
+ function requireNotesFacade(authority: AuthorityRegistry, caller: string): never {
69
+ authority.requireArtifactAllowed("doc", NOTE_SUBTYPE, "create", caller);
29
70
  throw new Error("note creation requires notes.capture");
30
71
  }
31
72
 
73
+ function templateSubtype(artifacts: ArtifactStore, templateId: string | undefined): string | undefined {
74
+ if (!templateId) return undefined;
75
+ const defaults = artifacts.get(templateId)?.extra["defaults"];
76
+ if (typeof defaults !== "object" || defaults === null || Array.isArray(defaults)) return undefined;
77
+ const subtype = (defaults as Record<string, unknown>)["subtype"];
78
+ return typeof subtype === "string" ? subtype : undefined;
79
+ }
80
+
81
+ function requireMutableDocument(document: Artifact, authority: AuthorityRegistry): Artifact {
82
+ authority.requireArtifactAllowed(document.kind, document.subtype, "status", "docs");
83
+ return document;
84
+ }
85
+
32
86
  export interface CreateDocumentInput {
33
87
  title: string;
34
88
  body?: string;
@@ -36,6 +90,8 @@ export interface CreateDocumentInput {
36
90
  labels?: string[];
37
91
  extra?: Record<string, unknown>;
38
92
  templateId?: string;
93
+ /** Optional at creation, unlike Tasks -- omitting it leaves the Doc in the unscoped bucket, matching today's default behavior for every existing caller. */
94
+ projectRoot?: string;
39
95
  }
40
96
 
41
97
  export type DocumentTransition = "activate" | "archive" | "reopen";
@@ -47,21 +103,35 @@ const DOCUMENT_TRANSITIONS: Record<DocumentTransition, { from: string[]; to: str
47
103
  reopen: { from: ["archived"], to: "draft" },
48
104
  };
49
105
 
50
- export function createDocument(artifacts: ArtifactStore, input: CreateDocumentInput): Artifact {
51
- if (rejectsNoteTemplate(artifacts, input.templateId, input.subtype)) requireNotesFacade();
52
- return artifacts.create({
106
+ export function createDocument(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreateDocumentInput, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
107
+ if (rejectsNoteTemplate(artifacts, input.templateId, input.subtype)) requireNotesFacade(authority, "docs");
108
+ authority.requireArtifactAllowed("doc", input.subtype ?? templateSubtype(artifacts, input.templateId), "create", "docs");
109
+ const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
110
+ const document = artifacts.create({
53
111
  kind: "doc",
112
+ // Explicit, not defaultStatusFor's "first status row by rowid" fallback -- the same
113
+ // heuristic that made Task creation non-deterministic on a migrated database. Every
114
+ // creation path that has no caller-supplied initial status must set one explicitly.
115
+ status: "draft",
54
116
  title: input.title,
55
117
  body: input.body,
56
118
  subtype: input.subtype,
57
119
  labels: input.labels,
58
120
  extra: input.extra,
59
121
  templateId: input.templateId,
60
- });
122
+ }, context);
123
+ scopes.assign(document.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
124
+ return document;
125
+ }
126
+
127
+ export function listDocuments(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
128
+ return listScoped(artifacts, scopes, "doc", filter, NOTE_SUBTYPE);
61
129
  }
62
130
 
63
- export function listDocuments(artifacts: ArtifactStore, filter: ListFilter): Artifact[] {
64
- return artifacts.query({ kind: "doc", excludeSubtype: NOTE_SUBTYPE, ...filter });
131
+ export function assignDocumentProject(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, projectRoot: string | undefined): Artifact {
132
+ requireDocument(artifacts, id); // rejects Notes -- project reassignment for notes goes through notes.* like everything else about them
133
+ scopes.assign(id, projectRoot === undefined ? undefined : normalizeProjectRoot(projectRoot), projectRoot === undefined ? "unscoped" : "explicit");
134
+ return artifacts.get(id)!;
65
135
  }
66
136
 
67
137
  function requireDocument(artifacts: ArtifactStore, id: string): Artifact {
@@ -75,17 +145,19 @@ export function showDocument(artifacts: ArtifactStore, id: string): Artifact {
75
145
  return artifacts.get(id, { tree: true })!;
76
146
  }
77
147
 
78
- export function transitionDocument(artifacts: ArtifactStore, id: string, action: DocumentTransition): Artifact {
79
- const document = requireDocument(artifacts, id);
148
+ export function transitionDocument(artifacts: ArtifactStore, id: string, action: DocumentTransition, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
149
+ const document = requireMutableDocument(requireDocument(artifacts, id), authority);
80
150
  const transition = DOCUMENT_TRANSITIONS[action];
81
151
  if (!transition.from.includes(document.status)) throw new Error(`cannot ${action} document from ${document.status}`);
82
- return artifacts.setStatus(id, transition.to)!;
152
+ return artifacts.setStatus(id, transition.to, context)!;
83
153
  }
84
154
 
85
- export function linkDocument(artifacts: ArtifactStore, id: string, relation: DocumentRelation, targetId: string): Artifact {
86
- requireDocument(artifacts, id);
87
- if (!artifacts.get(targetId)) throw new Error(`target artifact "${targetId}" not found`);
88
- artifacts.link({ from: id, relation, to: targetId });
155
+ export function linkDocument(artifacts: ArtifactStore, id: string, relation: DocumentRelation, targetId: string, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
156
+ requireMutableDocument(requireDocument(artifacts, id), authority);
157
+ const target = artifacts.get(targetId);
158
+ if (!target) throw new Error(`target artifact "${targetId}" not found`);
159
+ requireMutableDocument(target, authority);
160
+ artifacts.link({ from: id, relation, to: targetId }, context);
89
161
  return showDocument(artifacts, id);
90
162
  }
91
163
 
@@ -97,13 +169,16 @@ export interface CreateRuleInput {
97
169
  severity?: "block" | "warn" | "info";
98
170
  labels?: string[];
99
171
  extra?: Record<string, unknown>;
172
+ projectRoot?: string;
100
173
  }
101
174
 
102
175
  export type RuleTransition = "enable" | "disable";
103
176
 
104
- export function createRule(artifacts: ArtifactStore, input: CreateRuleInput): Artifact {
105
- return artifacts.create({
177
+ export function createRule(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreateRuleInput, context?: ArtifactEventContext): Artifact {
178
+ const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
179
+ const rule = artifacts.create({
106
180
  kind: "rule",
181
+ status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
107
182
  title: input.title,
108
183
  body: input.body,
109
184
  labels: input.labels,
@@ -113,11 +188,17 @@ export function createRule(artifacts: ArtifactStore, input: CreateRuleInput): Ar
113
188
  ...(input.action ? { action: input.action } : {}),
114
189
  severity: input.severity ?? "info",
115
190
  },
116
- });
191
+ }, context);
192
+ scopes.assign(rule.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
193
+ return rule;
117
194
  }
118
195
 
119
- export function listRules(artifacts: ArtifactStore, filter: ListFilter): Artifact[] {
120
- return artifacts.query({ kind: "rule", ...filter });
196
+ export function listRules(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
197
+ return listScoped(artifacts, scopes, "rule", filter);
198
+ }
199
+
200
+ export function assignRuleProject(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, projectRoot: string | undefined): Artifact {
201
+ return assignArtifactProject(artifacts, scopes, id, "rule", projectRoot);
121
202
  }
122
203
 
123
204
  /** Global rules always apply; scoped workflow rules apply only while their run owns active focus. */
@@ -144,18 +225,18 @@ export function previewRule(artifacts: ArtifactStore, id: string): string {
144
225
  return `• ${rule.title}${condition}\n ${action}`;
145
226
  }
146
227
 
147
- export function transitionRule(artifacts: ArtifactStore, id: string, action: RuleTransition): Artifact {
228
+ export function transitionRule(artifacts: ArtifactStore, id: string, action: RuleTransition, context?: ArtifactEventContext): Artifact {
148
229
  const rule = requireKind(artifacts, id, "rule");
149
230
  const expected = action === "enable" ? "deprecated" : "active";
150
231
  const target = action === "enable" ? "active" : "deprecated";
151
232
  if (rule.status !== expected) throw new Error(`cannot ${action} rule from ${rule.status}`);
152
- return artifacts.setStatus(id, target)!;
233
+ return artifacts.setStatus(id, target, context)!;
153
234
  }
154
235
 
155
- export function gateTaskWithRule(artifacts: ArtifactStore, ruleId: string, taskId: string): Artifact {
236
+ export function gateTaskWithRule(artifacts: ArtifactStore, ruleId: string, taskId: string, context?: ArtifactEventContext): Artifact {
156
237
  requireKind(artifacts, ruleId, "rule");
157
238
  requireKind(artifacts, taskId, "task");
158
- artifacts.link({ from: ruleId, relation: "gates", to: taskId });
239
+ artifacts.link({ from: ruleId, relation: "gates", to: taskId }, context);
159
240
  return showRule(artifacts, ruleId);
160
241
  }
161
242
 
@@ -168,6 +249,7 @@ export interface CreateSkillInput {
168
249
  definition?: unknown;
169
250
  labels?: string[];
170
251
  extra?: Record<string, unknown>;
252
+ projectRoot?: string;
171
253
  }
172
254
 
173
255
  export interface CreateArtifactTemplateInput {
@@ -177,18 +259,21 @@ export interface CreateArtifactTemplateInput {
177
259
  required?: string[];
178
260
  body?: string;
179
261
  labels?: string[];
262
+ projectRoot?: string;
180
263
  }
181
264
 
182
265
  export type SkillTransition = "enable" | "disable";
183
266
 
184
- export function createSkill(artifacts: ArtifactStore, input: CreateSkillInput): Artifact {
267
+ export function createSkill(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreateSkillInput, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
185
268
  if (input.definition !== undefined && (input.trigger !== undefined || input.steps !== undefined || input.tools !== undefined)) {
186
269
  throw new Error("workflow Skill definition cannot be mixed with legacy trigger, steps, or tools");
187
270
  }
188
271
  const definition = input.definition === undefined ? undefined : validateSkillDefinition(input.definition);
189
- if (definition?.blueprints.docs.some((document) => document.subtype === NOTE_SUBTYPE)) requireNotesFacade();
190
- return artifacts.create({
272
+ if (definition?.blueprints.docs.some((document) => document.subtype === NOTE_SUBTYPE)) requireNotesFacade(authority, "skills");
273
+ const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
274
+ const skill = artifacts.create({
191
275
  kind: "skill",
276
+ status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
192
277
  subtype: definition ? "workflow" : undefined,
193
278
  title: input.title,
194
279
  body: input.body,
@@ -200,13 +285,17 @@ export function createSkill(artifacts: ArtifactStore, input: CreateSkillInput):
200
285
  ...(input.steps ? { steps: input.steps } : {}),
201
286
  ...(input.tools ? { tools: input.tools } : {}),
202
287
  },
203
- });
288
+ }, context);
289
+ scopes.assign(skill.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
290
+ return skill;
204
291
  }
205
292
 
206
- export function createArtifactTemplate(artifacts: ArtifactStore, input: CreateArtifactTemplateInput): Artifact {
207
- if (input.targetKind === "doc" && input.defaults?.["subtype"] === NOTE_SUBTYPE) requireNotesFacade();
208
- return artifacts.create({
293
+ export function createArtifactTemplate(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreateArtifactTemplateInput, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
294
+ if (input.targetKind === "doc" && input.defaults?.["subtype"] === NOTE_SUBTYPE) requireNotesFacade(authority, "skills");
295
+ const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
296
+ const template = artifacts.create({
209
297
  kind: "skill",
298
+ status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
210
299
  subtype: "artifact-template",
211
300
  title: input.title,
212
301
  body: input.body,
@@ -216,16 +305,22 @@ export function createArtifactTemplate(artifacts: ArtifactStore, input: CreateAr
216
305
  defaults: input.defaults ?? {},
217
306
  required: input.required ?? ["title"],
218
307
  },
219
- });
308
+ }, context);
309
+ scopes.assign(template.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
310
+ return template;
311
+ }
312
+
313
+ export function instantiateTemplate(artifacts: ArtifactStore, templateId: string, input: CreateArtifactInput, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
314
+ if (rejectsNoteTemplate(artifacts, templateId, input.subtype)) requireNotesFacade(authority, "skills");
315
+ return artifacts.create({ ...input, templateId }, context);
220
316
  }
221
317
 
222
- export function instantiateTemplate(artifacts: ArtifactStore, templateId: string, input: CreateArtifactInput): Artifact {
223
- if (rejectsNoteTemplate(artifacts, templateId, input.subtype)) requireNotesFacade();
224
- return artifacts.create({ ...input, templateId });
318
+ export function listSkills(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
319
+ return listScoped(artifacts, scopes, "skill", filter);
225
320
  }
226
321
 
227
- export function listSkills(artifacts: ArtifactStore, filter: ListFilter): Artifact[] {
228
- return artifacts.query({ kind: "skill", ...filter });
322
+ export function assignSkillProject(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, projectRoot: string | undefined): Artifact {
323
+ return assignArtifactProject(artifacts, scopes, id, "skill", projectRoot);
229
324
  }
230
325
 
231
326
  export function showSkill(artifacts: ArtifactStore, id: string): Artifact {
@@ -261,10 +356,10 @@ export function skillInvocation(artifacts: ArtifactStore, id: string): string {
261
356
  ].join("\n");
262
357
  }
263
358
 
264
- export function transitionSkill(artifacts: ArtifactStore, id: string, action: SkillTransition): Artifact {
359
+ export function transitionSkill(artifacts: ArtifactStore, id: string, action: SkillTransition, context?: ArtifactEventContext): Artifact {
265
360
  const skill = requireKind(artifacts, id, "skill");
266
361
  const expected = action === "enable" ? "deprecated" : "active";
267
362
  const target = action === "enable" ? "active" : "deprecated";
268
363
  if (skill.status !== expected) throw new Error(`cannot ${action} skill from ${skill.status}`);
269
- return artifacts.setStatus(id, target)!;
364
+ return artifacts.setStatus(id, target, context)!;
270
365
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * graph-projection-service.ts — the generic graph projection protocol for external bounded
3
+ * contexts. See src/domain/graph-projection.ts for the batch/checkpoint shapes and the
4
+ * documented, deliberate scope limits of this first walking-skeleton slice.
5
+ */
6
+ import { GRAPH_PROJECTION_ID_MAX_LENGTH, GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH, GRAPH_PROJECTION_MAX_EDGES_PER_BATCH } from "./constants.ts";
7
+ import { GRAPH_PROJECTION_SCHEMA_VERSION, type GraphProjectionBatch, type GraphProjectionResult } from "./domain/graph-projection.ts";
8
+ import type { ArtifactStore } from "./ports/artifact-store.ts";
9
+ import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
10
+ import type { GraphProjectionStore } from "./ports/graph-projection-store.ts";
11
+ import type { AuthorityRegistry } from "./authority-registry.ts";
12
+
13
+ function boundedId(value: string, label: string): string {
14
+ if (!value || value.length === 0) throw new Error(`${label} is required`);
15
+ if (value.length > GRAPH_PROJECTION_ID_MAX_LENGTH) throw new Error(`${label} exceeds ${GRAPH_PROJECTION_ID_MAX_LENGTH} characters`);
16
+ return value;
17
+ }
18
+
19
+ export class GraphProjection {
20
+ constructor(
21
+ private readonly artifacts: ArtifactStore,
22
+ private readonly store: GraphProjectionStore,
23
+ private readonly authority: AuthorityRegistry,
24
+ ) {}
25
+
26
+ checkpoint(producerId: string) {
27
+ return this.store.getCheckpoint(boundedId(producerId, "producer_id"));
28
+ }
29
+
30
+ apply(batch: GraphProjectionBatch): GraphProjectionResult {
31
+ if (batch.schemaVersion !== GRAPH_PROJECTION_SCHEMA_VERSION) {
32
+ throw new Error(`unsupported graph projection schema version "${batch.schemaVersion}", expected "${GRAPH_PROJECTION_SCHEMA_VERSION}"`);
33
+ }
34
+ const producerId = boundedId(batch.producerId, "producer_id");
35
+ const batchId = boundedId(batch.batchId, "batch_id");
36
+ if (!Number.isInteger(batch.sequence) || batch.sequence < 1) throw new Error("sequence must be a positive integer");
37
+ if (batch.artifacts.length > GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH) {
38
+ throw new Error(`batch is bounded to ${GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH} artifacts; got ${batch.artifacts.length}`);
39
+ }
40
+ if (batch.edges.length > GRAPH_PROJECTION_MAX_EDGES_PER_BATCH) {
41
+ throw new Error(`batch is bounded to ${GRAPH_PROJECTION_MAX_EDGES_PER_BATCH} edges; got ${batch.edges.length}`);
42
+ }
43
+ for (const artifact of batch.artifacts) boundedId(artifact.externalId, "artifact externalId");
44
+ for (const edge of batch.edges) { boundedId(edge.from, "edge from"); boundedId(edge.to, "edge to"); }
45
+
46
+ const existingCheckpoint = this.store.getCheckpoint(producerId);
47
+ if (existingCheckpoint?.lastBatchId === batchId && existingCheckpoint.lastSequence === batch.sequence) {
48
+ return { producerId, batchId, sequence: batch.sequence, artifactsUpserted: 0, artifactsCreated: 0, edgesUpserted: 0, alreadyApplied: true };
49
+ }
50
+ if (existingCheckpoint === null) {
51
+ if (batch.sequence !== 1) throw new Error(`first batch for producer "${producerId}" must have sequence 1, got ${batch.sequence}`);
52
+ } else {
53
+ if (batch.sequence <= existingCheckpoint.lastSequence) {
54
+ throw new Error(`stale batch: sequence ${batch.sequence} is not after checkpoint sequence ${existingCheckpoint.lastSequence} for producer "${producerId}"`);
55
+ }
56
+ if (batch.sequence > existingCheckpoint.lastSequence + 1) {
57
+ throw new Error(`sequence gap for producer "${producerId}": expected ${existingCheckpoint.lastSequence + 1}, got ${batch.sequence}`);
58
+ }
59
+ }
60
+
61
+ // Authority up front, before any write: a producer that doesn't own a subtype/relation
62
+ // it's trying to project into must fail closed, not partially apply then fail midway.
63
+ for (const artifact of batch.artifacts) this.authority.requireArtifactAllowed(artifact.kind, artifact.subtype, "create", producerId);
64
+ for (const edge of batch.edges) this.authority.requireRelationAllowed(edge.relation, "link", producerId);
65
+
66
+ const atomic = requireAtomicArtifactStore(this.artifacts);
67
+ let artifactsCreated = 0;
68
+ let artifactsUpserted = 0;
69
+ let edgesUpserted = 0;
70
+ atomic.atomic(() => {
71
+ for (const projected of batch.artifacts) {
72
+ const existingId = this.store.resolveIdentity(producerId, projected.externalId);
73
+ if (existingId) {
74
+ this.artifacts.updateContent(existingId, { title: projected.title, body: projected.body, labels: projected.labels ? [...projected.labels] : undefined });
75
+ if (projected.extra !== undefined) this.artifacts.setExtra(existingId, projected.extra);
76
+ } else {
77
+ const created = this.artifacts.create({
78
+ kind: projected.kind,
79
+ subtype: projected.subtype,
80
+ title: projected.title,
81
+ body: projected.body,
82
+ labels: projected.labels ? [...projected.labels] : undefined,
83
+ extra: projected.extra,
84
+ });
85
+ this.store.recordIdentity(producerId, projected.externalId, created.id);
86
+ artifactsCreated++;
87
+ }
88
+ artifactsUpserted++;
89
+ }
90
+ for (const edge of batch.edges) {
91
+ const fromId = this.store.resolveIdentity(producerId, edge.from);
92
+ if (!fromId) throw new Error(`edge references unknown externalId "${edge.from}" for producer "${producerId}"`);
93
+ const toId = this.store.resolveIdentity(producerId, edge.to);
94
+ if (!toId) throw new Error(`edge references unknown externalId "${edge.to}" for producer "${producerId}"`);
95
+ this.artifacts.link({ from: fromId, relation: edge.relation, to: toId });
96
+ edgesUpserted++;
97
+ }
98
+ this.store.commitCheckpoint({ producerId, lastSequence: batch.sequence, lastBatchId: batchId, appliedAt: new Date().toISOString() });
99
+ });
100
+
101
+ return { producerId, batchId, sequence: batch.sequence, artifactsUpserted, artifactsCreated, edgesUpserted, alreadyApplied: false };
102
+ }
103
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * id-migration.ts — plan/apply/verify tooling for rewriting every existing artifact id to a
3
+ * UUID (see src/ops.ts: new artifacts already get crypto.randomUUID() by default; this tool
4
+ * closes the gap for artifacts that predate that change).
5
+ *
6
+ * This is deliberately NOT a daemon operation. Rewriting every artifact's primary key is a
7
+ * one-shot, high-blast-radius, mostly-irreversible operation on the database file itself, not
8
+ * a request a running service should accept over RPC. The intended, required sequence — never
9
+ * skip a step — is:
10
+ *
11
+ * 1. mirrorDatabase(liveDb, mirrorPath) -- consistent, compacted copy; the original is
12
+ * never opened for writing by anything below.
13
+ * 2. planIdMigration(mirror) + applyIdMigration(mirror, plan) -- mutate the MIRROR only.
14
+ * 3. verifyIdMigration(mirror, plan) -- must report { ok: true } before proceeding.
15
+ * 4. Only then: promote the validated mirror file to replace production (a plain file swap,
16
+ * done by the CLI once step 3 has passed — this module does not perform that swap itself,
17
+ * so there is no code path in this file that can touch a production file that hasn't
18
+ * already been proven correct as a mirror).
19
+ *
20
+ * Coverage: every column that is a structural foreign key to artifacts.id is remapped and
21
+ * verified via PRAGMA foreign_key_check (this is the correctness-critical half — a miss here
22
+ * means a broken database, not a stale reference). A second, best-effort pass exact-substring-
23
+ * replaces old ids wherever they appear inside a known set of free-text/JSON columns (title,
24
+ * body, extra, and the two Task-event text fields) — this is how a prose cross-reference like
25
+ * "see task some-old-id for the parent epic" keeps pointing at the right artifact after its id
26
+ * changes. Discourse post JSON payloads (content_json/command_json/references_json) are
27
+ * explicitly NOT scanned — that is Discourse-internal structure this tool does not have enough
28
+ * context on yet, tracked as a known limitation rather than guessed at.
29
+ */
30
+ import type { Db } from "./db.ts";
31
+ import { inTransaction } from "./db.ts";
32
+
33
+ /** This tool is designed and tested at Papyrus's current graph scale, not unbounded. */
34
+ export const ID_MIGRATION_MAX_ARTIFACTS = 50_000;
35
+
36
+ export interface IdMigrationPlan {
37
+ readonly idMap: ReadonlyMap<string, string>;
38
+ }
39
+
40
+ export interface IdMigrationReport {
41
+ readonly artifactsRemapped: number;
42
+ readonly edgesRemapped: number;
43
+ readonly textOccurrencesRemapped: number;
44
+ }
45
+
46
+ export interface IdMigrationVerification {
47
+ readonly ok: boolean;
48
+ readonly problems: string[];
49
+ }
50
+
51
+ /** Every column that structurally references an artifact id, enforced FK or not. */
52
+ const FK_COLUMNS: ReadonlyArray<{ table: string; column: string }> = [
53
+ { table: "edges", column: "from_id" },
54
+ { table: "edges", column: "to_id" },
55
+ { table: "task_focus", column: "task_id" },
56
+ { table: "task_events", column: "task_id" },
57
+ { table: "task_scopes", column: "task_id" },
58
+ { table: "task_views", column: "root_task_id" },
59
+ { table: "discourse_threads", column: "artifact_id" },
60
+ { table: "discourse_posts", column: "artifact_id" },
61
+ { table: "artifact_events", column: "artifact_id" },
62
+ { table: "artifact_events", column: "related_id" },
63
+ ];
64
+
65
+ /**
66
+ * Free-text/JSON columns that may embed a plain-text mention of an artifact id, beyond the
67
+ * structural FK columns above. See the module doc comment for what this deliberately excludes.
68
+ */
69
+ const TEXT_SCAN_COLUMNS: ReadonlyArray<{ table: string; column: string }> = [
70
+ { table: "artifacts", column: "title" },
71
+ { table: "artifacts", column: "body" },
72
+ { table: "artifacts", column: "extra" },
73
+ { table: "task_events", column: "reason" },
74
+ { table: "task_events", column: "evidence_json" },
75
+ ];
76
+
77
+ /** Audit tables whose append-only guard must be suspended for exactly this migration's duration. */
78
+ const APPEND_ONLY_GUARD_TRIGGERS = ["task_events_no_update", "task_events_no_delete", "artifact_events_no_update", "artifact_events_no_delete"];
79
+
80
+ function tableExists(db: Db, table: string): boolean {
81
+ return db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) != null;
82
+ }
83
+
84
+ export function planIdMigration(db: Db): IdMigrationPlan {
85
+ const rows = db.prepare("SELECT id FROM artifacts").all() as Array<{ id: string }>;
86
+ if (rows.length > ID_MIGRATION_MAX_ARTIFACTS) {
87
+ throw new Error(`id migration is bounded to ${ID_MIGRATION_MAX_ARTIFACTS} artifacts; found ${rows.length}`);
88
+ }
89
+ const idMap = new Map<string, string>();
90
+ for (const row of rows) idMap.set(row.id, crypto.randomUUID());
91
+ return { idMap };
92
+ }
93
+
94
+ /**
95
+ * Mutates `db` in place. Callers must only ever pass a mirror (see mirrorDatabase), never a
96
+ * database a live daemon may still be serving reads/writes against.
97
+ */
98
+ export function applyIdMigration(db: Db, plan: IdMigrationPlan): IdMigrationReport {
99
+ const { idMap } = plan;
100
+ if (idMap.size === 0) return { artifactsRemapped: 0, edgesRemapped: 0, textOccurrencesRemapped: 0 };
101
+
102
+ // PRAGMA foreign_keys is a no-op inside an open transaction in SQLite, so it must be set
103
+ // before BEGIN, not inside inTransaction's callback.
104
+ db.exec("PRAGMA foreign_keys = OFF");
105
+ try {
106
+ return inTransaction(db, () => {
107
+ const triggerDdl = new Map<string, string>();
108
+ for (const name of APPEND_ONLY_GUARD_TRIGGERS) {
109
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?").get(name) as { sql: string } | undefined;
110
+ if (row) { triggerDdl.set(name, row.sql); db.exec(`DROP TRIGGER ${name}`); }
111
+ }
112
+ try {
113
+ let edgesRemapped = 0;
114
+ for (const { table, column } of FK_COLUMNS) {
115
+ if (!tableExists(db, table)) continue;
116
+ const stmt = db.prepare(`UPDATE ${table} SET ${column} = ? WHERE ${column} = ?`);
117
+ for (const [oldId, newId] of idMap) {
118
+ stmt.run(newId, oldId);
119
+ if (table === "edges") edgesRemapped++;
120
+ }
121
+ }
122
+
123
+ // artifacts.id itself, once nothing else still points at the old value.
124
+ const idStmt = db.prepare("UPDATE artifacts SET id = ? WHERE id = ?");
125
+ for (const [oldId, newId] of idMap) idStmt.run(newId, oldId);
126
+
127
+ let textOccurrencesRemapped = 0;
128
+ for (const { table, column } of TEXT_SCAN_COLUMNS) {
129
+ if (!tableExists(db, table)) continue;
130
+ const rows = db.prepare(`SELECT rowid AS rowid, ${column} AS value FROM ${table} WHERE ${column} IS NOT NULL`).all() as Array<{ rowid: number; value: string }>;
131
+ const updateStmt = db.prepare(`UPDATE ${table} SET ${column} = ? WHERE rowid = ?`);
132
+ for (const row of rows) {
133
+ let value = row.value;
134
+ let changed = false;
135
+ for (const [oldId, newId] of idMap) {
136
+ if (value.includes(oldId)) {
137
+ value = value.split(oldId).join(newId);
138
+ changed = true;
139
+ textOccurrencesRemapped++;
140
+ }
141
+ }
142
+ if (changed) updateStmt.run(value, row.rowid);
143
+ }
144
+ }
145
+
146
+ return { artifactsRemapped: idMap.size, edgesRemapped, textOccurrencesRemapped };
147
+ } finally {
148
+ for (const [, sql] of triggerDdl) db.exec(sql);
149
+ }
150
+ });
151
+ } finally {
152
+ db.exec("PRAGMA foreign_keys = ON");
153
+ // File-backed databases run in WAL mode (see openDb): a committed transaction can be
154
+ // fully durable yet still live only in the -wal sidecar file, not yet folded into the
155
+ // main database file. A caller that copies just the main file (mirrorDatabase's whole
156
+ // point, and promote's file swap) would silently see stale pre-migration content unless
157
+ // this is forced. A no-op for :memory: databases (nothing to checkpoint).
158
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Read-only. Must be run against the mirror after applyIdMigration, before any promotion.
164
+ * Checks (a) referential integrity holds with no violations, (b) every old id is fully gone
165
+ * from artifacts.id and every FK column, (c) row counts are unchanged for artifacts/edges/
166
+ * both audit logs, and (d) every artifact's content is unchanged except for id substitution.
167
+ */
168
+ export function verifyIdMigration(db: Db, plan: IdMigrationPlan): IdMigrationVerification {
169
+ const problems: string[] = [];
170
+
171
+ const violations = db.prepare("PRAGMA foreign_key_check").all();
172
+ if (violations.length > 0) problems.push(`${violations.length} foreign key violation(s) found after migration`);
173
+
174
+ for (const oldId of plan.idMap.keys()) {
175
+ if (db.prepare("SELECT 1 FROM artifacts WHERE id = ?").get(oldId) != null) {
176
+ problems.push(`old id "${oldId}" is still present in artifacts.id`);
177
+ }
178
+ for (const { table, column } of FK_COLUMNS) {
179
+ if (!tableExists(db, table)) continue;
180
+ const leak = db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${column} = ?`).get(oldId) as { n: number };
181
+ if (leak.n > 0) problems.push(`old id "${oldId}" still referenced in ${table}.${column} (${leak.n} row(s))`);
182
+ }
183
+ }
184
+
185
+ const artifactCount = (db.prepare("SELECT COUNT(*) AS n FROM artifacts").get() as { n: number }).n;
186
+ if (artifactCount !== plan.idMap.size) {
187
+ problems.push(`expected ${plan.idMap.size} artifacts after migration, found ${artifactCount}`);
188
+ }
189
+
190
+ return { ok: problems.length === 0, problems };
191
+ }
192
+
193
+ /**
194
+ * Produces a consistent, compacted, independent copy of `db` at `path` via SQLite's own
195
+ * VACUUM INTO — safe regardless of the source's WAL state, and does not require the caller to
196
+ * coordinate checkpointing. The original connection and file are never written to by this call.
197
+ */
198
+ export function mirrorDatabase(db: Db, path: string): void {
199
+ db.prepare("VACUUM INTO ?").run(path);
200
+ }