@danypops/papyrus 0.47.2 → 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/package.json +1 -1
- 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
|
@@ -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
|
+
}
|
|
@@ -8,9 +8,14 @@
|
|
|
8
8
|
import type { Artifact } from "../artifact/artifact.ts";
|
|
9
9
|
import { requireLocallyOwnedContent } from "../artifact/artifact.ts";
|
|
10
10
|
import type { ArtifactEventContext } from "../artifact/artifact-event.ts";
|
|
11
|
-
import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
|
|
11
|
+
import type { ArtifactScope, ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
|
|
12
12
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT,
|
|
15
|
+
RULE_TEXT_HARD_LIMIT_CHARACTERS,
|
|
16
|
+
RULE_TEXT_SOFT_TARGET_CHARACTERS,
|
|
17
|
+
} from "../constants.ts";
|
|
18
|
+
import { resolveProjectReference } from "../domain/project-registry.ts";
|
|
14
19
|
import { normalizeProjectRoot } from "../domain/task-scope.ts";
|
|
15
20
|
import {
|
|
16
21
|
assertLabelsBounds,
|
|
@@ -24,6 +29,7 @@ import {
|
|
|
24
29
|
type TransitionTable,
|
|
25
30
|
type UpdateContentInput,
|
|
26
31
|
} from "../domain-service-shared.ts";
|
|
32
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
27
33
|
|
|
28
34
|
export interface CreateRuleInput {
|
|
29
35
|
title: string;
|
|
@@ -36,6 +42,8 @@ export interface CreateRuleInput {
|
|
|
36
42
|
extra?: Record<string, unknown>;
|
|
37
43
|
templateId?: string;
|
|
38
44
|
projectRoot?: string;
|
|
45
|
+
/** Bounded exact registered project references (id/name/alias/root) -- fail-closed unlike projectRoot's auto-register-by-root legacy form. Takes precedence over projectRoot when both are given. */
|
|
46
|
+
projectReferences?: string[];
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
export type RuleTransition = "enable" | "disable";
|
|
@@ -121,8 +129,12 @@ export function createRule(
|
|
|
121
129
|
scopes: ArtifactScopeStore,
|
|
122
130
|
input: CreateRuleInput,
|
|
123
131
|
context?: ArtifactEventContext,
|
|
132
|
+
registry?: ProjectRegistryStore,
|
|
124
133
|
): Artifact {
|
|
125
134
|
assertRuleTextWithinBounds(input.condition, input.action, input.body);
|
|
135
|
+
if (input.projectReferences !== undefined && input.projectReferences.length > 0 && registry === undefined) {
|
|
136
|
+
throw new Error("projectReferences requires a project registry");
|
|
137
|
+
}
|
|
126
138
|
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
127
139
|
const rule = artifacts.create(
|
|
128
140
|
{
|
|
@@ -143,7 +155,15 @@ export function createRule(
|
|
|
143
155
|
},
|
|
144
156
|
context,
|
|
145
157
|
);
|
|
146
|
-
|
|
158
|
+
if (input.projectReferences !== undefined && input.projectReferences.length > 0) {
|
|
159
|
+
if (input.projectReferences.length > ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
|
|
160
|
+
throw new Error(`projectReferences may include at most ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} entries`);
|
|
161
|
+
}
|
|
162
|
+
const ids = input.projectReferences.map((reference) => resolveProjectReference(registry!, reference).id);
|
|
163
|
+
scopes.replaceProjects(rule.id, ids, "explicit");
|
|
164
|
+
} else {
|
|
165
|
+
scopes.assign(rule.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
|
|
166
|
+
}
|
|
147
167
|
return rule;
|
|
148
168
|
}
|
|
149
169
|
|
|
@@ -161,22 +181,91 @@ export function assignRuleProject(
|
|
|
161
181
|
}
|
|
162
182
|
|
|
163
183
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
184
|
+
* The multi-project scope surface rules.assign_project cannot express (more than one membership,
|
|
185
|
+
* or exact fail-closed reference resolution instead of assign's auto-register-by-root). id is
|
|
186
|
+
* resolved through requireKind so these reject the same way against a non-Rule or unknown id as
|
|
187
|
+
* every other rules.* mutation; the project REFERENCE (name/alias/root) is resolved through the
|
|
188
|
+
* shared registry's resolveProjectReference, so an unknown or ambiguous project fails closed with
|
|
189
|
+
* bounded candidates rather than silently creating a new registration or guessing.
|
|
190
|
+
*/
|
|
191
|
+
export function ruleScope(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string): ArtifactScope {
|
|
192
|
+
requireKind(artifacts, id, "rule");
|
|
193
|
+
return scopes.scope(id);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function setRuleGlobal(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string): ArtifactScope {
|
|
197
|
+
requireKind(artifacts, id, "rule");
|
|
198
|
+
return scopes.setGlobal(id, "explicit");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function replaceRuleProjects(
|
|
202
|
+
artifacts: ArtifactStore,
|
|
203
|
+
scopes: ArtifactScopeStore,
|
|
204
|
+
registry: ProjectRegistryStore,
|
|
205
|
+
id: string,
|
|
206
|
+
projectReferences: readonly string[],
|
|
207
|
+
): ArtifactScope {
|
|
208
|
+
requireKind(artifacts, id, "rule");
|
|
209
|
+
if (projectReferences.length === 0) throw new Error("projectReferences must be non-empty; use rules.set_global to clear scoping");
|
|
210
|
+
if (projectReferences.length > ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
|
|
211
|
+
throw new Error(`projectReferences may include at most ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} entries`);
|
|
212
|
+
}
|
|
213
|
+
const ids = projectReferences.map((reference) => resolveProjectReference(registry, reference).id);
|
|
214
|
+
return scopes.replaceProjects(id, ids, "explicit");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function addRuleProject(
|
|
218
|
+
artifacts: ArtifactStore,
|
|
219
|
+
scopes: ArtifactScopeStore,
|
|
220
|
+
registry: ProjectRegistryStore,
|
|
221
|
+
id: string,
|
|
222
|
+
projectReference: string,
|
|
223
|
+
): ArtifactScope {
|
|
224
|
+
requireKind(artifacts, id, "rule");
|
|
225
|
+
const project = resolveProjectReference(registry, projectReference);
|
|
226
|
+
return scopes.addProject(id, project.id, "explicit");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function removeRuleProject(
|
|
230
|
+
artifacts: ArtifactStore,
|
|
231
|
+
scopes: ArtifactScopeStore,
|
|
232
|
+
registry: ProjectRegistryStore,
|
|
233
|
+
id: string,
|
|
234
|
+
projectReference: string,
|
|
235
|
+
): ArtifactScope {
|
|
236
|
+
requireKind(artifacts, id, "rule");
|
|
237
|
+
const project = resolveProjectReference(registry, projectReference);
|
|
238
|
+
return scopes.removeProject(id, project.id);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The extra.scope run-gating check alone -- a Rule with no extra.scope always passes this; one with a skill-run/playbook-run scope passes only while its run owns activeTaskId. Both a workflow-definition target's own run scope ("skill-run", written by workflow-execution.ts's runWorkflowSteps for that target kind) and a Playbook's own run scope ("playbook-run", same call for a Playbook target) are recognized -- confirmed live that only "skill-run" was ever checked here, silently breaking Playbook-run-scoped rule injection since Playbook gained its own doc/rule structured steps. */
|
|
242
|
+
function passesRunScope(rule: Artifact, activeTaskId: string | undefined): boolean {
|
|
243
|
+
const scope = rule.extra.scope;
|
|
244
|
+
if (scope === undefined) return true;
|
|
245
|
+
if (typeof scope !== "object" || scope === null || Array.isArray(scope)) return false;
|
|
246
|
+
const value = scope as Record<string, unknown>;
|
|
247
|
+
if ((value.type !== "skill-run" && value.type !== "playbook-run") || !Array.isArray(value.taskIds)) return false;
|
|
248
|
+
return activeTaskId !== undefined && value.taskIds.some((id) => id === activeTaskId);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Global rules always apply everywhere; a project-bound Rule (ArtifactScopeStore's own
|
|
253
|
+
* project-membership scope, orthogonal to extra.scope's run-gating) is applicable only when
|
|
254
|
+
* projectRoot resolves to one of its registered project memberships. Both checks are an AND, not
|
|
255
|
+
* an alternative: extra.scope's own run-gating can no longer bypass project scope, and project
|
|
256
|
+
* membership can no longer bypass run-gating -- confirmed live that a project-bound Rule was
|
|
257
|
+
* injected into every project before this fix, since this function never consulted
|
|
258
|
+
* ArtifactScopeStore at all despite rules.assign_project already existing.
|
|
170
259
|
*/
|
|
171
|
-
export function listInjectableRules(
|
|
260
|
+
export function listInjectableRules(
|
|
261
|
+
artifacts: ArtifactStore,
|
|
262
|
+
scopes: ArtifactScopeStore,
|
|
263
|
+
projectRoot: string | undefined,
|
|
264
|
+
activeTaskId?: string,
|
|
265
|
+
): Artifact[] {
|
|
172
266
|
return artifacts.query({ kind: "rule", status: "active" }).filter((rule) => {
|
|
173
267
|
if (rule.subtype === "artifact-template") return false;
|
|
174
|
-
|
|
175
|
-
if (scope === undefined) return true;
|
|
176
|
-
if (typeof scope !== "object" || scope === null || Array.isArray(scope)) return false;
|
|
177
|
-
const value = scope as Record<string, unknown>;
|
|
178
|
-
if ((value.type !== "skill-run" && value.type !== "playbook-run") || !Array.isArray(value.taskIds)) return false;
|
|
179
|
-
return activeTaskId !== undefined && value.taskIds.some((id) => id === activeTaskId);
|
|
268
|
+
return passesRunScope(rule, activeTaskId) && scopes.appliesToProjectRoot(rule.id, projectRoot);
|
|
180
269
|
});
|
|
181
270
|
}
|
|
182
271
|
|
package/src/service.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
|
|
|
4
4
|
import type { Logger } from "@danypops/vehicle-server/logging";
|
|
5
5
|
import type { CreateArtifactInput } from "./artifact/artifact.ts";
|
|
6
6
|
import type { ArtifactEventReader } from "./artifact/artifact-event-reader.ts";
|
|
7
|
+
import type { ArtifactScopeStore } from "./artifact/artifact-scope-store.ts";
|
|
7
8
|
import type { ArtifactStore } from "./artifact/artifact-store.ts";
|
|
8
9
|
import { removeArtifactSubtree } from "./artifact/artifact-subtree.ts";
|
|
9
10
|
import type { ArtifactTrashStore } from "./artifact/artifact-trash-store.ts";
|
|
@@ -38,6 +39,7 @@ import { SQLiteGateRunner } from "./stores/sqlite-gate-runner.ts";
|
|
|
38
39
|
import { SQLiteGraphProjectionStore } from "./stores/sqlite-graph-projection-store.ts";
|
|
39
40
|
import { SQLiteLogStore } from "./stores/sqlite-log-store.ts";
|
|
40
41
|
import { SQLiteNoteEventStore } from "./stores/sqlite-note-event-store.ts";
|
|
42
|
+
import { SQLiteProjectRegistryStore } from "./stores/sqlite-project-registry-store.ts";
|
|
41
43
|
import { SQLiteSessionIdentityStore } from "./stores/sqlite-session-identity-store.ts";
|
|
42
44
|
import { SQLiteTaskCreateRequestStore } from "./stores/sqlite-task-create-request-store.ts";
|
|
43
45
|
import { SQLiteTaskEventStore } from "./stores/sqlite-task-event-store.ts";
|
|
@@ -216,6 +218,7 @@ function handlers(
|
|
|
216
218
|
_notes: Notes,
|
|
217
219
|
_events: TaskEventStore,
|
|
218
220
|
_scopes: TaskScopeStore,
|
|
221
|
+
artifactScopes: ArtifactScopeStore,
|
|
219
222
|
migrate: () => unknown,
|
|
220
223
|
moduleRegistry: OperationRegistry,
|
|
221
224
|
authority: AuthorityRegistry,
|
|
@@ -345,8 +348,12 @@ function handlers(
|
|
|
345
348
|
const id = string(input, "id");
|
|
346
349
|
return artifacts.get(id)?.kind === "task" ? tasks.runGates(id, eventContextFor(input, "gates-api")) : gates.runAsync(id);
|
|
347
350
|
},
|
|
348
|
-
"rules.injectable": (input) =>
|
|
349
|
-
|
|
351
|
+
"rules.injectable": (input) => {
|
|
352
|
+
const filter = taskFilter(input);
|
|
353
|
+
return listInjectableRules(artifacts, artifactScopes, filter.projectRoot, tasks.active(filter)?.id).map(
|
|
354
|
+
({ id, title, body, extra }) => ({ id, title, body, extra }),
|
|
355
|
+
);
|
|
356
|
+
},
|
|
350
357
|
"tasks.create": forwardToModule("tasks.create"),
|
|
351
358
|
"tasks.update": forwardToModule("tasks.update"),
|
|
352
359
|
"tasks.list": forwardToModule("tasks.list"),
|
|
@@ -414,6 +421,11 @@ function handlers(
|
|
|
414
421
|
"rules.disable": forwardToModule("rules.disable"),
|
|
415
422
|
"rules.gate": forwardToModule("rules.gate"),
|
|
416
423
|
"rules.assign_project": forwardToModule("rules.assign_project"),
|
|
424
|
+
"rules.scope": forwardToModule("rules.scope"),
|
|
425
|
+
"rules.set_global": forwardToModule("rules.set_global"),
|
|
426
|
+
"rules.add_project": forwardToModule("rules.add_project"),
|
|
427
|
+
"rules.remove_project": forwardToModule("rules.remove_project"),
|
|
428
|
+
"rules.replace_projects": forwardToModule("rules.replace_projects"),
|
|
417
429
|
"rules.update": forwardToModule("rules.update"),
|
|
418
430
|
"playbooks.create": forwardToModule("playbooks.create"),
|
|
419
431
|
"playbooks.list": forwardToModule("playbooks.list"),
|
|
@@ -462,6 +474,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
462
474
|
const notes = new Notes(artifacts, noteEvents);
|
|
463
475
|
const projections = new SQLiteGraphProjectionStore(db);
|
|
464
476
|
const artifactScopes = new SQLiteArtifactScopeStore(db);
|
|
477
|
+
const projectRegistry = new SQLiteProjectRegistryStore(db);
|
|
465
478
|
const logs = new Logs(new SQLiteLogStore(db));
|
|
466
479
|
const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
|
|
467
480
|
const discussions = new Discussions(artifacts, new SQLiteDiscussionRoundStore(db));
|
|
@@ -476,6 +489,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
476
489
|
tasks,
|
|
477
490
|
discussions,
|
|
478
491
|
sessionIdentity,
|
|
492
|
+
projectRegistry,
|
|
479
493
|
});
|
|
480
494
|
const moduleRegistry = new OperationRegistry();
|
|
481
495
|
moduleRegistry.registerAll(notesOperations(notes));
|
|
@@ -484,10 +498,10 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
484
498
|
moduleRegistry.registerAll(discussOperations(discussions));
|
|
485
499
|
moduleRegistry.registerAll(tasksOperations(tasks, artifacts, sessionIdentity));
|
|
486
500
|
moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority));
|
|
487
|
-
moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));
|
|
501
|
+
moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes, projectRegistry));
|
|
488
502
|
moduleRegistry.registerAll(playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity }));
|
|
489
503
|
moduleRegistry.registerAll(graphProjectionOperations(artifacts, projections, authority));
|
|
490
|
-
const registry = handlers(artifacts, gates, tasks, notes, events, scopes, () => migrateDb(db), moduleRegistry, authority);
|
|
504
|
+
const registry = handlers(artifacts, gates, tasks, notes, events, scopes, artifactScopes, () => migrateDb(db), moduleRegistry, authority);
|
|
491
505
|
const state = (): SchemaState => {
|
|
492
506
|
const current = schemaVersion(db);
|
|
493
507
|
return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
|