@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@danypops/vehicle-client": "^0.6.1",
|
|
37
37
|
"@danypops/vehicle-core": "^0.12.3",
|
|
38
|
-
"@danypops/vehicle-server": "^0.
|
|
38
|
+
"@danypops/vehicle-server": "^0.19.0",
|
|
39
39
|
"@stricli/core": "^1.3.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { DaemonDiagnosis } from "@danypops/vehicle-server/daemon-lifecycle";
|
|
2
|
+
import type { CommandContext } from "@stricli/core";
|
|
3
|
+
import { buildApplication, buildCommand, buildRouteMap } from "@stricli/core";
|
|
4
|
+
import { runStricliToString } from "./stricli-run.ts";
|
|
5
|
+
|
|
6
|
+
export interface DaemonDiagnoseClient {
|
|
7
|
+
diagnose(): Promise<DaemonDiagnosis>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface DaemonContext extends CommandContext {
|
|
11
|
+
readonly client: DaemonDiagnoseClient;
|
|
12
|
+
readonly json: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function renderHistoryLine(event: DaemonDiagnosis["history"][number]): string {
|
|
16
|
+
const reason = event.reason ? ` (${event.reason})` : "";
|
|
17
|
+
return `${event.at} ${event.type}${reason} pid=${event.pid} instance=${event.instanceId}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const diagnoseCommand = buildCommand({
|
|
21
|
+
func: async function (this: DaemonContext) {
|
|
22
|
+
const diagnosis = await this.client.diagnose();
|
|
23
|
+
if (this.json) {
|
|
24
|
+
this.process.stdout.write(JSON.stringify(diagnosis));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const lines = [
|
|
28
|
+
`instance ${diagnosis.instanceId} (pid ${diagnosis.pid}, ${diagnosis.provenance}), started ${diagnosis.startedAt}`,
|
|
29
|
+
"",
|
|
30
|
+
"recent history:",
|
|
31
|
+
...(diagnosis.history.length === 0 ? [" (none)"] : diagnosis.history.map((event) => ` ${renderHistoryLine(event)}`)),
|
|
32
|
+
];
|
|
33
|
+
this.process.stdout.write(lines.join("\n"));
|
|
34
|
+
},
|
|
35
|
+
parameters: { flags: {} },
|
|
36
|
+
docs: { brief: "Show this daemon's identity and recent start/stop/already_running history" },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const app = buildApplication(buildRouteMap({ routes: { diagnose: diagnoseCommand }, docs: { brief: "Daemon operations" } }), {
|
|
40
|
+
name: "daemon",
|
|
41
|
+
scanner: { caseStyle: "allow-kebab-for-camel" },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export async function runDaemonCli(args: string[], client: DaemonDiagnoseClient): Promise<string> {
|
|
45
|
+
const json = args.includes("--json");
|
|
46
|
+
const positional = args.filter((arg) => arg !== "--json");
|
|
47
|
+
return runStricliToString(app, positional, { client, json });
|
|
48
|
+
}
|
package/src/cli/rules-command.ts
CHANGED
|
@@ -113,6 +113,93 @@ const assignProjectCommand = buildCommand({
|
|
|
113
113
|
docs: { brief: "Reassign a Rule's project scope, or unscope it" },
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
interface CliArtifactScope {
|
|
117
|
+
artifactId: string;
|
|
118
|
+
mode: string;
|
|
119
|
+
projectIds: string[];
|
|
120
|
+
source: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderScope(scope: CliArtifactScope): string {
|
|
124
|
+
return scope.mode === "global" ? "global (applies to every project)" : `projects: ${scope.projectIds.join(", ")}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const scopeCommand = buildCommand({
|
|
128
|
+
func: async function (this: RulesContext, _flags: Record<string, never>, id: string) {
|
|
129
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("rules.scope", { id });
|
|
130
|
+
render.call(this, scope, renderScope(scope));
|
|
131
|
+
},
|
|
132
|
+
parameters: { flags: {}, positional: { kind: "tuple", parameters: [{ brief: "Rule id", parse: String, placeholder: "id" }] } },
|
|
133
|
+
docs: { brief: "Show a Rule's real project scope" },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const setGlobalCommand = buildCommand({
|
|
137
|
+
func: async function (this: RulesContext, _flags: Record<string, never>, id: string) {
|
|
138
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("rules.set_global", { id });
|
|
139
|
+
render.call(this, scope, renderScope(scope));
|
|
140
|
+
},
|
|
141
|
+
parameters: { flags: {}, positional: { kind: "tuple", parameters: [{ brief: "Rule id", parse: String, placeholder: "id" }] } },
|
|
142
|
+
docs: { brief: "Make a Rule apply in every project" },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const addProjectCommand = buildCommand({
|
|
146
|
+
func: async function (this: RulesContext, _flags: Record<string, never>, id: string, project: string) {
|
|
147
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("rules.add_project", { id, project });
|
|
148
|
+
render.call(this, scope, renderScope(scope));
|
|
149
|
+
},
|
|
150
|
+
parameters: {
|
|
151
|
+
flags: {},
|
|
152
|
+
positional: {
|
|
153
|
+
kind: "tuple",
|
|
154
|
+
parameters: [
|
|
155
|
+
{ brief: "Rule id", parse: String, placeholder: "id" },
|
|
156
|
+
{ brief: "Project id/name/alias/root to add", parse: String, placeholder: "project" },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
docs: { brief: "Add one project to a Rule's membership" },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const removeProjectCommand = buildCommand({
|
|
164
|
+
func: async function (this: RulesContext, _flags: Record<string, never>, id: string, project: string) {
|
|
165
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("rules.remove_project", { id, project });
|
|
166
|
+
render.call(this, scope, renderScope(scope));
|
|
167
|
+
},
|
|
168
|
+
parameters: {
|
|
169
|
+
flags: {},
|
|
170
|
+
positional: {
|
|
171
|
+
kind: "tuple",
|
|
172
|
+
parameters: [
|
|
173
|
+
{ brief: "Rule id", parse: String, placeholder: "id" },
|
|
174
|
+
{ brief: "Project id/name/alias/root to remove", parse: String, placeholder: "project" },
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
docs: { brief: "Remove one project from a Rule's membership" },
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const replaceProjectsCommand = buildCommand({
|
|
182
|
+
func: async function (this: RulesContext, flags: { projectsJson: string[] }, id: string) {
|
|
183
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("rules.replace_projects", {
|
|
184
|
+
id,
|
|
185
|
+
projects: flags.projectsJson,
|
|
186
|
+
});
|
|
187
|
+
render.call(this, scope, renderScope(scope));
|
|
188
|
+
},
|
|
189
|
+
parameters: {
|
|
190
|
+
flags: {
|
|
191
|
+
projectsJson: {
|
|
192
|
+
brief: "JSON string array of project id/name/alias/root references",
|
|
193
|
+
kind: "parsed",
|
|
194
|
+
parse: parseStringArray,
|
|
195
|
+
placeholder: "json",
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
positional: { kind: "tuple", parameters: [{ brief: "Rule id", parse: String, placeholder: "id" }] },
|
|
199
|
+
},
|
|
200
|
+
docs: { brief: "Replace a Rule's entire project membership" },
|
|
201
|
+
});
|
|
202
|
+
|
|
116
203
|
const showCommand = buildCommand({
|
|
117
204
|
func: async function (this: RulesContext, _flags: Record<string, never>, id: string) {
|
|
118
205
|
const artifact = await this.client.call<Record<string, unknown>, CliArtifact>("rules.show", { id });
|
|
@@ -204,6 +291,11 @@ const app = buildApplication(
|
|
|
204
291
|
create: createCommand,
|
|
205
292
|
list: listCommand,
|
|
206
293
|
"assign-project": assignProjectCommand,
|
|
294
|
+
scope: scopeCommand,
|
|
295
|
+
"set-global": setGlobalCommand,
|
|
296
|
+
"add-project": addProjectCommand,
|
|
297
|
+
"remove-project": removeProjectCommand,
|
|
298
|
+
"replace-projects": replaceProjectsCommand,
|
|
207
299
|
show: showCommand,
|
|
208
300
|
preview: previewCommand,
|
|
209
301
|
enable: buildEnableDisableCommand("enable"),
|
package/src/cli.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { copyFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createNodeServiceInstallDeps, generateSystemdUnit, installUserService, type ServiceSpec } from "@danypops/vehicle-server/service";
|
|
6
6
|
import { runArtifactCli } from "./cli/artifact-command.ts";
|
|
7
|
+
import { runDaemonCli } from "./cli/daemon-command.ts";
|
|
7
8
|
import { runDiscussCli } from "./cli/discuss-command.ts";
|
|
8
9
|
import { runDocsCli } from "./cli/docs-command.ts";
|
|
9
10
|
import { runGatesCli } from "./cli/gates-command.ts";
|
|
@@ -206,7 +207,7 @@ function usage(): never {
|
|
|
206
207
|
process.exit(2);
|
|
207
208
|
}
|
|
208
209
|
|
|
209
|
-
export { runMigrationCli };
|
|
210
|
+
export { runDaemonCli, runMigrationCli };
|
|
210
211
|
|
|
211
212
|
function readIdMap(sidecarPath: string): IdMigrationPlan {
|
|
212
213
|
const raw = JSON.parse(readFileSync(sidecarPath, "utf8")) as { idMap: Record<string, string> };
|
|
@@ -294,7 +295,15 @@ export function runIdMigrationCli(args: string[]): string {
|
|
|
294
295
|
try {
|
|
295
296
|
result = verifyIdMigration(mirror, plan);
|
|
296
297
|
} finally {
|
|
298
|
+
// openDb() always opens a file-backed database in WAL mode, including this mirror
|
|
299
|
+
// (produced by VACUUM INTO with no WAL of its own until this very open). Fold it back
|
|
300
|
+
// into the main file and drop the sidecars before copying just the main file below --
|
|
301
|
+
// the same reasoning already applied to target's own sidecars a few lines down. Copying
|
|
302
|
+
// the main file while leaving a newer -wal/-shm pair for a now-deleted identity behind
|
|
303
|
+
// produces a file SQLite reopens as a malformed image, not merely a stale one.
|
|
304
|
+
mirror.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
297
305
|
mirror.close();
|
|
306
|
+
for (const sidecar of [`${mirrorPath}-wal`, `${mirrorPath}-shm`]) if (existsSync(sidecar)) unlinkSync(sidecar);
|
|
298
307
|
}
|
|
299
308
|
if (!result.ok) {
|
|
300
309
|
throw new Error(
|
|
@@ -316,7 +325,18 @@ export function runIdMigrationCli(args: string[]): string {
|
|
|
316
325
|
copyFileSync(target, backupPath);
|
|
317
326
|
for (const sidecar of [`${target}-wal`, `${target}-shm`]) if (existsSync(sidecar)) unlinkSync(sidecar);
|
|
318
327
|
}
|
|
319
|
-
copyFileSync(mirrorPath, target)
|
|
328
|
+
// A plain copyFileSync(mirrorPath, target) overwrites target's own file content in place --
|
|
329
|
+
// confirmed live to reopen as "database disk image is malformed" even though both the
|
|
330
|
+
// checkpointed target and the checkpointed mirror are independently completely healthy
|
|
331
|
+
// right before this copy: something about SQLite's own handling of a path/inode this
|
|
332
|
+
// process already opened earlier in the same run (target was just opened above to
|
|
333
|
+
// checkpoint it) survives closing that connection. Copying to a fresh staging path (a new
|
|
334
|
+
// inode, never opened by this process) and swapping it into place with an atomic rename
|
|
335
|
+
// sidesteps that entirely -- renameSync never fails partway the way a corrupted in-place
|
|
336
|
+
// overwrite can.
|
|
337
|
+
const staging = `${target}.promoting`;
|
|
338
|
+
copyFileSync(mirrorPath, staging);
|
|
339
|
+
renameSync(staging, target);
|
|
320
340
|
const result2 = { target, backupPath };
|
|
321
341
|
if (json) return JSON.stringify(result2);
|
|
322
342
|
return [
|
|
@@ -347,7 +367,7 @@ export {
|
|
|
347
367
|
export async function main(args: string[] = process.argv.slice(2)): Promise<void> {
|
|
348
368
|
const [command, action] = args;
|
|
349
369
|
if (command === "serve") {
|
|
350
|
-
serveMain();
|
|
370
|
+
await serveMain();
|
|
351
371
|
return;
|
|
352
372
|
}
|
|
353
373
|
if (command === "tasks") {
|
|
@@ -385,6 +405,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
|
|
|
385
405
|
console.log(await runMigrationCli(args.slice(1), client));
|
|
386
406
|
return;
|
|
387
407
|
}
|
|
408
|
+
if (command === "daemon") {
|
|
409
|
+
const client = await connectPapyrusClient();
|
|
410
|
+
console.log(await runDaemonCli(args.slice(1), client));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
388
413
|
if (command === "migrate-ids") {
|
|
389
414
|
console.log(runIdMigrationCli(args.slice(1)));
|
|
390
415
|
return;
|
package/src/client.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
spawnDetachedDaemon,
|
|
8
8
|
} from "@danypops/vehicle-client/daemon-client";
|
|
9
9
|
import { createLiveVersionExpectation } from "@danypops/vehicle-client/version";
|
|
10
|
+
import type { DaemonDiagnosis } from "@danypops/vehicle-server/daemon-lifecycle";
|
|
10
11
|
import { DAEMON_CLIENT_TIMEOUT_MS, DAEMON_DIR_ENV, DAEMON_PROBE_TIMEOUT_MS } from "./constants.ts";
|
|
11
12
|
import { type DaemonHandle, daemonStateDir, readDaemonHandle } from "./daemon/daemon-state.ts";
|
|
12
13
|
import type { OperationName, SchemaState } from "./service.ts";
|
|
@@ -50,6 +51,11 @@ export class PapyrusClient {
|
|
|
50
51
|
return this.request("/health");
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/** Backed by GET /daemon/diagnose -- see service.ts's createApp and vehicle-server's daemon-lifecycle.ts. */
|
|
55
|
+
diagnose(): Promise<DaemonDiagnosis> {
|
|
56
|
+
return this.request("/daemon/diagnose");
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
async operations(): Promise<OperationName[]> {
|
|
54
60
|
const body = await this.request<{ operations: OperationName[] }>("/api/v1/ops");
|
|
55
61
|
return body.operations;
|