@danypops/papyrus 0.11.4 → 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 +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/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/domain-services.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
223
|
-
|
|
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
|
|
228
|
-
return artifacts
|
|
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
|
+
}
|