@danypops/papyrus 0.47.2 → 0.49.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 +2 -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/daemon-command.ts +48 -0
- package/src/cli/rules-command.ts +92 -0
- package/src/cli.ts +29 -4
- package/src/client.ts +6 -0
- package/src/constants.ts +4 -1
- package/src/daemon/daemon-state.ts +13 -1
- package/src/daemon/daemon.ts +25 -6
- 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 +31 -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
|
@@ -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
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { VehicleError } from "@danypops/vehicle-core";
|
|
2
2
|
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
3
|
+
import type { DaemonDiagnosis } from "@danypops/vehicle-server/daemon-lifecycle";
|
|
3
4
|
import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
|
|
4
5
|
import type { Logger } from "@danypops/vehicle-server/logging";
|
|
5
6
|
import type { CreateArtifactInput } from "./artifact/artifact.ts";
|
|
6
7
|
import type { ArtifactEventReader } from "./artifact/artifact-event-reader.ts";
|
|
8
|
+
import type { ArtifactScopeStore } from "./artifact/artifact-scope-store.ts";
|
|
7
9
|
import type { ArtifactStore } from "./artifact/artifact-store.ts";
|
|
8
10
|
import { removeArtifactSubtree } from "./artifact/artifact-subtree.ts";
|
|
9
11
|
import type { ArtifactTrashStore } from "./artifact/artifact-trash-store.ts";
|
|
@@ -38,6 +40,7 @@ import { SQLiteGateRunner } from "./stores/sqlite-gate-runner.ts";
|
|
|
38
40
|
import { SQLiteGraphProjectionStore } from "./stores/sqlite-graph-projection-store.ts";
|
|
39
41
|
import { SQLiteLogStore } from "./stores/sqlite-log-store.ts";
|
|
40
42
|
import { SQLiteNoteEventStore } from "./stores/sqlite-note-event-store.ts";
|
|
43
|
+
import { SQLiteProjectRegistryStore } from "./stores/sqlite-project-registry-store.ts";
|
|
41
44
|
import { SQLiteSessionIdentityStore } from "./stores/sqlite-session-identity-store.ts";
|
|
42
45
|
import { SQLiteTaskCreateRequestStore } from "./stores/sqlite-task-create-request-store.ts";
|
|
43
46
|
import { SQLiteTaskEventStore } from "./stores/sqlite-task-event-store.ts";
|
|
@@ -216,6 +219,7 @@ function handlers(
|
|
|
216
219
|
_notes: Notes,
|
|
217
220
|
_events: TaskEventStore,
|
|
218
221
|
_scopes: TaskScopeStore,
|
|
222
|
+
artifactScopes: ArtifactScopeStore,
|
|
219
223
|
migrate: () => unknown,
|
|
220
224
|
moduleRegistry: OperationRegistry,
|
|
221
225
|
authority: AuthorityRegistry,
|
|
@@ -345,8 +349,12 @@ function handlers(
|
|
|
345
349
|
const id = string(input, "id");
|
|
346
350
|
return artifacts.get(id)?.kind === "task" ? tasks.runGates(id, eventContextFor(input, "gates-api")) : gates.runAsync(id);
|
|
347
351
|
},
|
|
348
|
-
"rules.injectable": (input) =>
|
|
349
|
-
|
|
352
|
+
"rules.injectable": (input) => {
|
|
353
|
+
const filter = taskFilter(input);
|
|
354
|
+
return listInjectableRules(artifacts, artifactScopes, filter.projectRoot, tasks.active(filter)?.id).map(
|
|
355
|
+
({ id, title, body, extra }) => ({ id, title, body, extra }),
|
|
356
|
+
);
|
|
357
|
+
},
|
|
350
358
|
"tasks.create": forwardToModule("tasks.create"),
|
|
351
359
|
"tasks.update": forwardToModule("tasks.update"),
|
|
352
360
|
"tasks.list": forwardToModule("tasks.list"),
|
|
@@ -414,6 +422,11 @@ function handlers(
|
|
|
414
422
|
"rules.disable": forwardToModule("rules.disable"),
|
|
415
423
|
"rules.gate": forwardToModule("rules.gate"),
|
|
416
424
|
"rules.assign_project": forwardToModule("rules.assign_project"),
|
|
425
|
+
"rules.scope": forwardToModule("rules.scope"),
|
|
426
|
+
"rules.set_global": forwardToModule("rules.set_global"),
|
|
427
|
+
"rules.add_project": forwardToModule("rules.add_project"),
|
|
428
|
+
"rules.remove_project": forwardToModule("rules.remove_project"),
|
|
429
|
+
"rules.replace_projects": forwardToModule("rules.replace_projects"),
|
|
417
430
|
"rules.update": forwardToModule("rules.update"),
|
|
418
431
|
"playbooks.create": forwardToModule("playbooks.create"),
|
|
419
432
|
"playbooks.list": forwardToModule("playbooks.list"),
|
|
@@ -462,6 +475,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
462
475
|
const notes = new Notes(artifacts, noteEvents);
|
|
463
476
|
const projections = new SQLiteGraphProjectionStore(db);
|
|
464
477
|
const artifactScopes = new SQLiteArtifactScopeStore(db);
|
|
478
|
+
const projectRegistry = new SQLiteProjectRegistryStore(db);
|
|
465
479
|
const logs = new Logs(new SQLiteLogStore(db));
|
|
466
480
|
const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
|
|
467
481
|
const discussions = new Discussions(artifacts, new SQLiteDiscussionRoundStore(db));
|
|
@@ -476,6 +490,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
476
490
|
tasks,
|
|
477
491
|
discussions,
|
|
478
492
|
sessionIdentity,
|
|
493
|
+
projectRegistry,
|
|
479
494
|
});
|
|
480
495
|
const moduleRegistry = new OperationRegistry();
|
|
481
496
|
moduleRegistry.registerAll(notesOperations(notes));
|
|
@@ -484,10 +499,10 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
484
499
|
moduleRegistry.registerAll(discussOperations(discussions));
|
|
485
500
|
moduleRegistry.registerAll(tasksOperations(tasks, artifacts, sessionIdentity));
|
|
486
501
|
moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority));
|
|
487
|
-
moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));
|
|
502
|
+
moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes, projectRegistry));
|
|
488
503
|
moduleRegistry.registerAll(playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity }));
|
|
489
504
|
moduleRegistry.registerAll(graphProjectionOperations(artifacts, projections, authority));
|
|
490
|
-
const registry = handlers(artifacts, gates, tasks, notes, events, scopes, () => migrateDb(db), moduleRegistry, authority);
|
|
505
|
+
const registry = handlers(artifacts, gates, tasks, notes, events, scopes, artifactScopes, () => migrateDb(db), moduleRegistry, authority);
|
|
491
506
|
const state = (): SchemaState => {
|
|
492
507
|
const current = schemaVersion(db);
|
|
493
508
|
return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
|
|
@@ -563,6 +578,14 @@ export function createApp(deps: {
|
|
|
563
578
|
onOperationExecuted?: (operation: string, input: OperationInput) => void;
|
|
564
579
|
/** Defaults to a no-op (createVehicleHttpApp's own default) -- daemon.ts wires vehicleLogger() so a failed invocation is actually logged, not silently discarded. */
|
|
565
580
|
logger?: Logger;
|
|
581
|
+
/**
|
|
582
|
+
* Backs GET /daemon/diagnose -- "who am I, and what happened recently" (see
|
|
583
|
+
* @danypops/vehicle-server's daemon-lifecycle.ts), without a caller reading Papyrus's own
|
|
584
|
+
* SQLite database or state files directly. Omitted (e.g. in most tests, which don't run a
|
|
585
|
+
* real supervised daemon process) means the route 404s, matching how /health always exists
|
|
586
|
+
* but this diagnostic identity does not until a real serveMain() supplies it.
|
|
587
|
+
*/
|
|
588
|
+
diagnose?: () => Promise<DaemonDiagnosis>;
|
|
566
589
|
}): { fetch(request: Request): Promise<Response> } {
|
|
567
590
|
// Same Bearer token, daemon, and port as the rest of this API -- see ./handlers/registry.ts.
|
|
568
591
|
const vehicleApp = createVehicleHttpApp({ registry: deps.service.vehicle, token: deps.token, logger: deps.logger });
|
|
@@ -592,6 +615,10 @@ export function createApp(deps: {
|
|
|
592
615
|
if (request.method === "GET" && url.pathname === "/health") {
|
|
593
616
|
return json({ ok: true, version: VERSION, schema: deps.service.schemaState() });
|
|
594
617
|
}
|
|
618
|
+
if (request.method === "GET" && url.pathname === "/daemon/diagnose") {
|
|
619
|
+
if (!deps.diagnose) return json({ error: "daemon diagnose is unavailable on this instance" }, { status: 404 });
|
|
620
|
+
return json(await deps.diagnose());
|
|
621
|
+
}
|
|
595
622
|
if (request.method === "GET" && url.pathname === "/api/v1/ops") {
|
|
596
623
|
return json({ operations: deps.service.operationNames() });
|
|
597
624
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { TASK_PROJECT_ALIAS_MAX_COUNT } from "../constants.ts";
|
|
4
|
+
import type { Project, RegisterProjectInput } from "../domain/project-registry.ts";
|
|
5
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
6
|
+
|
|
7
|
+
export function uniqueAliases(values: readonly string[], name: string): string[] {
|
|
8
|
+
const seen = new Set([name.trim().toLowerCase()]);
|
|
9
|
+
const aliases = values.flatMap((value) => {
|
|
10
|
+
const trimmed = value.trim();
|
|
11
|
+
const key = trimmed.toLowerCase();
|
|
12
|
+
if (!trimmed || seen.has(key)) return [];
|
|
13
|
+
seen.add(key);
|
|
14
|
+
return [trimmed];
|
|
15
|
+
});
|
|
16
|
+
if (aliases.length > TASK_PROJECT_ALIAS_MAX_COUNT) {
|
|
17
|
+
throw new Error(`project aliases cannot exceed ${TASK_PROJECT_ALIAS_MAX_COUNT} entries`);
|
|
18
|
+
}
|
|
19
|
+
return aliases;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extracted from what was TaskScopeStore's own project-registry bookkeeping (project list/exact
|
|
24
|
+
* resolve/register/rename/move) -- moving a registered project's root must rewrite every
|
|
25
|
+
* consumer's own rows keyed by that root (Task's own TaskProjectScope/TaskViewPreference,
|
|
26
|
+
* and independently an ArtifactScopeStore's own membership rows), and more than one consumer
|
|
27
|
+
* can share a single registry instance specifically so they resolve against identical project
|
|
28
|
+
* identities. subscribeRootMoved supports that: every subscriber is notified, not just the
|
|
29
|
+
* first, so sharing one instance across TaskScopeStore and ArtifactScopeStore never silently
|
|
30
|
+
* drops the other's rewrite.
|
|
31
|
+
*/
|
|
32
|
+
export class InMemoryProjectRegistryStore implements ProjectRegistryStore {
|
|
33
|
+
private readonly projectRows = new Map<string, Project>();
|
|
34
|
+
private readonly rootMovedListeners: Array<(previousRoot: string, nextRoot: string) => void> = [];
|
|
35
|
+
|
|
36
|
+
subscribeRootMoved(listener: (previousRoot: string, nextRoot: string) => void): void {
|
|
37
|
+
this.rootMovedListeners.push(listener);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
projects(query: string | undefined, limit: number): Project[] {
|
|
41
|
+
const needle = query?.trim().toLowerCase();
|
|
42
|
+
return [...this.projectRows.values()]
|
|
43
|
+
.filter(
|
|
44
|
+
(project) =>
|
|
45
|
+
!needle ||
|
|
46
|
+
project.name.toLowerCase().includes(needle) ||
|
|
47
|
+
project.projectRoot.toLowerCase().includes(needle) ||
|
|
48
|
+
project.aliases.some((alias) => alias.toLowerCase().includes(needle)),
|
|
49
|
+
)
|
|
50
|
+
.sort((left, right) => left.name.localeCompare(right.name) || left.projectRoot.localeCompare(right.projectRoot))
|
|
51
|
+
.slice(0, limit);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
matchingProjects(reference: string): Project[] {
|
|
55
|
+
const needle = reference.trim().toLowerCase();
|
|
56
|
+
return [...this.projectRows.values()]
|
|
57
|
+
.filter(
|
|
58
|
+
(project) =>
|
|
59
|
+
project.id.toLowerCase() === needle ||
|
|
60
|
+
project.name.toLowerCase() === needle ||
|
|
61
|
+
project.projectRoot.toLowerCase() === needle ||
|
|
62
|
+
project.aliases.some((alias) => alias.toLowerCase() === needle),
|
|
63
|
+
)
|
|
64
|
+
.slice(0, 11);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
registerProject(input: RegisterProjectInput): Project {
|
|
68
|
+
const now = new Date().toISOString();
|
|
69
|
+
const byRoot = [...this.projectRows.values()].find((project) => project.projectRoot === input.projectRoot);
|
|
70
|
+
const existing = input.existingId ? this.projectRows.get(input.existingId) : byRoot;
|
|
71
|
+
const name = input.name?.trim() || existing?.name || basename(input.projectRoot) || input.projectRoot;
|
|
72
|
+
const aliases = uniqueAliases(
|
|
73
|
+
[...(existing?.aliases ?? []), ...(existing && existing.name !== name ? [existing.name] : []), ...(input.aliases ?? [])],
|
|
74
|
+
name,
|
|
75
|
+
);
|
|
76
|
+
const project: Project = {
|
|
77
|
+
id: existing?.id ?? randomUUID(),
|
|
78
|
+
name,
|
|
79
|
+
aliases,
|
|
80
|
+
projectRoot: input.projectRoot,
|
|
81
|
+
createdAt: existing?.createdAt ?? now,
|
|
82
|
+
updatedAt: now,
|
|
83
|
+
};
|
|
84
|
+
if (existing && existing.projectRoot !== project.projectRoot) {
|
|
85
|
+
for (const listener of this.rootMovedListeners) listener(existing.projectRoot, project.projectRoot);
|
|
86
|
+
}
|
|
87
|
+
this.projectRows.set(project.id, project);
|
|
88
|
+
return project;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Exposed for a caller (e.g. InMemoryTaskScopeStore) that needs to look up a project by its exact current root without going through the bounded matchingProjects search. */
|
|
92
|
+
byRoot(projectRoot: string): Project | undefined {
|
|
93
|
+
return [...this.projectRows.values()].find((project) => project.projectRoot === projectRoot);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Exposed for a caller (e.g. InMemoryArtifactScopeStore) that needs a project's own current fields (its root, for the legacy single-root compatibility view) from a stored membership id, without going through the needle-matching matchingProjects search. */
|
|
97
|
+
byId(id: string): Project | undefined {
|
|
98
|
+
return this.projectRows.get(id);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import type { Db } from "../db.ts";
|
|
4
|
+
import { inTransaction } from "../db.ts";
|
|
5
|
+
import type { Project, RegisterProjectInput } from "../domain/project-registry.ts";
|
|
6
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
7
|
+
import { uniqueAliases } from "./in-memory-project-registry-store.ts";
|
|
8
|
+
|
|
9
|
+
interface ProjectRow {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
aliases_json: string;
|
|
13
|
+
project_root: string;
|
|
14
|
+
created_at: string;
|
|
15
|
+
updated_at: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function projectFromRow(row: ProjectRow): Project {
|
|
19
|
+
return {
|
|
20
|
+
id: row.id,
|
|
21
|
+
name: row.name,
|
|
22
|
+
aliases: JSON.parse(row.aliases_json) as string[],
|
|
23
|
+
projectRoot: row.project_root,
|
|
24
|
+
createdAt: row.created_at,
|
|
25
|
+
updatedAt: row.updated_at,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Backed by `task_projects` -- the same table name Task's own migration (v26) established --
|
|
31
|
+
* kept as-is rather than renamed, so an existing database's registered project ids/names/roots
|
|
32
|
+
* survive this extraction with zero migration. Kind-neutral in behavior: nothing here reads or
|
|
33
|
+
* writes a Task-shaped row: onRootMoved lets a composing store (TaskScopeStore,
|
|
34
|
+
* ArtifactScopeStore) react to a root move for its own kind-specific rows in the same
|
|
35
|
+
* transaction, without this store needing to know either of them exist.
|
|
36
|
+
*/
|
|
37
|
+
export class SQLiteProjectRegistryStore implements ProjectRegistryStore {
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly db: Db,
|
|
40
|
+
private readonly onRootMoved?: (db: Db, previousRoot: string, nextRoot: string) => void,
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
projects(query: string | undefined, limit: number): Project[] {
|
|
44
|
+
const needle = query?.trim().toLowerCase();
|
|
45
|
+
if (!needle) {
|
|
46
|
+
return (
|
|
47
|
+
this.db
|
|
48
|
+
.prepare(
|
|
49
|
+
"SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects ORDER BY name, project_root LIMIT ?",
|
|
50
|
+
)
|
|
51
|
+
.all(limit) as ProjectRow[]
|
|
52
|
+
).map(projectFromRow);
|
|
53
|
+
}
|
|
54
|
+
return (
|
|
55
|
+
this.db
|
|
56
|
+
.prepare(`
|
|
57
|
+
SELECT id, name, aliases_json, project_root, created_at, updated_at
|
|
58
|
+
FROM task_projects
|
|
59
|
+
WHERE instr(lower(name), ?) > 0
|
|
60
|
+
OR instr(lower(project_root), ?) > 0
|
|
61
|
+
OR EXISTS (
|
|
62
|
+
SELECT 1 FROM json_each(task_projects.aliases_json)
|
|
63
|
+
WHERE instr(lower(CAST(json_each.value AS TEXT)), ?) > 0
|
|
64
|
+
)
|
|
65
|
+
ORDER BY name, project_root
|
|
66
|
+
LIMIT ?
|
|
67
|
+
`)
|
|
68
|
+
.all(needle, needle, needle, limit) as ProjectRow[]
|
|
69
|
+
).map(projectFromRow);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
matchingProjects(reference: string): Project[] {
|
|
73
|
+
const needle = reference.trim().toLowerCase();
|
|
74
|
+
return (
|
|
75
|
+
this.db
|
|
76
|
+
.prepare(`
|
|
77
|
+
SELECT id, name, aliases_json, project_root, created_at, updated_at
|
|
78
|
+
FROM task_projects
|
|
79
|
+
WHERE lower(id) = ? OR lower(name) = ? OR lower(project_root) = ?
|
|
80
|
+
OR EXISTS (
|
|
81
|
+
SELECT 1 FROM json_each(task_projects.aliases_json)
|
|
82
|
+
WHERE lower(CAST(json_each.value AS TEXT)) = ?
|
|
83
|
+
)
|
|
84
|
+
ORDER BY name, project_root
|
|
85
|
+
LIMIT 11
|
|
86
|
+
`)
|
|
87
|
+
.all(needle, needle, needle, needle) as ProjectRow[]
|
|
88
|
+
).map(projectFromRow);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
registerProject(input: RegisterProjectInput): Project {
|
|
92
|
+
return inTransaction(this.db, () => {
|
|
93
|
+
const row = (
|
|
94
|
+
input.existingId
|
|
95
|
+
? this.db
|
|
96
|
+
.prepare("SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects WHERE id = ?")
|
|
97
|
+
.get(input.existingId)
|
|
98
|
+
: this.db
|
|
99
|
+
.prepare("SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects WHERE project_root = ?")
|
|
100
|
+
.get(input.projectRoot)
|
|
101
|
+
) as ProjectRow | null;
|
|
102
|
+
const existing = row ? projectFromRow(row) : undefined;
|
|
103
|
+
const now = new Date().toISOString();
|
|
104
|
+
const name = input.name?.trim() || existing?.name || basename(input.projectRoot) || input.projectRoot;
|
|
105
|
+
const aliases = uniqueAliases(
|
|
106
|
+
[...(existing?.aliases ?? []), ...(existing && existing.name !== name ? [existing.name] : []), ...(input.aliases ?? [])],
|
|
107
|
+
name,
|
|
108
|
+
);
|
|
109
|
+
const project: Project = {
|
|
110
|
+
id: existing?.id ?? randomUUID(),
|
|
111
|
+
name,
|
|
112
|
+
aliases,
|
|
113
|
+
projectRoot: input.projectRoot,
|
|
114
|
+
createdAt: existing?.createdAt ?? now,
|
|
115
|
+
updatedAt: now,
|
|
116
|
+
};
|
|
117
|
+
if (existing && existing.projectRoot !== project.projectRoot) this.onRootMoved?.(this.db, existing.projectRoot, project.projectRoot);
|
|
118
|
+
this.db
|
|
119
|
+
.prepare(`
|
|
120
|
+
INSERT INTO task_projects (id, name, aliases_json, project_root, created_at, updated_at)
|
|
121
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
122
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
123
|
+
name = excluded.name,
|
|
124
|
+
aliases_json = excluded.aliases_json,
|
|
125
|
+
project_root = excluded.project_root,
|
|
126
|
+
updated_at = excluded.updated_at
|
|
127
|
+
`)
|
|
128
|
+
.run(project.id, project.name, JSON.stringify(project.aliases), project.projectRoot, project.createdAt, project.updatedAt);
|
|
129
|
+
return project;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { basename } from "node:path";
|
|
3
|
-
import { TASK_PROJECT_ALIAS_MAX_COUNT } from "../constants.ts";
|
|
4
1
|
import type { Db } from "../db.ts";
|
|
5
2
|
import { inTransaction } from "../db.ts";
|
|
6
3
|
import type {
|
|
@@ -11,45 +8,21 @@ import type {
|
|
|
11
8
|
TaskViewMode,
|
|
12
9
|
TaskViewPreference,
|
|
13
10
|
} from "../domain/task-scope.ts";
|
|
11
|
+
import { SQLiteProjectRegistryStore } from "./sqlite-project-registry-store.ts";
|
|
14
12
|
import type { TaskScopeStore } from "./task-scope-store.ts";
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
project_root: string;
|
|
21
|
-
created_at: string;
|
|
22
|
-
updated_at: string;
|
|
14
|
+
/** Rewrites every task_scopes/task_views row pinned to a project root that just moved -- called inside the same registerProject() transaction, so a rename/move is atomic with the rewrite. */
|
|
15
|
+
function rewriteTaskRowsForMovedRoot(db: Db, previousRoot: string, nextRoot: string): void {
|
|
16
|
+
db.prepare("UPDATE task_scopes SET project_root = ? WHERE project_root = ?").run(nextRoot, previousRoot);
|
|
17
|
+
db.prepare("UPDATE task_views SET project_root = ? WHERE project_root = ?").run(nextRoot, previousRoot);
|
|
23
18
|
}
|
|
24
19
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
id: row.id,
|
|
28
|
-
name: row.name,
|
|
29
|
-
aliases: JSON.parse(row.aliases_json) as string[],
|
|
30
|
-
projectRoot: row.project_root,
|
|
31
|
-
createdAt: row.created_at,
|
|
32
|
-
updatedAt: row.updated_at,
|
|
33
|
-
};
|
|
34
|
-
}
|
|
20
|
+
export class SQLiteTaskScopeStore implements TaskScopeStore {
|
|
21
|
+
private readonly registry: SQLiteProjectRegistryStore;
|
|
35
22
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const aliases = values.flatMap((value) => {
|
|
39
|
-
const trimmed = value.trim();
|
|
40
|
-
const key = trimmed.toLowerCase();
|
|
41
|
-
if (!trimmed || seen.has(key)) return [];
|
|
42
|
-
seen.add(key);
|
|
43
|
-
return [trimmed];
|
|
44
|
-
});
|
|
45
|
-
if (aliases.length > TASK_PROJECT_ALIAS_MAX_COUNT) {
|
|
46
|
-
throw new Error(`project aliases cannot exceed ${TASK_PROJECT_ALIAS_MAX_COUNT} entries`);
|
|
23
|
+
constructor(private readonly db: Db) {
|
|
24
|
+
this.registry = new SQLiteProjectRegistryStore(db, rewriteTaskRowsForMovedRoot);
|
|
47
25
|
}
|
|
48
|
-
return aliases;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export class SQLiteTaskScopeStore implements TaskScopeStore {
|
|
52
|
-
constructor(private readonly db: Db) {}
|
|
53
26
|
|
|
54
27
|
assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope {
|
|
55
28
|
inTransaction(this.db, () => {
|
|
@@ -115,95 +88,14 @@ export class SQLiteTaskScopeStore implements TaskScopeStore {
|
|
|
115
88
|
}
|
|
116
89
|
|
|
117
90
|
projects(query: string | undefined, limit: number): TaskProject[] {
|
|
118
|
-
|
|
119
|
-
if (!needle) {
|
|
120
|
-
return (
|
|
121
|
-
this.db
|
|
122
|
-
.prepare(
|
|
123
|
-
"SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects ORDER BY name, project_root LIMIT ?",
|
|
124
|
-
)
|
|
125
|
-
.all(limit) as ProjectRow[]
|
|
126
|
-
).map(projectFromRow);
|
|
127
|
-
}
|
|
128
|
-
return (
|
|
129
|
-
this.db
|
|
130
|
-
.prepare(`
|
|
131
|
-
SELECT id, name, aliases_json, project_root, created_at, updated_at
|
|
132
|
-
FROM task_projects
|
|
133
|
-
WHERE instr(lower(name), ?) > 0
|
|
134
|
-
OR instr(lower(project_root), ?) > 0
|
|
135
|
-
OR EXISTS (
|
|
136
|
-
SELECT 1 FROM json_each(task_projects.aliases_json)
|
|
137
|
-
WHERE instr(lower(CAST(json_each.value AS TEXT)), ?) > 0
|
|
138
|
-
)
|
|
139
|
-
ORDER BY name, project_root
|
|
140
|
-
LIMIT ?
|
|
141
|
-
`)
|
|
142
|
-
.all(needle, needle, needle, limit) as ProjectRow[]
|
|
143
|
-
).map(projectFromRow);
|
|
91
|
+
return this.registry.projects(query, limit);
|
|
144
92
|
}
|
|
145
93
|
|
|
146
94
|
matchingProjects(reference: string): TaskProject[] {
|
|
147
|
-
|
|
148
|
-
return (
|
|
149
|
-
this.db
|
|
150
|
-
.prepare(`
|
|
151
|
-
SELECT id, name, aliases_json, project_root, created_at, updated_at
|
|
152
|
-
FROM task_projects
|
|
153
|
-
WHERE lower(id) = ? OR lower(name) = ? OR lower(project_root) = ?
|
|
154
|
-
OR EXISTS (
|
|
155
|
-
SELECT 1 FROM json_each(task_projects.aliases_json)
|
|
156
|
-
WHERE lower(CAST(json_each.value AS TEXT)) = ?
|
|
157
|
-
)
|
|
158
|
-
ORDER BY name, project_root
|
|
159
|
-
LIMIT 11
|
|
160
|
-
`)
|
|
161
|
-
.all(needle, needle, needle, needle) as ProjectRow[]
|
|
162
|
-
).map(projectFromRow);
|
|
95
|
+
return this.registry.matchingProjects(reference);
|
|
163
96
|
}
|
|
164
97
|
|
|
165
98
|
registerProject(input: RegisterTaskProjectInput): TaskProject {
|
|
166
|
-
return
|
|
167
|
-
const row = (
|
|
168
|
-
input.existingId
|
|
169
|
-
? this.db
|
|
170
|
-
.prepare("SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects WHERE id = ?")
|
|
171
|
-
.get(input.existingId)
|
|
172
|
-
: this.db
|
|
173
|
-
.prepare("SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects WHERE project_root = ?")
|
|
174
|
-
.get(input.projectRoot)
|
|
175
|
-
) as ProjectRow | null;
|
|
176
|
-
const existing = row ? projectFromRow(row) : undefined;
|
|
177
|
-
const now = new Date().toISOString();
|
|
178
|
-
const name = input.name?.trim() || existing?.name || basename(input.projectRoot) || input.projectRoot;
|
|
179
|
-
const aliases = uniqueAliases(
|
|
180
|
-
[...(existing?.aliases ?? []), ...(existing && existing.name !== name ? [existing.name] : []), ...(input.aliases ?? [])],
|
|
181
|
-
name,
|
|
182
|
-
);
|
|
183
|
-
const project: TaskProject = {
|
|
184
|
-
id: existing?.id ?? randomUUID(),
|
|
185
|
-
name,
|
|
186
|
-
aliases,
|
|
187
|
-
projectRoot: input.projectRoot,
|
|
188
|
-
createdAt: existing?.createdAt ?? now,
|
|
189
|
-
updatedAt: now,
|
|
190
|
-
};
|
|
191
|
-
if (existing && existing.projectRoot !== project.projectRoot) {
|
|
192
|
-
this.db.prepare("UPDATE task_scopes SET project_root = ? WHERE project_root = ?").run(project.projectRoot, existing.projectRoot);
|
|
193
|
-
this.db.prepare("UPDATE task_views SET project_root = ? WHERE project_root = ?").run(project.projectRoot, existing.projectRoot);
|
|
194
|
-
}
|
|
195
|
-
this.db
|
|
196
|
-
.prepare(`
|
|
197
|
-
INSERT INTO task_projects (id, name, aliases_json, project_root, created_at, updated_at)
|
|
198
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
199
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
200
|
-
name = excluded.name,
|
|
201
|
-
aliases_json = excluded.aliases_json,
|
|
202
|
-
project_root = excluded.project_root,
|
|
203
|
-
updated_at = excluded.updated_at
|
|
204
|
-
`)
|
|
205
|
-
.run(project.id, project.name, JSON.stringify(project.aliases), project.projectRoot, project.createdAt, project.updatedAt);
|
|
206
|
-
return project;
|
|
207
|
-
});
|
|
99
|
+
return this.registry.registerProject(input);
|
|
208
100
|
}
|
|
209
101
|
}
|