@danypops/papyrus 0.47.1 → 0.48.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 +81 -54
- package/package.json +1 -2
- package/src/artifact/artifact-scope-store.ts +37 -7
- package/src/artifact/in-memory-artifact-scope-store.ts +123 -0
- package/src/artifact/sqlite-artifact-scope-store.ts +148 -27
- package/src/cli/rules-command.ts +92 -0
- package/src/cli.ts +21 -2
- package/src/constants.ts +3 -1
- package/src/db.ts +55 -0
- package/src/domain/project-registry.ts +58 -0
- package/src/domain/task-scope.ts +4 -14
- package/src/handlers/registry.ts +3 -1
- package/src/handlers/rules.ts +91 -4
- package/src/modules/rules.ts +29 -1
- package/src/ops.ts +1 -0
- package/src/ports/project-registry-store.ts +13 -0
- package/src/rules/rules-service.ts +105 -16
- package/src/service.ts +18 -4
- package/src/stores/in-memory-project-registry-store.ts +100 -0
- package/src/stores/sqlite-project-registry-store.ts +132 -0
- package/src/stores/sqlite-task-scope-store.ts +12 -120
- package/src/stores/task-scope-store.ts +30 -71
package/src/cli/rules-command.ts
CHANGED
|
@@ -113,6 +113,93 @@ const assignProjectCommand = buildCommand({
|
|
|
113
113
|
docs: { brief: "Reassign a Rule's project scope, or unscope it" },
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
interface CliArtifactScope {
|
|
117
|
+
artifactId: string;
|
|
118
|
+
mode: string;
|
|
119
|
+
projectIds: string[];
|
|
120
|
+
source: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderScope(scope: CliArtifactScope): string {
|
|
124
|
+
return scope.mode === "global" ? "global (applies to every project)" : `projects: ${scope.projectIds.join(", ")}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const scopeCommand = buildCommand({
|
|
128
|
+
func: async function (this: RulesContext, _flags: Record<string, never>, id: string) {
|
|
129
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("rules.scope", { id });
|
|
130
|
+
render.call(this, scope, renderScope(scope));
|
|
131
|
+
},
|
|
132
|
+
parameters: { flags: {}, positional: { kind: "tuple", parameters: [{ brief: "Rule id", parse: String, placeholder: "id" }] } },
|
|
133
|
+
docs: { brief: "Show a Rule's real project scope" },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const setGlobalCommand = buildCommand({
|
|
137
|
+
func: async function (this: RulesContext, _flags: Record<string, never>, id: string) {
|
|
138
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("rules.set_global", { id });
|
|
139
|
+
render.call(this, scope, renderScope(scope));
|
|
140
|
+
},
|
|
141
|
+
parameters: { flags: {}, positional: { kind: "tuple", parameters: [{ brief: "Rule id", parse: String, placeholder: "id" }] } },
|
|
142
|
+
docs: { brief: "Make a Rule apply in every project" },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const addProjectCommand = buildCommand({
|
|
146
|
+
func: async function (this: RulesContext, _flags: Record<string, never>, id: string, project: string) {
|
|
147
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("rules.add_project", { id, project });
|
|
148
|
+
render.call(this, scope, renderScope(scope));
|
|
149
|
+
},
|
|
150
|
+
parameters: {
|
|
151
|
+
flags: {},
|
|
152
|
+
positional: {
|
|
153
|
+
kind: "tuple",
|
|
154
|
+
parameters: [
|
|
155
|
+
{ brief: "Rule id", parse: String, placeholder: "id" },
|
|
156
|
+
{ brief: "Project id/name/alias/root to add", parse: String, placeholder: "project" },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
docs: { brief: "Add one project to a Rule's membership" },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const removeProjectCommand = buildCommand({
|
|
164
|
+
func: async function (this: RulesContext, _flags: Record<string, never>, id: string, project: string) {
|
|
165
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("rules.remove_project", { id, project });
|
|
166
|
+
render.call(this, scope, renderScope(scope));
|
|
167
|
+
},
|
|
168
|
+
parameters: {
|
|
169
|
+
flags: {},
|
|
170
|
+
positional: {
|
|
171
|
+
kind: "tuple",
|
|
172
|
+
parameters: [
|
|
173
|
+
{ brief: "Rule id", parse: String, placeholder: "id" },
|
|
174
|
+
{ brief: "Project id/name/alias/root to remove", parse: String, placeholder: "project" },
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
docs: { brief: "Remove one project from a Rule's membership" },
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const replaceProjectsCommand = buildCommand({
|
|
182
|
+
func: async function (this: RulesContext, flags: { projectsJson: string[] }, id: string) {
|
|
183
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("rules.replace_projects", {
|
|
184
|
+
id,
|
|
185
|
+
projects: flags.projectsJson,
|
|
186
|
+
});
|
|
187
|
+
render.call(this, scope, renderScope(scope));
|
|
188
|
+
},
|
|
189
|
+
parameters: {
|
|
190
|
+
flags: {
|
|
191
|
+
projectsJson: {
|
|
192
|
+
brief: "JSON string array of project id/name/alias/root references",
|
|
193
|
+
kind: "parsed",
|
|
194
|
+
parse: parseStringArray,
|
|
195
|
+
placeholder: "json",
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
positional: { kind: "tuple", parameters: [{ brief: "Rule id", parse: String, placeholder: "id" }] },
|
|
199
|
+
},
|
|
200
|
+
docs: { brief: "Replace a Rule's entire project membership" },
|
|
201
|
+
});
|
|
202
|
+
|
|
116
203
|
const showCommand = buildCommand({
|
|
117
204
|
func: async function (this: RulesContext, _flags: Record<string, never>, id: string) {
|
|
118
205
|
const artifact = await this.client.call<Record<string, unknown>, CliArtifact>("rules.show", { id });
|
|
@@ -204,6 +291,11 @@ const app = buildApplication(
|
|
|
204
291
|
create: createCommand,
|
|
205
292
|
list: listCommand,
|
|
206
293
|
"assign-project": assignProjectCommand,
|
|
294
|
+
scope: scopeCommand,
|
|
295
|
+
"set-global": setGlobalCommand,
|
|
296
|
+
"add-project": addProjectCommand,
|
|
297
|
+
"remove-project": removeProjectCommand,
|
|
298
|
+
"replace-projects": replaceProjectsCommand,
|
|
207
299
|
show: showCommand,
|
|
208
300
|
preview: previewCommand,
|
|
209
301
|
enable: buildEnableDisableCommand("enable"),
|
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { copyFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createNodeServiceInstallDeps, generateSystemdUnit, installUserService, type ServiceSpec } from "@danypops/vehicle-server/service";
|
|
6
6
|
import { runArtifactCli } from "./cli/artifact-command.ts";
|
|
@@ -294,7 +294,15 @@ export function runIdMigrationCli(args: string[]): string {
|
|
|
294
294
|
try {
|
|
295
295
|
result = verifyIdMigration(mirror, plan);
|
|
296
296
|
} finally {
|
|
297
|
+
// openDb() always opens a file-backed database in WAL mode, including this mirror
|
|
298
|
+
// (produced by VACUUM INTO with no WAL of its own until this very open). Fold it back
|
|
299
|
+
// into the main file and drop the sidecars before copying just the main file below --
|
|
300
|
+
// the same reasoning already applied to target's own sidecars a few lines down. Copying
|
|
301
|
+
// the main file while leaving a newer -wal/-shm pair for a now-deleted identity behind
|
|
302
|
+
// produces a file SQLite reopens as a malformed image, not merely a stale one.
|
|
303
|
+
mirror.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
297
304
|
mirror.close();
|
|
305
|
+
for (const sidecar of [`${mirrorPath}-wal`, `${mirrorPath}-shm`]) if (existsSync(sidecar)) unlinkSync(sidecar);
|
|
298
306
|
}
|
|
299
307
|
if (!result.ok) {
|
|
300
308
|
throw new Error(
|
|
@@ -316,7 +324,18 @@ export function runIdMigrationCli(args: string[]): string {
|
|
|
316
324
|
copyFileSync(target, backupPath);
|
|
317
325
|
for (const sidecar of [`${target}-wal`, `${target}-shm`]) if (existsSync(sidecar)) unlinkSync(sidecar);
|
|
318
326
|
}
|
|
319
|
-
copyFileSync(mirrorPath, target)
|
|
327
|
+
// A plain copyFileSync(mirrorPath, target) overwrites target's own file content in place --
|
|
328
|
+
// confirmed live to reopen as "database disk image is malformed" even though both the
|
|
329
|
+
// checkpointed target and the checkpointed mirror are independently completely healthy
|
|
330
|
+
// right before this copy: something about SQLite's own handling of a path/inode this
|
|
331
|
+
// process already opened earlier in the same run (target was just opened above to
|
|
332
|
+
// checkpoint it) survives closing that connection. Copying to a fresh staging path (a new
|
|
333
|
+
// inode, never opened by this process) and swapping it into place with an atomic rename
|
|
334
|
+
// sidesteps that entirely -- renameSync never fails partway the way a corrupted in-place
|
|
335
|
+
// overwrite can.
|
|
336
|
+
const staging = `${target}.promoting`;
|
|
337
|
+
copyFileSync(mirrorPath, staging);
|
|
338
|
+
renameSync(staging, target);
|
|
320
339
|
const result2 = { target, backupPath };
|
|
321
340
|
if (json) return JSON.stringify(result2);
|
|
322
341
|
return [
|
package/src/constants.ts
CHANGED
|
@@ -9,7 +9,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
|
9
9
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
10
10
|
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
11
11
|
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
12
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
12
|
+
export const SQLITE_SCHEMA_VERSION = 28;
|
|
13
13
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
14
14
|
|
|
15
15
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
@@ -261,6 +261,8 @@ export const ARTIFACT_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
261
261
|
export const TASK_SCOPE_MAX_TASKS = 1_000;
|
|
262
262
|
/** Docs/Rules/Skills project scope listing bound, mirroring TASK_SCOPE_MAX_TASKS. */
|
|
263
263
|
export const ARTIFACT_SCOPE_MAX_ARTIFACTS = 1_000;
|
|
264
|
+
/** How many distinct registered projects a single Doc/Rule/Playbook may belong to at once, in "projects" scope mode. */
|
|
265
|
+
export const ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT = 50;
|
|
264
266
|
export const TASK_PROJECT_ROOT_MAX_LENGTH = 4_096;
|
|
265
267
|
export const TASK_PROJECT_NAME_MAX_LENGTH = 200;
|
|
266
268
|
export const TASK_PROJECT_ALIAS_MAX_COUNT = 20;
|
package/src/db.ts
CHANGED
|
@@ -233,10 +233,17 @@ CREATE INDEX IF NOT EXISTS graph_projection_identities_artifact_idx ON graph_pro
|
|
|
233
233
|
CREATE TABLE IF NOT EXISTS artifact_scopes (
|
|
234
234
|
artifact_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
235
235
|
project_root TEXT,
|
|
236
|
+
mode TEXT NOT NULL DEFAULT 'global' CHECK (mode IN ('global', 'projects')),
|
|
236
237
|
source TEXT NOT NULL CHECK (source IN ('cwd', 'explicit', 'unscoped')),
|
|
237
238
|
assigned_at TEXT NOT NULL
|
|
238
239
|
);
|
|
239
240
|
CREATE INDEX IF NOT EXISTS artifact_scopes_project_idx ON artifact_scopes(project_root, artifact_id);
|
|
241
|
+
CREATE TABLE IF NOT EXISTS artifact_scope_projects (
|
|
242
|
+
artifact_id TEXT NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE,
|
|
243
|
+
project_id TEXT NOT NULL REFERENCES task_projects(id),
|
|
244
|
+
PRIMARY KEY (artifact_id, project_id)
|
|
245
|
+
);
|
|
246
|
+
CREATE INDEX IF NOT EXISTS artifact_scope_projects_project_idx ON artifact_scope_projects(project_id, artifact_id);
|
|
240
247
|
CREATE TABLE IF NOT EXISTS log_sources (
|
|
241
248
|
id TEXT PRIMARY KEY,
|
|
242
249
|
label TEXT NOT NULL,
|
|
@@ -843,6 +850,54 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
843
850
|
`);
|
|
844
851
|
},
|
|
845
852
|
},
|
|
853
|
+
{
|
|
854
|
+
version: 28,
|
|
855
|
+
name: "artifact-multi-project-scope",
|
|
856
|
+
// See artifact/artifact-scope-store.ts. Replaces the single-project_root shape with an
|
|
857
|
+
// explicit global/projects mode plus a bounded, non-empty many-to-many membership table,
|
|
858
|
+
// keyed by the same registered project ids Tasks already use (task_projects, see version 26)
|
|
859
|
+
// rather than a raw root string -- so a project rename/move never needs a best-effort
|
|
860
|
+
// string rewrite across every artifact scoped to it. Preserves every row: NULL project_root
|
|
861
|
+
// becomes explicit global mode (the column's own new DEFAULT already covers a fresh
|
|
862
|
+
// bootstrap; this branch back-fills it for an upgrading database); a non-NULL project_root
|
|
863
|
+
// becomes projects mode with exactly one membership, registering that root in task_projects
|
|
864
|
+
// first if no Task ever used it either.
|
|
865
|
+
up: (db) => {
|
|
866
|
+
const existing = new Set((db.prepare("PRAGMA table_info(artifact_scopes)").all() as Array<{ name: string }>).map((row) => row.name));
|
|
867
|
+
if (!existing.has("mode")) {
|
|
868
|
+
db.exec("ALTER TABLE artifact_scopes ADD COLUMN mode TEXT NOT NULL DEFAULT 'global' CHECK (mode IN ('global', 'projects'))");
|
|
869
|
+
}
|
|
870
|
+
db.exec(`
|
|
871
|
+
CREATE TABLE IF NOT EXISTS artifact_scope_projects (
|
|
872
|
+
artifact_id TEXT NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE,
|
|
873
|
+
project_id TEXT NOT NULL REFERENCES task_projects(id),
|
|
874
|
+
PRIMARY KEY (artifact_id, project_id)
|
|
875
|
+
);
|
|
876
|
+
CREATE INDEX IF NOT EXISTS artifact_scope_projects_project_idx ON artifact_scope_projects(project_id, artifact_id);
|
|
877
|
+
`);
|
|
878
|
+
const scoped = db.prepare("SELECT artifact_id, project_root FROM artifact_scopes WHERE project_root IS NOT NULL").all() as Array<{
|
|
879
|
+
artifact_id: string;
|
|
880
|
+
project_root: string;
|
|
881
|
+
}>;
|
|
882
|
+
const findProject = db.prepare("SELECT id FROM task_projects WHERE project_root = ?");
|
|
883
|
+
const insertProject = db.prepare(
|
|
884
|
+
"INSERT INTO task_projects (id, name, aliases_json, project_root, created_at, updated_at) VALUES (?, ?, '[]', ?, ?, ?)",
|
|
885
|
+
);
|
|
886
|
+
const setProjectsMode = db.prepare("UPDATE artifact_scopes SET mode = 'projects' WHERE artifact_id = ?");
|
|
887
|
+
const insertMembership = db.prepare("INSERT OR IGNORE INTO artifact_scope_projects (artifact_id, project_id) VALUES (?, ?)");
|
|
888
|
+
for (const row of scoped) {
|
|
889
|
+
const found = findProject.get(row.project_root) as { id: string } | null;
|
|
890
|
+
let projectId = found?.id;
|
|
891
|
+
if (!projectId) {
|
|
892
|
+
projectId = randomUUID();
|
|
893
|
+
const now = new Date().toISOString();
|
|
894
|
+
insertProject.run(projectId, basename(row.project_root) || row.project_root, row.project_root, now, now);
|
|
895
|
+
}
|
|
896
|
+
setProjectsMode.run(row.artifact_id);
|
|
897
|
+
insertMembership.run(row.artifact_id, projectId);
|
|
898
|
+
}
|
|
899
|
+
},
|
|
900
|
+
},
|
|
846
901
|
];
|
|
847
902
|
|
|
848
903
|
/**
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A registered project identity, shared across every artifact kind (Tasks, Docs, Rules,
|
|
3
|
+
* Playbooks) rather than owned by Tasks alone -- extracted so a Doc/Rule/Playbook can resolve
|
|
4
|
+
* and register against the exact same id/name/alias/root space a Task already does, instead of
|
|
5
|
+
* each domain inventing its own project catalog.
|
|
6
|
+
*/
|
|
7
|
+
export interface Project {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
aliases: string[];
|
|
11
|
+
projectRoot: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RegisterProjectInput {
|
|
17
|
+
projectRoot: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
aliases?: string[];
|
|
20
|
+
existingId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ProjectNotFoundError extends Error {}
|
|
24
|
+
export class ProjectAmbiguousError extends Error {}
|
|
25
|
+
|
|
26
|
+
export interface ProjectReferenceLookup {
|
|
27
|
+
matchingProjects(reference: string): Project[];
|
|
28
|
+
projects(query: string | undefined, limit: number): Project[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Same bounded, fail-closed exact-reference resolution Tasks' own resolveProject already uses
|
|
33
|
+
* (case-insensitive exact id/name/alias/root, zero matches is an error with up to 10 bounded
|
|
34
|
+
* candidates, more than one match is an error listing every match up to 10) -- extracted here so
|
|
35
|
+
* a non-Task domain (Rules, and later Docs/Playbooks) gets the identical contract instead of a
|
|
36
|
+
* hand-rolled approximation. Tasks' own TaskProjectNotFoundError/TaskProjectAmbiguousError are
|
|
37
|
+
* deliberately left as they are (a working, tested path with its own established call sites);
|
|
38
|
+
* this is for every domain that never had project-reference resolution before.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveProjectReference(registry: ProjectReferenceLookup, reference: string): Project {
|
|
41
|
+
const matches = registry.matchingProjects(reference);
|
|
42
|
+
if (matches.length === 0) {
|
|
43
|
+
const candidates = registry.projects(reference, 10);
|
|
44
|
+
const fallback = candidates.length === 0 ? registry.projects(undefined, 10) : candidates;
|
|
45
|
+
const suffix =
|
|
46
|
+
fallback.length === 0 ? "" : ` Candidates: ${fallback.map((project) => `${project.name} (${project.projectRoot})`).join(", ")}`;
|
|
47
|
+
throw new ProjectNotFoundError(`no project named or aliased "${reference}" is registered.${suffix}`);
|
|
48
|
+
}
|
|
49
|
+
if (matches.length > 1) {
|
|
50
|
+
throw new ProjectAmbiguousError(
|
|
51
|
+
`project reference "${reference}" is ambiguous: ${matches
|
|
52
|
+
.slice(0, 10)
|
|
53
|
+
.map((project) => `${project.name} (${project.projectRoot})`)
|
|
54
|
+
.join(", ")}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return matches[0]!;
|
|
58
|
+
}
|
package/src/domain/task-scope.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { basename, isAbsolute, normalize } from "node:path";
|
|
2
2
|
import { TASK_PROJECT_ROOT_MAX_LENGTH } from "../constants.ts";
|
|
3
|
+
import type { Project, RegisterProjectInput } from "./project-registry.ts";
|
|
3
4
|
|
|
4
5
|
export type TaskViewMode = "project" | "graph" | "all";
|
|
5
6
|
export type TaskScopeSource = "cwd" | "explicit" | "unscoped";
|
|
@@ -10,21 +11,10 @@ export interface TaskProjectScope {
|
|
|
10
11
|
source: TaskScopeSource;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
name: string;
|
|
16
|
-
aliases: string[];
|
|
17
|
-
projectRoot: string;
|
|
18
|
-
createdAt: string;
|
|
19
|
-
updatedAt: string;
|
|
20
|
-
}
|
|
14
|
+
/** Task's own name for the shared, kind-neutral Project identity -- see project-registry.ts. Kept as a type alias so every existing Task-scope call site keeps working unchanged. */
|
|
15
|
+
export type TaskProject = Project;
|
|
21
16
|
|
|
22
|
-
export
|
|
23
|
-
projectRoot: string;
|
|
24
|
-
name?: string;
|
|
25
|
-
aliases?: string[];
|
|
26
|
-
existingId?: string;
|
|
27
|
-
}
|
|
17
|
+
export type RegisterTaskProjectInput = RegisterProjectInput;
|
|
28
18
|
|
|
29
19
|
export interface TaskViewPreference {
|
|
30
20
|
projectRoot: string;
|
package/src/handlers/registry.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { ArtifactTrashStore } from "../artifact/artifact-trash-store.ts";
|
|
|
12
12
|
import type { AuthorityRegistry } from "../authority-registry.ts";
|
|
13
13
|
import type { Discussions } from "../discussion/discussion-service.ts";
|
|
14
14
|
import type { Notes } from "../note/note-service.ts";
|
|
15
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
15
16
|
import type { SessionIdentity } from "../session-identity/session-identity-service.ts";
|
|
16
17
|
import type { TaskEventStore } from "../stores/task-event-store.ts";
|
|
17
18
|
import type { TaskScopeStore } from "../stores/task-scope-store.ts";
|
|
@@ -34,6 +35,7 @@ export interface PapyrusVehicleDeps {
|
|
|
34
35
|
tasks: Tasks;
|
|
35
36
|
discussions: Discussions;
|
|
36
37
|
sessionIdentity: SessionIdentity;
|
|
38
|
+
projectRegistry: ProjectRegistryStore;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleRegistry {
|
|
@@ -48,7 +50,7 @@ export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleR
|
|
|
48
50
|
// session_id, a correlation id, not a secret -- see session-identity-service.ts).
|
|
49
51
|
registry.setExposeHandlerFailureDetails(true);
|
|
50
52
|
registerNotesVehicleOperations(registry, deps.notes, deps.artifacts);
|
|
51
|
-
registerRulesVehicleOperations(registry, deps.artifacts, deps.scopes);
|
|
53
|
+
registerRulesVehicleOperations(registry, deps.artifacts, deps.scopes, deps.projectRegistry);
|
|
52
54
|
registerDocsVehicleOperations(registry, deps.artifacts, deps.scopes, deps.authority);
|
|
53
55
|
registerPlaybooksVehicleOperations(registry, {
|
|
54
56
|
artifacts: deps.artifacts,
|
package/src/handlers/rules.ts
CHANGED
|
@@ -8,12 +8,20 @@ import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
|
8
8
|
import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
|
|
9
9
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
10
10
|
import { rulesOperations } from "../modules/rules.ts";
|
|
11
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
11
12
|
import { listRules } from "../rules/rules-service.ts";
|
|
12
13
|
import { booleanProp, createOperationDefiner, numberProp, resolveArtifactIdWidened, stringProp, validationError } from "./shared.ts";
|
|
13
14
|
|
|
14
15
|
const OWNER = "rules";
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Resolves a rule's id from either an explicit id or its title. When project_root is given and
|
|
19
|
+
* the project-scoped search finds nothing, widens only to a rule that actually APPLIES to this
|
|
20
|
+
* project (global, or explicitly scoped to it via appliesToProjectRoot) -- never to a same-named
|
|
21
|
+
* rule that belongs to a different project. A prior version widened to every rule of that name
|
|
22
|
+
* across every project unconditionally once the scoped search came up empty, silently leaking a
|
|
23
|
+
* name-based mutation across project boundaries.
|
|
24
|
+
*/
|
|
17
25
|
function resolveRuleId(
|
|
18
26
|
artifacts: ArtifactStore,
|
|
19
27
|
scopes: ArtifactScopeStore,
|
|
@@ -27,7 +35,9 @@ function resolveRuleId(
|
|
|
27
35
|
artifacts,
|
|
28
36
|
name,
|
|
29
37
|
() => listRules(artifacts, scopes, { text: name, projectRoot }),
|
|
30
|
-
projectRoot === undefined
|
|
38
|
+
projectRoot === undefined
|
|
39
|
+
? undefined
|
|
40
|
+
: () => artifacts.query({ kind: "rule", text: name }).filter((rule) => scopes.appliesToProjectRoot(rule.id, projectRoot)),
|
|
31
41
|
);
|
|
32
42
|
}
|
|
33
43
|
|
|
@@ -42,8 +52,13 @@ function resolveTaskId(artifacts: ArtifactStore, _projectRoot: string | undefine
|
|
|
42
52
|
return resolveArtifactIdWidened(artifacts, name, () => artifacts.query({ kind: "task", text: name }));
|
|
43
53
|
}
|
|
44
54
|
|
|
45
|
-
export function registerRulesVehicleOperations(
|
|
46
|
-
|
|
55
|
+
export function registerRulesVehicleOperations(
|
|
56
|
+
registry: VehicleRegistry,
|
|
57
|
+
artifacts: ArtifactStore,
|
|
58
|
+
scopes: ArtifactScopeStore,
|
|
59
|
+
projectRegistry: ProjectRegistryStore,
|
|
60
|
+
): void {
|
|
61
|
+
const moduleOperations = new Map(rulesOperations(artifacts, scopes, projectRegistry).map((op) => [op.name, op]));
|
|
47
62
|
const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
|
|
48
63
|
const define = createOperationDefiner(registry, OWNER, "rules", ["rules:read", "rules:write"], call);
|
|
49
64
|
|
|
@@ -62,6 +77,7 @@ export function registerRulesVehicleOperations(registry: VehicleRegistry, artifa
|
|
|
62
77
|
extra: { type: "object" } as unknown as { type: string },
|
|
63
78
|
template_id: stringProp,
|
|
64
79
|
project_root: stringProp,
|
|
80
|
+
projects: { type: "array" } as unknown as { type: string },
|
|
65
81
|
actor: stringProp,
|
|
66
82
|
source: stringProp,
|
|
67
83
|
session_id: stringProp,
|
|
@@ -153,6 +169,77 @@ export function registerRulesVehicleOperations(registry: VehicleRegistry, artifa
|
|
|
153
169
|
(input) => ({ ...input, id: resolveRuleId(artifacts, scopes, undefined, input.id, input.name) }),
|
|
154
170
|
);
|
|
155
171
|
|
|
172
|
+
define(
|
|
173
|
+
"scope",
|
|
174
|
+
"Shows a Rule's real project scope: global (applies everywhere) or the bounded set of registered projects it applies to.",
|
|
175
|
+
"read",
|
|
176
|
+
{ id: stringProp, name: stringProp, project_root: stringProp },
|
|
177
|
+
[],
|
|
178
|
+
(input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
define(
|
|
182
|
+
"set_global",
|
|
183
|
+
"Makes a Rule apply in every project, clearing any project membership. The only way to widen an active project-bound Rule back to global -- removing its last membership through remove_project is rejected instead.",
|
|
184
|
+
"local-write",
|
|
185
|
+
{ id: stringProp, name: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
186
|
+
[],
|
|
187
|
+
(input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
define(
|
|
191
|
+
"add_project",
|
|
192
|
+
"Adds one registered project (exact id, name, alias, or root) to a Rule's membership, switching it from global to project-bound if it was global. Idempotent if the project is already a member.",
|
|
193
|
+
"local-write",
|
|
194
|
+
{
|
|
195
|
+
id: stringProp,
|
|
196
|
+
name: stringProp,
|
|
197
|
+
project: { ...stringProp, description: "Exact project id, name, alias, or registered root to add." },
|
|
198
|
+
project_root: stringProp,
|
|
199
|
+
actor: stringProp,
|
|
200
|
+
source: stringProp,
|
|
201
|
+
session_id: stringProp,
|
|
202
|
+
},
|
|
203
|
+
["project"],
|
|
204
|
+
(input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
define(
|
|
208
|
+
"remove_project",
|
|
209
|
+
"Removes one registered project from a Rule's membership. Rejected while it is the Rule's only remaining membership -- call set_global first if the Rule should stop being project-bound entirely.",
|
|
210
|
+
"local-write",
|
|
211
|
+
{
|
|
212
|
+
id: stringProp,
|
|
213
|
+
name: stringProp,
|
|
214
|
+
project: { ...stringProp, description: "Exact project id, name, alias, or registered root to remove." },
|
|
215
|
+
project_root: stringProp,
|
|
216
|
+
actor: stringProp,
|
|
217
|
+
source: stringProp,
|
|
218
|
+
session_id: stringProp,
|
|
219
|
+
},
|
|
220
|
+
["project"],
|
|
221
|
+
(input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
define(
|
|
225
|
+
"replace_projects",
|
|
226
|
+
"Replaces a Rule's entire project membership with exactly this bounded, non-empty list of registered project references (id/name/alias/root). Use set_global instead to clear scoping entirely.",
|
|
227
|
+
"local-write",
|
|
228
|
+
{
|
|
229
|
+
id: stringProp,
|
|
230
|
+
name: stringProp,
|
|
231
|
+
projects: { type: "array", description: "Non-empty list of exact project id/name/alias/root references." } as unknown as {
|
|
232
|
+
type: string;
|
|
233
|
+
},
|
|
234
|
+
project_root: stringProp,
|
|
235
|
+
actor: stringProp,
|
|
236
|
+
source: stringProp,
|
|
237
|
+
session_id: stringProp,
|
|
238
|
+
},
|
|
239
|
+
["projects"],
|
|
240
|
+
(input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
241
|
+
);
|
|
242
|
+
|
|
156
243
|
define(
|
|
157
244
|
"update",
|
|
158
245
|
"Changes a Rule's title/body/labels (at least one required). Body updates still enforce the same combined condition+action+body context-tax bound as creation. The response includes combinedLength and a non-blocking warning once it exceeds the ~600-character soft target.",
|
package/src/modules/rules.ts
CHANGED
|
@@ -15,14 +15,20 @@ import { type Artifact, summarizeArtifact } from "../artifact/artifact.ts";
|
|
|
15
15
|
import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
|
|
16
16
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
17
17
|
import type { OperationDefinition } from "../module-registry.ts";
|
|
18
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
18
19
|
import {
|
|
20
|
+
addRuleProject,
|
|
19
21
|
assignRuleProject,
|
|
20
22
|
createRule,
|
|
21
23
|
gateTaskWithRule,
|
|
22
24
|
listRules,
|
|
23
25
|
previewRule,
|
|
26
|
+
removeRuleProject,
|
|
27
|
+
replaceRuleProjects,
|
|
24
28
|
ruleCombinedLength,
|
|
25
29
|
ruleCombinedLengthWarning,
|
|
30
|
+
ruleScope,
|
|
31
|
+
setRuleGlobal,
|
|
26
32
|
showRule,
|
|
27
33
|
transitionRule,
|
|
28
34
|
updateRule,
|
|
@@ -71,10 +77,19 @@ export const RULES_OPERATION_NAMES = [
|
|
|
71
77
|
"rules.disable",
|
|
72
78
|
"rules.gate",
|
|
73
79
|
"rules.assign_project",
|
|
80
|
+
"rules.scope",
|
|
81
|
+
"rules.set_global",
|
|
82
|
+
"rules.add_project",
|
|
83
|
+
"rules.remove_project",
|
|
84
|
+
"rules.replace_projects",
|
|
74
85
|
"rules.update",
|
|
75
86
|
] as const;
|
|
76
87
|
|
|
77
|
-
export function rulesOperations(
|
|
88
|
+
export function rulesOperations(
|
|
89
|
+
artifacts: ArtifactStore,
|
|
90
|
+
scopes: ArtifactScopeStore,
|
|
91
|
+
registry: ProjectRegistryStore,
|
|
92
|
+
): OperationDefinition[] {
|
|
78
93
|
const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
|
|
79
94
|
name,
|
|
80
95
|
moduleId: MODULE_ID,
|
|
@@ -97,8 +112,10 @@ export function rulesOperations(artifacts: ArtifactStore, scopes: ArtifactScopeS
|
|
|
97
112
|
extra: input.extra as Record<string, unknown> | undefined,
|
|
98
113
|
templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
|
|
99
114
|
projectRoot: optionalString(input, "project_root"),
|
|
115
|
+
projectReferences: input.projects as string[] | undefined,
|
|
100
116
|
},
|
|
101
117
|
eventContext(input),
|
|
118
|
+
registry,
|
|
102
119
|
),
|
|
103
120
|
),
|
|
104
121
|
),
|
|
@@ -125,6 +142,17 @@ export function rulesOperations(artifacts: ArtifactStore, scopes: ArtifactScopeS
|
|
|
125
142
|
define("rules.assign_project", (input: OperationInput) =>
|
|
126
143
|
assignRuleProject(artifacts, scopes, string(input, "id"), optionalString(input, "project_root")),
|
|
127
144
|
),
|
|
145
|
+
define("rules.scope", (input: OperationInput) => ruleScope(artifacts, scopes, string(input, "id"))),
|
|
146
|
+
define("rules.set_global", (input: OperationInput) => setRuleGlobal(artifacts, scopes, string(input, "id"))),
|
|
147
|
+
define("rules.add_project", (input: OperationInput) =>
|
|
148
|
+
addRuleProject(artifacts, scopes, registry, string(input, "id"), string(input, "project")),
|
|
149
|
+
),
|
|
150
|
+
define("rules.remove_project", (input: OperationInput) =>
|
|
151
|
+
removeRuleProject(artifacts, scopes, registry, string(input, "id"), string(input, "project")),
|
|
152
|
+
),
|
|
153
|
+
define("rules.replace_projects", (input: OperationInput) =>
|
|
154
|
+
replaceRuleProjects(artifacts, scopes, registry, string(input, "id"), (input.projects as string[] | undefined) ?? []),
|
|
155
|
+
),
|
|
128
156
|
define("rules.update", (input: OperationInput) =>
|
|
129
157
|
withRuleLengthInfo(
|
|
130
158
|
updateRule(
|
package/src/ops.ts
CHANGED
|
@@ -496,6 +496,7 @@ export function purgeDueArtifacts(db: Db, now: () => string = () => new Date().t
|
|
|
496
496
|
db.prepare("DELETE FROM task_scopes WHERE task_id = ?").run(id);
|
|
497
497
|
db.prepare("DELETE FROM task_views WHERE root_task_id = ?").run(id);
|
|
498
498
|
db.prepare("DELETE FROM graph_projection_identities WHERE artifact_id = ?").run(id);
|
|
499
|
+
db.prepare("DELETE FROM artifact_scope_projects WHERE artifact_id = ?").run(id);
|
|
499
500
|
db.prepare("DELETE FROM artifact_scopes WHERE artifact_id = ?").run(id);
|
|
500
501
|
db.prepare("DELETE FROM task_events WHERE task_id = ?").run(id);
|
|
501
502
|
db.prepare("DELETE FROM note_events WHERE note_id = ?").run(id);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Project, RegisterProjectInput } from "../domain/project-registry.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Kind-neutral project identity, shared by Task scope and every non-Task artifact scope
|
|
5
|
+
* (Docs/Rules/Playbooks) rather than each domain keeping its own catalog. TaskScopeStore
|
|
6
|
+
* composes one of these for its own `projects`/`matchingProjects`/`registerProject` methods
|
|
7
|
+
* instead of implementing project bookkeeping itself; ArtifactScopeStore does the same.
|
|
8
|
+
*/
|
|
9
|
+
export interface ProjectRegistryStore {
|
|
10
|
+
projects(query: string | undefined, limit: number): Project[];
|
|
11
|
+
matchingProjects(reference: string): Project[];
|
|
12
|
+
registerProject(input: RegisterProjectInput): Project;
|
|
13
|
+
}
|