@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/README.md
CHANGED
|
@@ -1,29 +1,45 @@
|
|
|
1
1
|
# @danypops/papyrus
|
|
2
2
|
|
|
3
|
-
The daemon, CLI, and domain services behind Papyrus's graph artifact store
|
|
3
|
+
The daemon, CLI, and domain services behind Papyrus's graph artifact store: evidence-bearing Tasks, Docs, Rules, Playbooks, and Notes over one authenticated local database. Runs standalone as a plain daemon and CLI. `@danypops/pi-papyrus` projects the same daemon into native Pi tools.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Contents
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
- [Install](#install)
|
|
8
|
+
- [Quick start](#quick-start)
|
|
9
|
+
- [Schema protocol](#schema-protocol)
|
|
10
|
+
- [Hierarchy and traversal](#hierarchy-and-traversal)
|
|
11
|
+
- [Playbooks](#playbooks)
|
|
12
|
+
- [Naming vs. ids](#naming-vs-ids)
|
|
13
|
+
- [Mutability](#mutability)
|
|
14
|
+
- [Idempotent lifecycle mutations](#idempotent-lifecycle-mutations)
|
|
15
|
+
- [Removing an artifact](#removing-an-artifact)
|
|
16
|
+
- [Context Mesh persistence model](#context-mesh-persistence-model)
|
|
17
|
+
- [Storage and service](#storage-and-service)
|
|
18
|
+
- [Related packages](#related-packages)
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bun add @danypops/papyrus
|
|
10
24
|
```
|
|
11
25
|
|
|
26
|
+
The `papyrus` binary ships with the package.
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
12
30
|
```bash
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
31
|
+
papyrus service install # install, enable, and start the user service
|
|
32
|
+
papyrus service status
|
|
33
|
+
papyrus service restart
|
|
16
34
|
|
|
17
|
-
#
|
|
35
|
+
# Task operations (add --json for machine output)
|
|
18
36
|
papyrus tasks plan
|
|
19
37
|
papyrus tasks depend <task-id> <prerequisite-id>
|
|
20
38
|
papyrus tasks update <task-id> --title "Revised task"
|
|
21
39
|
papyrus tasks focus <task-id>
|
|
22
|
-
papyrus tasks pause
|
|
23
|
-
papyrus tasks unpause
|
|
24
40
|
papyrus tasks complete <task-id>
|
|
25
41
|
|
|
26
|
-
# Resolve a
|
|
42
|
+
# Resolve a registered project name before scoped operations
|
|
27
43
|
papyrus tasks projects --query lector --json
|
|
28
44
|
papyrus tasks resolve-project Lector --json
|
|
29
45
|
|
|
@@ -32,79 +48,90 @@ papyrus notes capture "Review release provenance later"
|
|
|
32
48
|
papyrus notes list --json
|
|
33
49
|
```
|
|
34
50
|
|
|
35
|
-
|
|
51
|
+
## Schema protocol
|
|
36
52
|
|
|
37
|
-
|
|
38
|
-
bun run guard:install
|
|
39
|
-
```
|
|
53
|
+
Papyrus enforces four artifact kinds, each with its own status vocabulary:
|
|
40
54
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
Task project names are explicit registrations, never ambient-directory guesses. `tasks.projects` searches bounded registered identities; `tasks.resolve_project` requires one case-insensitive exact id, name, alias, or canonical root and fails on unknown or ambiguous references. Pass its returned `projectRoot` as `project_root` to subsequent task operations. `tasks.register_project` can rename or move an existing identity while preserving its stable id and prior name as an alias.
|
|
46
|
-
|
|
47
|
-
`tasks.create` accepts an optional `idempotency_key`. Replays with the same caller, canonical project root, key, and payload return the original response without another mutation; conflicting payload reuse is rejected. Keys are retained for seven days, isolated across callers and projects, and then expire. Retry only when reusing the exact key and payload; an unkeyed create remains unsafe to replay after an ambiguous transport failure.
|
|
55
|
+
- `doc` — knowledge: specifications, decisions, and research
|
|
56
|
+
- `task` — work: desired outcomes, gates, checklists, and dependencies
|
|
57
|
+
- `rule` — governance injected into the Pi system prompt
|
|
58
|
+
- `playbook` — a trigger and an ordered list of steps whose validated arguments render a connected collection of deterministic Tasks plus contextual Rules and Docs
|
|
48
59
|
|
|
49
|
-
|
|
60
|
+
Every edge endpoint resolves to a real artifact, and every edge relation is registered in `relation_names`. Relations are universal: any kind can link to any other kind.
|
|
50
61
|
|
|
51
|
-
|
|
62
|
+
## Hierarchy and traversal
|
|
52
63
|
|
|
53
|
-
|
|
64
|
+
`contains`/`part_of` express parent/child structure; `depends_on` expresses execution ordering. Dependency edges form an executable DAG: a self-dependency or cycle is rejected, fan-in waits for every prerequisite, and fan-out can expose several ready successors while active focus stays singular. Graph reads are cycle-safe and bounded by `depth`/`max_nodes` (default 4/100, ceiling 20/1,000). Executable task plans are bounded to 1,000 tasks and 10,000 relationships.
|
|
54
65
|
|
|
55
|
-
|
|
66
|
+
## Playbooks
|
|
56
67
|
|
|
57
|
-
|
|
68
|
+
A Playbook step is a plain prose string (a Task) or a structured object: `{kind:'doc',...}` creates a Doc, `{kind:'rule',...}` creates a Rule, `{kind:'call',...}` nests another Playbook's own run as a pipeline step. `playbooks.invoke` validates and normalizes arguments, renders placeholders in memory, validates the complete graph, then persists artifacts and edges in one transaction. Dependencies, containment, gates, checklists, and context all survive rendering. A run's Rules are active only while focus belongs to that run; Docs keep their invocation context and provenance.
|
|
58
69
|
|
|
59
|
-
|
|
70
|
+
A run result carries a stable schema: Playbook id, run id, normalized arguments, created ids grouped by kind, ready root task ids, and the bounded execution plan. An explicit run id produces deterministic artifact ids (`<run-id>-<blueprint-ref>`); a collision rolls the whole run back.
|
|
60
71
|
|
|
61
72
|
```bash
|
|
62
|
-
papyrus
|
|
63
|
-
--
|
|
73
|
+
papyrus playbooks invoke <playbook-id> \
|
|
74
|
+
--arguments-json '{"project":"Papyrus"}' \
|
|
64
75
|
--json
|
|
65
76
|
```
|
|
66
77
|
|
|
67
|
-
##
|
|
78
|
+
## Naming vs. ids
|
|
68
79
|
|
|
69
|
-
|
|
80
|
+
Every agent-facing domain (tasks, docs, rules, playbooks, notes, discuss) addresses artifacts by `name` (the exact title) anywhere `id` would otherwise be required — `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` (tasks), `target_name` (docs link, searched across every kind), `task_name` (rules gate, discuss block/unblock), and `blocks_task_names` (discuss open). Resolution is an exact, case-insensitive, trimmed title match scoped like a plain list call; an ambiguous name's error lists the real ids, the one place disambiguation needs them. A returned result leads with name and status; id surfaces only when two artifacts in the same result share a title. `id` itself keeps working, in every tool.
|
|
70
81
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
## Mutability
|
|
83
|
+
|
|
84
|
+
Tasks, Docs, Rules, and Playbooks all support `update` (title/body/labels, at least one field required) alongside creation. Every update shares creation's own bounds (Rules keep their own tighter condition+action+body ceiling) and lands on the artifact's append-only mutation history, queryable via `graph.history`. An artifact carrying a `source:<system>` label (e.g. `source:web-spider`) is a read-only projection owned by that system; `update` is refused with a clear error, and a correction belongs in a new linked Doc until that system ships its own write-back path. Notes route every content change through their own facade.
|
|
85
|
+
|
|
86
|
+
## Idempotent lifecycle mutations
|
|
75
87
|
|
|
76
|
-
|
|
88
|
+
`tasks.create` accepts an optional `idempotency_key`, scoped to the caller and canonical project root. Replaying the same key with the same payload returns the original response; a changed payload is rejected. Keys expire after seven days.
|
|
77
89
|
|
|
78
|
-
|
|
90
|
+
Task lifecycle mutations (`start`, `submit`, `reject`, `retry`, `cancel`, `reopen`, `complete`, `pause`, `unpause`) are destination-state idempotent: repeating an already-reached transition is a `changed: false` no-op with no duplicate history. Pass `idempotency_key` on any mutation whose response might get lost; after an unclear outcome, call `tasks.show` and `tasks.mutation_status` with that same key before deciding the next action. Completed receipts are retained for seven days; concurrent duplicate completion calls share one gate run. An incompatible transition returns typed `invalid-transition` details with current/intended status, allowed actions, and recovery guidance.
|
|
79
91
|
|
|
80
|
-
|
|
92
|
+
`tasks.claim`, `tasks.heartbeat_lease`, and `tasks.lease` return the reusable artifact alias as `taskName` plus `taskTitle`; use `taskName` for later Task operations and keep the lease token for heartbeat/release.
|
|
81
93
|
|
|
82
|
-
|
|
94
|
+
Task project names are registered identities, resolved explicitly rather than guessed from a working directory. `tasks.projects` searches bounded registered identities; `tasks.resolve_project` matches one case-insensitive exact id, name, alias, or canonical root, and reports unknown or ambiguous references directly. Pass the returned `projectRoot` into subsequent task operations. `tasks.register_project` renames or moves an existing identity while keeping its stable id and folding the prior name into its aliases.
|
|
83
95
|
|
|
84
|
-
|
|
96
|
+
## Removing an artifact
|
|
85
97
|
|
|
86
|
-
|
|
98
|
+
An artifact gets a permanent, immutable `created` row in the mutation event log the moment it exists, so removal is a time-gated trash entry. `remove` (the shared `artifact.remove`/`artifact.remove_subtree` operations every domain routes through, or `papyrus artifact remove <id> [--reason <text>]`) moves an artifact to the trash: excluded from every list/query immediately, still reachable directly by id, and recoverable via `restore` for 30 days. `remove` on a Task currently holding live Focus in any scope is refused.
|
|
99
|
+
|
|
100
|
+
Past the 30-day deadline, the daemon's periodic sweep performs a real, cascading delete — the one deliberate exception to Papyrus's append-only history, enforced by a database trigger checked at delete time.
|
|
101
|
+
|
|
102
|
+
## Context Mesh persistence model
|
|
103
|
+
|
|
104
|
+
`artifacts` is the shared graph-identity supertype; `edges` references that single identity table at both endpoints, keeping foreign-key integrity across domains. Domain extension tables exist only where application invariants need indexed relational state — Task chronology/focus/scope, Discourse posts/events/session cursors/projection checkpoints — a class-table/table-per-type layout with explicit child-to-parent foreign keys.
|
|
105
|
+
|
|
106
|
+
The owning application stays the mutation authority for its own extension rows: Discourse commits its rows and `context-thread`/`context-message` Doc projections atomically through `discourse.store`, while generic artifact/document/lifecycle/graph-link operations reject those owned subtypes and the `reply_to`/`discusses` relations. SQLite triggers verify each extension row references its expected Doc subtype. Domain tables stay canonical for domain invariants; graph bodies and metadata are read-oriented projections committed in the same transaction.
|
|
87
107
|
|
|
88
108
|
```bash
|
|
89
|
-
papyrus
|
|
90
|
-
--
|
|
109
|
+
papyrus discourse store read_thread --store-id team-forum \
|
|
110
|
+
--input-json '{"forumId":"engineering","topicId":"reviews","threadId":"mesh","limit":25}' \
|
|
91
111
|
--json
|
|
92
112
|
```
|
|
93
113
|
|
|
94
|
-
|
|
114
|
+
## Storage and service
|
|
95
115
|
|
|
96
|
-
|
|
116
|
+
```text
|
|
117
|
+
$XDG_DATA_HOME/papyrus/papyrus.db # durable graph
|
|
118
|
+
$XDG_RUNTIME_DIR/papyrus/{port,token} # private daemon discovery
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The daemon runs SQLite with WAL, foreign keys, a bounded busy timeout, versioned migrations, periodic passive checkpoints, and periodic `PRAGMA optimize`. Keep the database on a local filesystem — WAL depends on real local file locking.
|
|
97
122
|
|
|
98
|
-
|
|
123
|
+
Application services depend on the `ArtifactStore` and `GateRunner` ports; SQLite and subprocess execution are adapters the daemon composes, so task behavior is unit-tested against fakes without a database. Task visualization projects the same `TaskGraph` into semantic display graphs through a `GraphRenderer` port — the Pi adapter in `@danypops/pi-papyrus` renders terminal Unicode via `beautiful-mermaid` behind that port, so the task domain carries no Mermaid syntax.
|
|
99
124
|
|
|
100
|
-
|
|
125
|
+
For repository work, install the versioned ownership guard from the workspace root:
|
|
101
126
|
|
|
102
|
-
|
|
127
|
+
```bash
|
|
128
|
+
bun run guard:install
|
|
129
|
+
```
|
|
103
130
|
|
|
104
|
-
|
|
131
|
+
It checks a push's destination against `DanyPops/papyrus`, including an explicit fallback URL, so a push bound for the wrong remote fails locally before it reaches GitHub.
|
|
105
132
|
|
|
106
|
-
|
|
133
|
+
`src/index.ts` is this package's public surface for `@danypops/pi-papyrus` and any other consumer: explicit, named exports curated for outside use.
|
|
107
134
|
|
|
108
|
-
|
|
135
|
+
## Related packages
|
|
109
136
|
|
|
110
|
-
|
|
137
|
+
- **[`@danypops/pi-papyrus`](https://www.npmjs.com/package/@danypops/pi-papyrus)** — the Pi extension: native tools, TUI panels, and context injection over this daemon's authenticated loopback connection.
|
package/package.json
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"keywords": ["pi-package"],
|
|
7
6
|
"main": "./src/index.ts",
|
|
8
7
|
"types": "./src/index.ts",
|
|
9
8
|
"bin": {
|
|
@@ -1,20 +1,50 @@
|
|
|
1
1
|
import type { TaskScopeSource } from "../domain/task-scope.ts";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Project scoping for Docs/Rules/Playbooks
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Project scoping for Docs/Rules/Playbooks: an artifact is either explicitly global (applies
|
|
5
|
+
* everywhere) or bound to a bounded, non-empty set of registered project ids -- never inferred
|
|
6
|
+
* from an accidentally empty join table, which is why `mode` is its own explicit field rather
|
|
7
|
+
* than "projectIds.length === 0 means global". Membership is by project id (from the shared
|
|
8
|
+
* ProjectRegistryStore) internally, so a registered project's root can move without a
|
|
9
|
+
* best-effort string rewrite across every artifact that references it -- assign/get/ids keep
|
|
10
|
+
* taking/returning a root for compatibility with every existing caller, resolved to/from a
|
|
11
|
+
* project id under the hood.
|
|
8
12
|
*/
|
|
13
|
+
export type ArtifactScopeMode = "global" | "projects";
|
|
14
|
+
|
|
9
15
|
export interface ArtifactScope {
|
|
16
|
+
artifactId: string;
|
|
17
|
+
mode: ArtifactScopeMode;
|
|
18
|
+
/** Registered project ids this artifact applies to. Always empty when mode is "global"; always non-empty when mode is "projects". */
|
|
19
|
+
projectIds: string[];
|
|
20
|
+
source: TaskScopeSource;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LegacyArtifactScope {
|
|
10
24
|
artifactId: string;
|
|
11
25
|
projectRoot?: string;
|
|
12
26
|
source: TaskScopeSource;
|
|
13
27
|
}
|
|
14
28
|
|
|
15
29
|
export interface ArtifactScopeStore {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/**
|
|
30
|
+
/** The real, non-lossy multi-membership view. Defaults to global/unscoped for an artifact with no scope row yet. */
|
|
31
|
+
scope(artifactId: string): ArtifactScope;
|
|
32
|
+
/** Single-root compatibility view over scope(): a global or unscoped artifact omits projectRoot; a "projects" mode artifact with exactly one membership resolves it back to that project's current root; more than one membership (only reachable through the new multi-project primitives below) omits projectRoot, since this shape cannot represent more than one. */
|
|
33
|
+
get(artifactId: string): LegacyArtifactScope | undefined;
|
|
34
|
+
/** Single-root compatibility shim: registers/resolves projectRoot and replaces the artifact's scope with exactly that one membership, or setGlobal() when projectRoot is undefined. Every existing caller (rules/docs/playbooks assign_project) keeps working unchanged. */
|
|
35
|
+
assign(artifactId: string, projectRoot: string | undefined, source: TaskScopeSource): LegacyArtifactScope;
|
|
36
|
+
/** Sets an artifact to explicitly global, clearing any project membership. */
|
|
37
|
+
setGlobal(artifactId: string, source: TaskScopeSource): ArtifactScope;
|
|
38
|
+
/** Replaces an artifact's entire project membership set with exactly these (registered) project ids -- must be non-empty; use setGlobal to clear scoping entirely. */
|
|
39
|
+
replaceProjects(artifactId: string, projectIds: readonly string[], source: TaskScopeSource): ArtifactScope;
|
|
40
|
+
/** Adds one project to an artifact's membership (idempotent -- adding an already-present id is a no-op), switching mode to "projects" if it was global. Enforces the bounded maximum membership count. */
|
|
41
|
+
addProject(artifactId: string, projectId: string, source: TaskScopeSource): ArtifactScope;
|
|
42
|
+
/** Removes one project from an artifact's membership (idempotent -- removing an absent id is a no-op). Rejects removing the last membership while mode is "projects": a caller must explicitly call setGlobal instead of accidentally broadening scope by emptying the set. */
|
|
43
|
+
removeProject(artifactId: string, projectId: string): ArtifactScope;
|
|
44
|
+
/** Bounded id listing for one project root (or the global/unscoped bucket when projectRoot is undefined) -- an unregistered root always yields an empty list, since nothing can be scoped to a project that was never registered. */
|
|
19
45
|
ids(projectRoot: string | undefined, limit: number): string[];
|
|
46
|
+
/** True when artifactId's scope includes projectId (mode "projects" and a member), or is "global" (applies everywhere). False for an unscoped artifact with no row -- matching a Rule/Doc/Playbook's default of "applies everywhere" being represented by the same "global" default scope() already returns, but injection call sites decide their own applicability policy; this is the raw membership fact only. */
|
|
47
|
+
appliesToProject(artifactId: string, projectId: string): boolean;
|
|
48
|
+
/** Root-based convenience over appliesToProject, for a caller (e.g. rules.injectable) that only has a project root, not a resolved id -- resolves the same way ids() does. An unregistered root (no project was ever registered for it) means only a global-mode artifact applies; projectRoot === undefined means "no project context at all", so only global-mode artifacts apply either way. */
|
|
49
|
+
appliesToProjectRoot(artifactId: string, projectRoot: string | undefined): boolean;
|
|
20
50
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT } from "../constants.ts";
|
|
2
|
+
import type { TaskScopeSource } from "../domain/task-scope.ts";
|
|
3
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
4
|
+
import { InMemoryProjectRegistryStore } from "../stores/in-memory-project-registry-store.ts";
|
|
5
|
+
import type { ArtifactScope, ArtifactScopeStore, LegacyArtifactScope } from "./artifact-scope-store.ts";
|
|
6
|
+
|
|
7
|
+
interface Row {
|
|
8
|
+
mode: "global" | "projects";
|
|
9
|
+
projectIds: Set<string>;
|
|
10
|
+
source: TaskScopeSource;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class InMemoryArtifactScopeStore implements ArtifactScopeStore {
|
|
14
|
+
private readonly rows = new Map<string, Row>();
|
|
15
|
+
private readonly registry: InMemoryProjectRegistryStore;
|
|
16
|
+
|
|
17
|
+
// Membership is stored by project id, never by root, so a registry root move (see
|
|
18
|
+
// ProjectRegistryStore.registerProject) needs no rewrite here at all -- unlike
|
|
19
|
+
// InMemoryTaskScopeStore, this store never subscribes to root-move notifications.
|
|
20
|
+
constructor(registry?: ProjectRegistryStore) {
|
|
21
|
+
this.registry = registry instanceof InMemoryProjectRegistryStore ? registry : new InMemoryProjectRegistryStore();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private toScope(artifactId: string, row: Row | undefined): ArtifactScope {
|
|
25
|
+
return row
|
|
26
|
+
? { artifactId, mode: row.mode, projectIds: [...row.projectIds], source: row.source }
|
|
27
|
+
: { artifactId, mode: "global", projectIds: [], source: "unscoped" };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
scope(artifactId: string): ArtifactScope {
|
|
31
|
+
return this.toScope(artifactId, this.rows.get(artifactId));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get(artifactId: string): LegacyArtifactScope | undefined {
|
|
35
|
+
const row = this.rows.get(artifactId);
|
|
36
|
+
if (!row) return undefined;
|
|
37
|
+
const onlyProjectId = row.mode === "projects" && row.projectIds.size === 1 ? [...row.projectIds][0] : undefined;
|
|
38
|
+
const projectRoot = onlyProjectId === undefined ? undefined : this.registry.byId(onlyProjectId)?.projectRoot;
|
|
39
|
+
return { artifactId, ...(projectRoot === undefined ? {} : { projectRoot }), source: row.source };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
assign(artifactId: string, projectRoot: string | undefined, source: TaskScopeSource): LegacyArtifactScope {
|
|
43
|
+
if (projectRoot === undefined) {
|
|
44
|
+
this.setGlobal(artifactId, source);
|
|
45
|
+
return { artifactId, source };
|
|
46
|
+
}
|
|
47
|
+
const project = this.registry.registerProject({ projectRoot });
|
|
48
|
+
this.replaceProjects(artifactId, [project.id], source);
|
|
49
|
+
return { artifactId, projectRoot: project.projectRoot, source };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setGlobal(artifactId: string, source: TaskScopeSource): ArtifactScope {
|
|
53
|
+
const row: Row = { mode: "global", projectIds: new Set(), source };
|
|
54
|
+
this.rows.set(artifactId, row);
|
|
55
|
+
return this.toScope(artifactId, row);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
replaceProjects(artifactId: string, projectIds: readonly string[], source: TaskScopeSource): ArtifactScope {
|
|
59
|
+
if (projectIds.length === 0) throw new Error("replaceProjects requires at least one project id; use setGlobal to clear scoping");
|
|
60
|
+
if (projectIds.length > ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
|
|
61
|
+
throw new Error(`an artifact cannot belong to more than ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} projects`);
|
|
62
|
+
}
|
|
63
|
+
const row: Row = { mode: "projects", projectIds: new Set(projectIds), source };
|
|
64
|
+
this.rows.set(artifactId, row);
|
|
65
|
+
return this.toScope(artifactId, row);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
addProject(artifactId: string, projectId: string, source: TaskScopeSource): ArtifactScope {
|
|
69
|
+
const existing = this.rows.get(artifactId);
|
|
70
|
+
const projectIds = new Set(existing?.mode === "projects" ? existing.projectIds : []);
|
|
71
|
+
if (!projectIds.has(projectId) && projectIds.size >= ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
|
|
72
|
+
throw new Error(`an artifact cannot belong to more than ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} projects`);
|
|
73
|
+
}
|
|
74
|
+
projectIds.add(projectId);
|
|
75
|
+
const row: Row = { mode: "projects", projectIds, source };
|
|
76
|
+
this.rows.set(artifactId, row);
|
|
77
|
+
return this.toScope(artifactId, row);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
removeProject(artifactId: string, projectId: string): ArtifactScope {
|
|
81
|
+
const existing = this.rows.get(artifactId);
|
|
82
|
+
if (existing?.mode !== "projects" || !existing.projectIds.has(projectId)) return this.toScope(artifactId, existing);
|
|
83
|
+
if (existing.projectIds.size === 1) {
|
|
84
|
+
throw new Error("cannot remove the last project membership; call setGlobal to make this artifact apply everywhere instead");
|
|
85
|
+
}
|
|
86
|
+
const projectIds = new Set(existing.projectIds);
|
|
87
|
+
projectIds.delete(projectId);
|
|
88
|
+
const row: Row = { mode: "projects", projectIds, source: existing.source };
|
|
89
|
+
this.rows.set(artifactId, row);
|
|
90
|
+
return this.toScope(artifactId, row);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
ids(projectRoot: string | undefined, limit: number): string[] {
|
|
94
|
+
if (projectRoot === undefined) {
|
|
95
|
+
return [...this.rows.entries()]
|
|
96
|
+
.filter(([, row]) => row.mode === "global")
|
|
97
|
+
.map(([artifactId]) => artifactId)
|
|
98
|
+
.sort()
|
|
99
|
+
.slice(0, limit);
|
|
100
|
+
}
|
|
101
|
+
const project = this.registry.byRoot(projectRoot);
|
|
102
|
+
if (!project) return [];
|
|
103
|
+
return [...this.rows.entries()]
|
|
104
|
+
.filter(([, row]) => row.mode === "projects" && row.projectIds.has(project.id))
|
|
105
|
+
.map(([artifactId]) => artifactId)
|
|
106
|
+
.sort()
|
|
107
|
+
.slice(0, limit);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
appliesToProject(artifactId: string, projectId: string): boolean {
|
|
111
|
+
const row = this.rows.get(artifactId);
|
|
112
|
+
if (!row || row.mode === "global") return true;
|
|
113
|
+
return row.projectIds.has(projectId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
appliesToProjectRoot(artifactId: string, projectRoot: string | undefined): boolean {
|
|
117
|
+
const row = this.rows.get(artifactId);
|
|
118
|
+
if (!row || row.mode === "global") return true;
|
|
119
|
+
if (projectRoot === undefined) return false;
|
|
120
|
+
const project = this.registry.byRoot(projectRoot);
|
|
121
|
+
return project !== undefined && row.projectIds.has(project.id);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -1,45 +1,166 @@
|
|
|
1
|
+
import { ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT } from "../constants.ts";
|
|
1
2
|
import type { Db } from "../db.ts";
|
|
2
3
|
import { inTransaction } from "../db.ts";
|
|
3
4
|
import type { TaskScopeSource } from "../domain/task-scope.ts";
|
|
4
|
-
import
|
|
5
|
+
import { SQLiteProjectRegistryStore } from "../stores/sqlite-project-registry-store.ts";
|
|
6
|
+
import type { ArtifactScope, ArtifactScopeMode, ArtifactScopeStore, LegacyArtifactScope } from "./artifact-scope-store.ts";
|
|
7
|
+
|
|
8
|
+
interface ScopeRow {
|
|
9
|
+
artifact_id: string;
|
|
10
|
+
mode: ArtifactScopeMode;
|
|
11
|
+
source: TaskScopeSource;
|
|
12
|
+
}
|
|
5
13
|
|
|
6
14
|
export class SQLiteArtifactScopeStore implements ArtifactScopeStore {
|
|
7
|
-
|
|
15
|
+
private readonly registry: SQLiteProjectRegistryStore;
|
|
8
16
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
// Membership is by project id, not root -- a registry root move (see
|
|
18
|
+
// SQLiteProjectRegistryStore.registerProject) needs no rewrite of artifact_scope_projects at
|
|
19
|
+
// all, unlike SQLiteTaskScopeStore's own task_scopes/task_views rewrite.
|
|
20
|
+
constructor(private readonly db: Db) {
|
|
21
|
+
this.registry = new SQLiteProjectRegistryStore(db);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private membershipIds(artifactId: string): string[] {
|
|
25
|
+
return (
|
|
26
|
+
this.db.prepare("SELECT project_id FROM artifact_scope_projects WHERE artifact_id = ? ORDER BY project_id").all(artifactId) as Array<{
|
|
27
|
+
project_id: string;
|
|
28
|
+
}>
|
|
29
|
+
).map((row) => row.project_id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private readRow(artifactId: string): ScopeRow | undefined {
|
|
33
|
+
const row = this.db
|
|
34
|
+
.prepare("SELECT artifact_id, mode, source FROM artifact_scopes WHERE artifact_id = ?")
|
|
35
|
+
.get(artifactId) as ScopeRow | null;
|
|
36
|
+
return row ?? undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
scope(artifactId: string): ArtifactScope {
|
|
40
|
+
const row = this.readRow(artifactId);
|
|
41
|
+
if (!row) return { artifactId, mode: "global", projectIds: [], source: "unscoped" };
|
|
42
|
+
return { artifactId, mode: row.mode, projectIds: row.mode === "projects" ? this.membershipIds(artifactId) : [], source: row.source };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get(artifactId: string): LegacyArtifactScope | undefined {
|
|
46
|
+
const row = this.readRow(artifactId);
|
|
47
|
+
if (!row) return undefined;
|
|
48
|
+
if (row.mode !== "projects") return { artifactId, source: row.source };
|
|
49
|
+
const ids = this.membershipIds(artifactId);
|
|
50
|
+
if (ids.length !== 1) return { artifactId, source: row.source };
|
|
51
|
+
const project = this.registry.matchingProjects(ids[0]!).find((candidate) => candidate.id === ids[0]);
|
|
52
|
+
return { artifactId, ...(project ? { projectRoot: project.projectRoot } : {}), source: row.source };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
assign(artifactId: string, projectRoot: string | undefined, source: TaskScopeSource): LegacyArtifactScope {
|
|
56
|
+
if (projectRoot === undefined) {
|
|
57
|
+
this.setGlobal(artifactId, source);
|
|
58
|
+
return { artifactId, source };
|
|
59
|
+
}
|
|
60
|
+
return inTransaction(this.db, () => {
|
|
61
|
+
const project = this.registry.registerProject({ projectRoot });
|
|
62
|
+
this.replaceProjects(artifactId, [project.id], source);
|
|
63
|
+
return { artifactId, projectRoot: project.projectRoot, source };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private upsertScopeRow(artifactId: string, mode: ArtifactScopeMode, source: TaskScopeSource): void {
|
|
68
|
+
this.db
|
|
69
|
+
.prepare(`
|
|
70
|
+
INSERT INTO artifact_scopes (artifact_id, project_root, mode, source, assigned_at)
|
|
71
|
+
VALUES (?, NULL, ?, ?, ?)
|
|
15
72
|
ON CONFLICT(artifact_id) DO UPDATE SET
|
|
16
|
-
project_root =
|
|
73
|
+
project_root = NULL,
|
|
74
|
+
mode = excluded.mode,
|
|
17
75
|
source = excluded.source,
|
|
18
76
|
assigned_at = excluded.assigned_at
|
|
19
77
|
`)
|
|
20
|
-
|
|
78
|
+
.run(artifactId, mode, source, new Date().toISOString());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setGlobal(artifactId: string, source: TaskScopeSource): ArtifactScope {
|
|
82
|
+
return inTransaction(this.db, () => {
|
|
83
|
+
this.upsertScopeRow(artifactId, "global", source);
|
|
84
|
+
this.db.prepare("DELETE FROM artifact_scope_projects WHERE artifact_id = ?").run(artifactId);
|
|
85
|
+
return { artifactId, mode: "global", projectIds: [], source };
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
replaceProjects(artifactId: string, projectIds: readonly string[], source: TaskScopeSource): ArtifactScope {
|
|
90
|
+
if (projectIds.length === 0) throw new Error("replaceProjects requires at least one project id; use setGlobal to clear scoping");
|
|
91
|
+
if (projectIds.length > ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
|
|
92
|
+
throw new Error(`an artifact cannot belong to more than ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} projects`);
|
|
93
|
+
}
|
|
94
|
+
return inTransaction(this.db, () => {
|
|
95
|
+
this.upsertScopeRow(artifactId, "projects", source);
|
|
96
|
+
this.db.prepare("DELETE FROM artifact_scope_projects WHERE artifact_id = ?").run(artifactId);
|
|
97
|
+
const insert = this.db.prepare("INSERT OR IGNORE INTO artifact_scope_projects (artifact_id, project_id) VALUES (?, ?)");
|
|
98
|
+
const unique = [...new Set(projectIds)];
|
|
99
|
+
for (const projectId of unique) insert.run(artifactId, projectId);
|
|
100
|
+
return { artifactId, mode: "projects", projectIds: unique, source };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
addProject(artifactId: string, projectId: string, source: TaskScopeSource): ArtifactScope {
|
|
105
|
+
return inTransaction(this.db, () => {
|
|
106
|
+
const current = this.membershipIds(artifactId);
|
|
107
|
+
if (!current.includes(projectId) && current.length >= ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
|
|
108
|
+
throw new Error(`an artifact cannot belong to more than ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} projects`);
|
|
109
|
+
}
|
|
110
|
+
this.upsertScopeRow(artifactId, "projects", source);
|
|
111
|
+
this.db.prepare("INSERT OR IGNORE INTO artifact_scope_projects (artifact_id, project_id) VALUES (?, ?)").run(artifactId, projectId);
|
|
112
|
+
return { artifactId, mode: "projects", projectIds: this.membershipIds(artifactId), source };
|
|
21
113
|
});
|
|
22
|
-
return { artifactId, ...(projectRoot === undefined ? {} : { projectRoot }), source };
|
|
23
114
|
}
|
|
24
115
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
116
|
+
removeProject(artifactId: string, projectId: string): ArtifactScope {
|
|
117
|
+
return inTransaction(this.db, () => {
|
|
118
|
+
const row = this.readRow(artifactId);
|
|
119
|
+
const current = row?.mode === "projects" ? this.membershipIds(artifactId) : [];
|
|
120
|
+
if (row?.mode !== "projects" || !current.includes(projectId)) return this.scope(artifactId);
|
|
121
|
+
if (current.length === 1) {
|
|
122
|
+
throw new Error("cannot remove the last project membership; call setGlobal to make this artifact apply everywhere instead");
|
|
123
|
+
}
|
|
124
|
+
this.db.prepare("DELETE FROM artifact_scope_projects WHERE artifact_id = ? AND project_id = ?").run(artifactId, projectId);
|
|
125
|
+
return { artifactId, mode: "projects", projectIds: this.membershipIds(artifactId), source: row.source };
|
|
126
|
+
});
|
|
34
127
|
}
|
|
35
128
|
|
|
36
129
|
ids(projectRoot: string | undefined, limit: number): string[] {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
130
|
+
if (projectRoot === undefined) {
|
|
131
|
+
return (
|
|
132
|
+
this.db.prepare("SELECT artifact_id FROM artifact_scopes WHERE mode = 'global' ORDER BY artifact_id LIMIT ?").all(limit) as Array<{
|
|
133
|
+
artifact_id: string;
|
|
134
|
+
}>
|
|
135
|
+
).map((row) => row.artifact_id);
|
|
136
|
+
}
|
|
137
|
+
const project = this.db.prepare("SELECT id FROM task_projects WHERE project_root = ?").get(projectRoot) as { id: string } | null;
|
|
138
|
+
if (!project) return [];
|
|
139
|
+
return (
|
|
140
|
+
this.db
|
|
141
|
+
.prepare(`
|
|
142
|
+
SELECT asp.artifact_id AS artifact_id
|
|
143
|
+
FROM artifact_scope_projects asp
|
|
144
|
+
JOIN artifact_scopes s ON s.artifact_id = asp.artifact_id AND s.mode = 'projects'
|
|
145
|
+
WHERE asp.project_id = ?
|
|
146
|
+
ORDER BY asp.artifact_id
|
|
147
|
+
LIMIT ?
|
|
148
|
+
`)
|
|
149
|
+
.all(project.id, limit) as Array<{ artifact_id: string }>
|
|
150
|
+
).map((row) => row.artifact_id);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
appliesToProject(artifactId: string, projectId: string): boolean {
|
|
154
|
+
const row = this.readRow(artifactId);
|
|
155
|
+
if (!row || row.mode === "global") return true;
|
|
156
|
+
return this.membershipIds(artifactId).includes(projectId);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
appliesToProjectRoot(artifactId: string, projectRoot: string | undefined): boolean {
|
|
160
|
+
const row = this.readRow(artifactId);
|
|
161
|
+
if (!row || row.mode === "global") return true;
|
|
162
|
+
if (projectRoot === undefined) return false;
|
|
163
|
+
const project = this.db.prepare("SELECT id FROM task_projects WHERE project_root = ?").get(projectRoot) as { id: string } | null;
|
|
164
|
+
return project !== null && this.membershipIds(artifactId).includes(project.id);
|
|
44
165
|
}
|
|
45
166
|
}
|