@danypops/papyrus 0.60.0 → 0.60.2
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 +3 -2
- package/src/binder/binder-service.ts +507 -0
- package/src/binder/binder.ts +34 -0
- package/src/binder/index.ts +2 -0
- package/src/cli/binders-command.ts +340 -0
- package/src/cli.ts +14 -0
- package/src/constants.ts +6 -1
- package/src/db.ts +23 -0
- package/src/handlers/artifact-trash.ts +12 -3
- package/src/handlers/binders.ts +231 -0
- package/src/handlers/registry.ts +2 -0
- package/src/handlers/tasks.ts +9 -3
- package/src/index.ts +8 -0
- package/src/modules/binders.ts +171 -0
- package/src/project-registry/scope-source.ts +1 -1
- package/src/service.ts +44 -4
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import type { CommandContext } from "@stricli/core";
|
|
2
|
+
import { buildApplication, buildCommand, buildRouteMap, numberParser } from "@stricli/core";
|
|
3
|
+
import type { PapyrusClient } from "../client.ts";
|
|
4
|
+
import type { OperationName } from "../service.ts";
|
|
5
|
+
import { artifactLabel, type CliArtifact } from "./shared.ts";
|
|
6
|
+
import { runStricliToString } from "./stricli-run.ts";
|
|
7
|
+
|
|
8
|
+
type BindersClient = Pick<PapyrusClient, "call">;
|
|
9
|
+
|
|
10
|
+
interface BindersContext extends CommandContext {
|
|
11
|
+
readonly client: BindersClient;
|
|
12
|
+
readonly json: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseStringArray(value: string): string[] {
|
|
16
|
+
const parsed = JSON.parse(value) as unknown;
|
|
17
|
+
if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) throw new Error("value must be a JSON string array");
|
|
18
|
+
return parsed as string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function render(this: BindersContext, result: unknown, human: string): void {
|
|
22
|
+
this.process.stdout.write(this.json ? JSON.stringify(result) : human);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const createCommand = buildCommand({
|
|
26
|
+
func: async function (
|
|
27
|
+
this: BindersContext,
|
|
28
|
+
flags: { title: string; labelsJson?: string[]; parentId?: string; projectRoot?: string; projectsJson?: string[] },
|
|
29
|
+
) {
|
|
30
|
+
const binder = await this.client.call<Record<string, unknown>, CliArtifact>("binders.create", {
|
|
31
|
+
title: flags.title,
|
|
32
|
+
labels: flags.labelsJson,
|
|
33
|
+
parent_id: flags.parentId,
|
|
34
|
+
project_root: flags.projectRoot,
|
|
35
|
+
projects: flags.projectsJson,
|
|
36
|
+
});
|
|
37
|
+
render.call(this, binder, `Created Binder: ${artifactLabel(binder)}`);
|
|
38
|
+
},
|
|
39
|
+
parameters: {
|
|
40
|
+
flags: {
|
|
41
|
+
title: { brief: "Binder name", kind: "parsed", parse: String, placeholder: "text" },
|
|
42
|
+
labelsJson: {
|
|
43
|
+
brief: "JSON string array of inherited labels",
|
|
44
|
+
kind: "parsed",
|
|
45
|
+
parse: parseStringArray,
|
|
46
|
+
placeholder: "json",
|
|
47
|
+
optional: true,
|
|
48
|
+
},
|
|
49
|
+
parentId: { brief: "Parent Binder id", kind: "parsed", parse: String, placeholder: "id", optional: true },
|
|
50
|
+
projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true },
|
|
51
|
+
projectsJson: { brief: "JSON project-reference array", kind: "parsed", parse: parseStringArray, placeholder: "json", optional: true },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
docs: { brief: "Create a Binder" },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const listCommand = buildCommand({
|
|
58
|
+
func: async function (this: BindersContext, flags: { text?: string; limit?: number; projectRoot?: string; applicable?: boolean }) {
|
|
59
|
+
const rows = await this.client.call<Record<string, unknown>, CliArtifact[]>("binders.list", {
|
|
60
|
+
text: flags.text,
|
|
61
|
+
limit: flags.limit,
|
|
62
|
+
project_root: flags.projectRoot,
|
|
63
|
+
applicable: flags.applicable,
|
|
64
|
+
});
|
|
65
|
+
render.call(this, rows, rows.length === 0 ? "No Binders found." : rows.map(artifactLabel).join("\n"));
|
|
66
|
+
},
|
|
67
|
+
parameters: {
|
|
68
|
+
flags: {
|
|
69
|
+
text: { brief: "Substring match", kind: "parsed", parse: String, placeholder: "text", optional: true },
|
|
70
|
+
limit: { brief: "Maximum Binders", kind: "parsed", parse: numberParser, optional: true },
|
|
71
|
+
projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true },
|
|
72
|
+
applicable: { brief: "Include global and project-applicable Binders", kind: "boolean", optional: true },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
docs: { brief: "List Binders" },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
interface CliBinderTree {
|
|
79
|
+
nodes: Array<{ binder: CliArtifact; path: string; effectiveLabels: string[] }>;
|
|
80
|
+
artifacts: Array<{ artifactId: string; binderId?: string; inheritedLabels: string[]; effectiveLabels: string[] }>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const treeCommand = buildCommand({
|
|
84
|
+
func: async function (this: BindersContext, flags: { projectRoot?: string; artifactIdsJson?: string[] }) {
|
|
85
|
+
const tree = await this.client.call<Record<string, unknown>, CliBinderTree>("binders.tree", {
|
|
86
|
+
project_root: flags.projectRoot,
|
|
87
|
+
artifact_ids: flags.artifactIdsJson,
|
|
88
|
+
});
|
|
89
|
+
const lines = tree.nodes.map((node) => `${node.path}${node.effectiveLabels.length ? ` [${node.effectiveLabels.join(", ")}]` : ""}`);
|
|
90
|
+
render.call(this, tree, lines.length === 0 ? "/ (no Binders)" : lines.join("\n"));
|
|
91
|
+
},
|
|
92
|
+
parameters: {
|
|
93
|
+
flags: {
|
|
94
|
+
projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true },
|
|
95
|
+
artifactIdsJson: {
|
|
96
|
+
brief: "JSON artifact-id array to project",
|
|
97
|
+
kind: "parsed",
|
|
98
|
+
parse: parseStringArray,
|
|
99
|
+
placeholder: "json",
|
|
100
|
+
optional: true,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
docs: { brief: "Show Binder hierarchy and effective labels" },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const showCommand = buildCommand({
|
|
108
|
+
func: async function (this: BindersContext, flags: { projectRoot?: string }, id: string) {
|
|
109
|
+
const node = await this.client.call<Record<string, unknown>, { binder: CliArtifact; path: string; effectiveLabels: string[] }>(
|
|
110
|
+
"binders.show",
|
|
111
|
+
{
|
|
112
|
+
id,
|
|
113
|
+
project_root: flags.projectRoot,
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
render.call(this, node, `${node.path}${node.effectiveLabels.length ? ` [${node.effectiveLabels.join(", ")}]` : ""}`);
|
|
117
|
+
},
|
|
118
|
+
parameters: {
|
|
119
|
+
flags: { projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true } },
|
|
120
|
+
positional: { kind: "tuple", parameters: [{ brief: "Binder id", parse: String, placeholder: "id" }] },
|
|
121
|
+
},
|
|
122
|
+
docs: { brief: "Show one Binder" },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const updateCommand = buildCommand({
|
|
126
|
+
func: async function (this: BindersContext, flags: { title?: string; labelsJson?: string[]; projectRoot?: string }, id: string) {
|
|
127
|
+
if (flags.title === undefined && flags.labelsJson === undefined) throw new Error("binders update requires --title or --labels-json");
|
|
128
|
+
const binder = await this.client.call<Record<string, unknown>, CliArtifact>("binders.update", {
|
|
129
|
+
id,
|
|
130
|
+
title: flags.title,
|
|
131
|
+
labels: flags.labelsJson,
|
|
132
|
+
project_root: flags.projectRoot,
|
|
133
|
+
});
|
|
134
|
+
render.call(this, binder, artifactLabel(binder));
|
|
135
|
+
},
|
|
136
|
+
parameters: {
|
|
137
|
+
flags: {
|
|
138
|
+
title: { brief: "New Binder name", kind: "parsed", parse: String, placeholder: "text", optional: true },
|
|
139
|
+
labelsJson: { brief: "Replacement direct-label array", kind: "parsed", parse: parseStringArray, placeholder: "json", optional: true },
|
|
140
|
+
projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true },
|
|
141
|
+
},
|
|
142
|
+
positional: { kind: "tuple", parameters: [{ brief: "Binder id", parse: String, placeholder: "id" }] },
|
|
143
|
+
},
|
|
144
|
+
docs: { brief: "Rename or relabel a Binder" },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const moveCommand = buildCommand({
|
|
148
|
+
func: async function (this: BindersContext, flags: { projectRoot?: string }, id: string, parentId?: string) {
|
|
149
|
+
const node = await this.client.call<Record<string, unknown>, { path: string }>("binders.move", {
|
|
150
|
+
id,
|
|
151
|
+
parent_id: parentId,
|
|
152
|
+
project_root: flags.projectRoot,
|
|
153
|
+
});
|
|
154
|
+
render.call(this, node, `Moved ${id} to ${node.path}`);
|
|
155
|
+
},
|
|
156
|
+
parameters: {
|
|
157
|
+
flags: { projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true } },
|
|
158
|
+
positional: {
|
|
159
|
+
kind: "tuple",
|
|
160
|
+
parameters: [
|
|
161
|
+
{ brief: "Binder id", parse: String, placeholder: "id" },
|
|
162
|
+
{ brief: "Parent Binder id; omit for root", parse: String, placeholder: "parent-id", optional: true },
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
docs: { brief: "Move a Binder" },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const fileCommand = buildCommand({
|
|
170
|
+
func: async function (this: BindersContext, flags: { projectRoot?: string }, artifactId: string, binderId: string) {
|
|
171
|
+
const placement = await this.client.call<Record<string, unknown>, { effectiveLabels: string[] }>("binders.file", {
|
|
172
|
+
artifact_id: artifactId,
|
|
173
|
+
binder_id: binderId,
|
|
174
|
+
project_root: flags.projectRoot,
|
|
175
|
+
});
|
|
176
|
+
render.call(this, placement, `Filed ${artifactId} in ${binderId}`);
|
|
177
|
+
},
|
|
178
|
+
parameters: {
|
|
179
|
+
flags: { projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true } },
|
|
180
|
+
positional: {
|
|
181
|
+
kind: "tuple",
|
|
182
|
+
parameters: [
|
|
183
|
+
{ brief: "Artifact id", parse: String, placeholder: "artifact-id" },
|
|
184
|
+
{ brief: "Binder id", parse: String, placeholder: "binder-id" },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
docs: { brief: "File an artifact in a Binder" },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const unfileCommand = buildCommand({
|
|
192
|
+
func: async function (this: BindersContext, flags: { projectRoot?: string }, artifactId: string) {
|
|
193
|
+
const placement = await this.client.call<Record<string, unknown>, unknown>("binders.unfile", {
|
|
194
|
+
artifact_id: artifactId,
|
|
195
|
+
project_root: flags.projectRoot,
|
|
196
|
+
});
|
|
197
|
+
render.call(this, placement, `Moved ${artifactId} to root`);
|
|
198
|
+
},
|
|
199
|
+
parameters: {
|
|
200
|
+
flags: { projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true } },
|
|
201
|
+
positional: { kind: "tuple", parameters: [{ brief: "Artifact id", parse: String, placeholder: "artifact-id" }] },
|
|
202
|
+
},
|
|
203
|
+
docs: { brief: "Move an artifact to Binder root" },
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const removeCommand = buildCommand({
|
|
207
|
+
func: async function (this: BindersContext, flags: { projectRoot?: string; reason?: string }, id: string) {
|
|
208
|
+
const result = await this.client.call<Record<string, unknown>, unknown>("binders.remove", {
|
|
209
|
+
id,
|
|
210
|
+
project_root: flags.projectRoot,
|
|
211
|
+
reason: flags.reason,
|
|
212
|
+
});
|
|
213
|
+
render.call(this, result, `Removed empty Binder ${id}`);
|
|
214
|
+
},
|
|
215
|
+
parameters: {
|
|
216
|
+
flags: {
|
|
217
|
+
projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true },
|
|
218
|
+
reason: { brief: "Audit reason", kind: "parsed", parse: String, placeholder: "text", optional: true },
|
|
219
|
+
},
|
|
220
|
+
positional: { kind: "tuple", parameters: [{ brief: "Binder id", parse: String, placeholder: "id" }] },
|
|
221
|
+
},
|
|
222
|
+
docs: { brief: "Remove an empty Binder" },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
interface CliScope {
|
|
226
|
+
artifactId: string;
|
|
227
|
+
mode: string;
|
|
228
|
+
members: Array<{ type: string; id: string }>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function scopeLabel(scope: CliScope): string {
|
|
232
|
+
return scope.mode === "all" ? "global" : `${scope.mode}: ${scope.members.map((member) => `${member.type}:${member.id}`).join(", ")}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function unaryScopeCommand(operation: "scope" | "set_global" | "set_none", brief: string) {
|
|
236
|
+
return buildCommand({
|
|
237
|
+
func: async function (this: BindersContext, _flags: Record<string, never>, id: string) {
|
|
238
|
+
const scope = await this.client.call<Record<string, unknown>, CliScope>(`binders.${operation}` as OperationName, { id });
|
|
239
|
+
render.call(this, scope, scopeLabel(scope));
|
|
240
|
+
},
|
|
241
|
+
parameters: { flags: {}, positional: { kind: "tuple", parameters: [{ brief: "Binder id", parse: String, placeholder: "id" }] } },
|
|
242
|
+
docs: { brief },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function memberCommand(operation: "add_project" | "remove_project" | "add_group" | "remove_group") {
|
|
247
|
+
const key = operation.endsWith("project") ? "project" : "group";
|
|
248
|
+
return buildCommand({
|
|
249
|
+
func: async function (this: BindersContext, _flags: Record<string, never>, id: string, member: string) {
|
|
250
|
+
const scope = await this.client.call<Record<string, unknown>, CliScope>(`binders.${operation}` as OperationName, {
|
|
251
|
+
id,
|
|
252
|
+
[key]: member,
|
|
253
|
+
});
|
|
254
|
+
render.call(this, scope, scopeLabel(scope));
|
|
255
|
+
},
|
|
256
|
+
parameters: {
|
|
257
|
+
flags: {},
|
|
258
|
+
positional: {
|
|
259
|
+
kind: "tuple",
|
|
260
|
+
parameters: [
|
|
261
|
+
{ brief: "Binder id", parse: String, placeholder: "id" },
|
|
262
|
+
{ brief: `${key} reference`, parse: String, placeholder: key },
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
docs: { brief: `${operation.startsWith("add") ? "Add" : "Remove"} a ${key}` },
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function replaceMembersCommand(operation: "replace_projects" | "replace_groups") {
|
|
271
|
+
const key = operation === "replace_projects" ? "projects" : "groups";
|
|
272
|
+
const flag = operation === "replace_projects" ? "projectsJson" : "groupsJson";
|
|
273
|
+
return buildCommand({
|
|
274
|
+
func: async function (this: BindersContext, flags: { projectsJson?: string[]; groupsJson?: string[] }, id: string) {
|
|
275
|
+
const values = flags[flag];
|
|
276
|
+
if (!values) throw new Error(`--${operation === "replace_projects" ? "projects-json" : "groups-json"} is required`);
|
|
277
|
+
const scope = await this.client.call<Record<string, unknown>, CliScope>(`binders.${operation}` as OperationName, {
|
|
278
|
+
id,
|
|
279
|
+
[key]: values,
|
|
280
|
+
});
|
|
281
|
+
render.call(this, scope, scopeLabel(scope));
|
|
282
|
+
},
|
|
283
|
+
parameters: {
|
|
284
|
+
flags: {
|
|
285
|
+
projectsJson: {
|
|
286
|
+
brief: "JSON project reference array",
|
|
287
|
+
kind: "parsed",
|
|
288
|
+
parse: parseStringArray,
|
|
289
|
+
placeholder: "json",
|
|
290
|
+
optional: true,
|
|
291
|
+
},
|
|
292
|
+
groupsJson: {
|
|
293
|
+
brief: "JSON scope-group reference array",
|
|
294
|
+
kind: "parsed",
|
|
295
|
+
parse: parseStringArray,
|
|
296
|
+
placeholder: "json",
|
|
297
|
+
optional: true,
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
positional: { kind: "tuple", parameters: [{ brief: "Binder id", parse: String, placeholder: "id" }] },
|
|
301
|
+
},
|
|
302
|
+
docs: { brief: `Replace Binder ${key}` },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const app = buildApplication(
|
|
307
|
+
buildRouteMap({
|
|
308
|
+
routes: {
|
|
309
|
+
create: createCommand,
|
|
310
|
+
list: listCommand,
|
|
311
|
+
tree: treeCommand,
|
|
312
|
+
show: showCommand,
|
|
313
|
+
update: updateCommand,
|
|
314
|
+
move: moveCommand,
|
|
315
|
+
file: fileCommand,
|
|
316
|
+
unfile: unfileCommand,
|
|
317
|
+
remove: removeCommand,
|
|
318
|
+
scope: unaryScopeCommand("scope", "Show Binder scope"),
|
|
319
|
+
"set-global": unaryScopeCommand("set_global", "Make a Binder global"),
|
|
320
|
+
"set-none": unaryScopeCommand("set_none", "Hide a Binder"),
|
|
321
|
+
"add-project": memberCommand("add_project"),
|
|
322
|
+
"remove-project": memberCommand("remove_project"),
|
|
323
|
+
"replace-projects": replaceMembersCommand("replace_projects"),
|
|
324
|
+
"add-group": memberCommand("add_group"),
|
|
325
|
+
"remove-group": memberCommand("remove_group"),
|
|
326
|
+
"replace-groups": replaceMembersCommand("replace_groups"),
|
|
327
|
+
},
|
|
328
|
+
docs: { brief: "Binder hierarchy and inherited-label operations" },
|
|
329
|
+
}),
|
|
330
|
+
{ name: "binders", scanner: { caseStyle: "allow-kebab-for-camel" } },
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
export async function runBindersCli(args: string[], client: BindersClient): Promise<string> {
|
|
334
|
+
const json = args.includes("--json");
|
|
335
|
+
return runStricliToString(
|
|
336
|
+
app,
|
|
337
|
+
args.filter((argument) => argument !== "--json"),
|
|
338
|
+
{ client, json },
|
|
339
|
+
);
|
|
340
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -5,6 +5,7 @@ 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
7
|
import { runBatchCli } from "./cli/batch-command.ts";
|
|
8
|
+
import { runBindersCli } from "./cli/binders-command.ts";
|
|
8
9
|
import { runDaemonCli } from "./cli/daemon-command.ts";
|
|
9
10
|
import { runDiscussCli } from "./cli/discuss-command.ts";
|
|
10
11
|
import { runDocsCli } from "./cli/docs-command.ts";
|
|
@@ -114,6 +115,13 @@ const USAGE = `Usage:
|
|
|
114
115
|
papyrus artifact restore <id> [--json]
|
|
115
116
|
papyrus artifact trash-status <id> [--json]
|
|
116
117
|
papyrus artifact trash-list [--json]
|
|
118
|
+
papyrus binders create --title <name> [--labels-json <json>] [--parent-id <id>] [--project-root <path>] [--json]
|
|
119
|
+
papyrus binders list|tree [--project-root <path>] [--json]
|
|
120
|
+
papyrus binders show|remove <id> [--project-root <path>] [--json]
|
|
121
|
+
papyrus binders update <id> [--title <name>] [--labels-json <json>] [--project-root <path>] [--json]
|
|
122
|
+
papyrus binders move <id> [parent-id] [--project-root <path>] [--json]
|
|
123
|
+
papyrus binders file <artifact-id> <binder-id> [--project-root <path>] [--json]
|
|
124
|
+
papyrus binders unfile <artifact-id> [--project-root <path>] [--json]
|
|
117
125
|
papyrus docs create --title <title> [--body <body>] [--subtype <subtype>] [--labels-json <json>] [--extra-json <json>] [--template-id <id>] [--project-root <path>] [--json]
|
|
118
126
|
papyrus docs list [--status <status>] [--text <query>] [--limit <count>] [--project-root <path>] [--json]
|
|
119
127
|
papyrus docs show <id> [--json]
|
|
@@ -355,6 +363,7 @@ export function runIdMigrationCli(args: string[]): string {
|
|
|
355
363
|
export {
|
|
356
364
|
runArtifactCli,
|
|
357
365
|
runBatchCli,
|
|
366
|
+
runBindersCli,
|
|
358
367
|
runDiscussCli,
|
|
359
368
|
runDocsCli,
|
|
360
369
|
runGatesCli,
|
|
@@ -381,6 +390,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
|
|
|
381
390
|
console.log(await runTaskCli(args.slice(1), client));
|
|
382
391
|
return;
|
|
383
392
|
}
|
|
393
|
+
if (command === "binders") {
|
|
394
|
+
const client = await connectPapyrusClient();
|
|
395
|
+
console.log(await runBindersCli(args.slice(1), client));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
384
398
|
if (command === "playbooks") {
|
|
385
399
|
const client = await connectPapyrusClient();
|
|
386
400
|
console.log(await runPlaybooksCli(args.slice(1), client));
|
package/src/constants.ts
CHANGED
|
@@ -17,7 +17,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
|
17
17
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
18
18
|
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
19
19
|
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
20
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
20
|
+
export const SQLITE_SCHEMA_VERSION = 31;
|
|
21
21
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
22
22
|
|
|
23
23
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
@@ -163,6 +163,11 @@ export const PLAYBOOK_INVOCATION_MAX_CREATED_TASKS = 200;
|
|
|
163
163
|
export const TASK_CANCEL_SUBTREE_MAX_NODES = 500;
|
|
164
164
|
/** artifact.remove_subtree walks `contains` transitively across any artifact kind (a task tree, or a playbook's own nested-playbook children) -- same bound rationale as TASK_CANCEL_SUBTREE_MAX_NODES, kept separate since the two traversals serve different operations. */
|
|
165
165
|
export const ARTIFACT_REMOVE_SUBTREE_MAX_NODES = 500;
|
|
166
|
+
/** Filesystem-style Binder projection bounds. A tree is one project-context view, never an unbounded graph export. */
|
|
167
|
+
export const BINDER_TREE_MAX_ARTIFACTS = 1_000;
|
|
168
|
+
export const BINDER_TREE_MAX_RELATIONSHIPS = 10_000;
|
|
169
|
+
export const BINDER_TREE_MAX_DEPTH = 32;
|
|
170
|
+
export const BINDER_EFFECTIVE_LABEL_MAX_COUNT = 256;
|
|
166
171
|
|
|
167
172
|
/**
|
|
168
173
|
* At the core, a workflow Skill creates Tasks and begins a pipeline -- an Ansible playbook or
|
package/src/db.ts
CHANGED
|
@@ -361,6 +361,7 @@ INSERT OR IGNORE INTO kinds VALUES ('doc','Knowledge — what we know (specs, de
|
|
|
361
361
|
INSERT OR IGNORE INTO kinds VALUES ('task','Work — what we are doing (objectives, steps, checklists)');
|
|
362
362
|
INSERT OR IGNORE INTO kinds VALUES ('rule','Governance — when doing X, follow Y');
|
|
363
363
|
INSERT OR IGNORE INTO kinds VALUES ('playbook','Reusable procedure — a trigger and an ordered list of steps an agent reads and follows, not a mechanically instantiated blueprint');
|
|
364
|
+
INSERT OR IGNORE INTO kinds VALUES ('binder','Organizational directory — a stable path segment for browsing artifacts without changing their identity');
|
|
364
365
|
INSERT OR IGNORE INTO statuses VALUES ('draft','doc');
|
|
365
366
|
INSERT OR IGNORE INTO statuses VALUES ('active','doc');
|
|
366
367
|
INSERT OR IGNORE INTO statuses VALUES ('archived','doc');
|
|
@@ -375,6 +376,7 @@ INSERT OR IGNORE INTO statuses VALUES ('active','rule');
|
|
|
375
376
|
INSERT OR IGNORE INTO statuses VALUES ('deprecated','rule');
|
|
376
377
|
INSERT OR IGNORE INTO statuses VALUES ('active','playbook');
|
|
377
378
|
INSERT OR IGNORE INTO statuses VALUES ('deprecated','playbook');
|
|
379
|
+
INSERT OR IGNORE INTO statuses VALUES ('active','binder');
|
|
378
380
|
INSERT OR IGNORE INTO relation_names VALUES ('references','Source material (doc→doc, doc→task, doc→rule)');
|
|
379
381
|
INSERT OR IGNORE INTO relation_names VALUES ('implements','This work satisfies that (task→doc, task→rule)');
|
|
380
382
|
INSERT OR IGNORE INTO relation_names VALUES ('follows','This work obeys that (task→rule, task→playbook)');
|
|
@@ -387,6 +389,8 @@ INSERT OR IGNORE INTO relation_names VALUES ('gates','This rule gates that task
|
|
|
387
389
|
INSERT OR IGNORE INTO relation_names VALUES ('triggers','This playbook run applies to that work (playbook→task)');
|
|
388
390
|
INSERT OR IGNORE INTO relation_names VALUES ('contains','Parent contains a nested artifact (any→any)');
|
|
389
391
|
INSERT OR IGNORE INTO relation_names VALUES ('part_of','Artifact belongs to a parent artifact (any→any)');
|
|
392
|
+
INSERT OR IGNORE INTO relation_names VALUES ('organizes','Binder provides an organizational location for an artifact without execution semantics');
|
|
393
|
+
INSERT OR IGNORE INTO relation_names VALUES ('filed_in','Artifact is filed in a Binder; reverse of organizes');
|
|
390
394
|
CREATE INDEX IF NOT EXISTS edges_to_id_idx ON edges(to_id);
|
|
391
395
|
`;
|
|
392
396
|
|
|
@@ -436,6 +440,11 @@ const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; check
|
|
|
436
440
|
{ version: 6, name: "artifact-trash", checksum: "4a75dbec2892deb54bcc1afdf0d51d81f03a8d10861787d083784a29e5c7e8f9" },
|
|
437
441
|
{ version: 7, name: "discuss-native", checksum: "ab7bdd04824bd93681917807b817d6e08b9825af90161e3ccd6d6663021dc6a0" },
|
|
438
442
|
{ version: 8, name: "discuss-options", checksum: "ba1fc5ab7cfe9166d71cc1842f13bc32a0905d08696006cec202196a70c832e8" },
|
|
443
|
+
{
|
|
444
|
+
version: 9,
|
|
445
|
+
name: "binder-hierarchy-and-label-inheritance",
|
|
446
|
+
checksum: "fbd0c8059363ab4acea78a151262da73ef87766755fe799586cb63a6405d9e3c",
|
|
447
|
+
},
|
|
439
448
|
];
|
|
440
449
|
|
|
441
450
|
export function migrationLedger(db: Db): ModuleMigrationRow[] {
|
|
@@ -1047,6 +1056,20 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
1047
1056
|
}
|
|
1048
1057
|
},
|
|
1049
1058
|
},
|
|
1059
|
+
{
|
|
1060
|
+
version: 31,
|
|
1061
|
+
name: "binder-hierarchy-and-label-inheritance",
|
|
1062
|
+
// Binders reuse the existing artifacts/edges/scope stores. Only the kind/status vocabulary
|
|
1063
|
+
// and organizational relation pair are new; paths and inherited labels stay computed views.
|
|
1064
|
+
up: (db) => {
|
|
1065
|
+
db.exec(`
|
|
1066
|
+
INSERT OR IGNORE INTO kinds VALUES ('binder','Organizational directory — a stable path segment for browsing artifacts without changing their identity');
|
|
1067
|
+
INSERT OR IGNORE INTO statuses VALUES ('active','binder');
|
|
1068
|
+
INSERT OR IGNORE INTO relation_names VALUES ('organizes','Binder provides an organizational location for an artifact without execution semantics');
|
|
1069
|
+
INSERT OR IGNORE INTO relation_names VALUES ('filed_in','Artifact is filed in a Binder; reverse of organizes');
|
|
1070
|
+
`);
|
|
1071
|
+
},
|
|
1072
|
+
},
|
|
1050
1073
|
];
|
|
1051
1074
|
|
|
1052
1075
|
/**
|
|
@@ -8,6 +8,7 @@ import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
|
8
8
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
9
9
|
import { removeArtifactSubtree } from "../artifact/artifact-subtree.ts";
|
|
10
10
|
import type { ArtifactTrashStore } from "../artifact/artifact-trash-store.ts";
|
|
11
|
+
import { BINDER_KIND } from "../binder/binder.ts";
|
|
11
12
|
import { looseObjectSchema, numberProp, passthroughOutput, stringProp, validationError } from "./shared.ts";
|
|
12
13
|
|
|
13
14
|
const OWNER = "artifact";
|
|
@@ -30,6 +31,14 @@ function requireId(input: Record<string, unknown>): string {
|
|
|
30
31
|
return id;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
function requireGenericTrashId(artifacts: ArtifactStore, input: Record<string, unknown>): string {
|
|
35
|
+
const id = requireId(input);
|
|
36
|
+
if (artifacts.get(id)?.kind === BINDER_KIND) {
|
|
37
|
+
throw validationError("Binder removal requires binders.remove so a non-empty directory cannot be orphaned");
|
|
38
|
+
}
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
export function registerArtifactTrashOperations(registry: VehicleRegistry, artifacts: ArtifactStore & ArtifactTrashStore): void {
|
|
34
43
|
const define = (
|
|
35
44
|
action: string,
|
|
@@ -72,12 +81,12 @@ export function registerArtifactTrashOperations(registry: VehicleRegistry, artif
|
|
|
72
81
|
|
|
73
82
|
define(
|
|
74
83
|
"remove",
|
|
75
|
-
"Moves
|
|
84
|
+
"Moves a non-Binder artifact to a time-gated trash, excluded from list/query but still directly showable, restorable via artifact.restore until the purge deadline. Binders require binders.remove so non-empty directories are protected.",
|
|
76
85
|
"local-write",
|
|
77
86
|
{ id: stringProp, reason: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
78
87
|
["id"],
|
|
79
88
|
(input) =>
|
|
80
|
-
artifacts.trash(
|
|
89
|
+
artifacts.trash(requireGenericTrashId(artifacts, input), {
|
|
81
90
|
reason: typeof input.reason === "string" ? input.reason : undefined,
|
|
82
91
|
context: eventContext(input),
|
|
83
92
|
}),
|
|
@@ -90,7 +99,7 @@ export function registerArtifactTrashOperations(registry: VehicleRegistry, artif
|
|
|
90
99
|
{ id: stringProp, reason: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
91
100
|
["id"],
|
|
92
101
|
(input) =>
|
|
93
|
-
removeArtifactSubtree(artifacts,
|
|
102
|
+
removeArtifactSubtree(artifacts, requireGenericTrashId(artifacts, input), {
|
|
94
103
|
reason: typeof input.reason === "string" ? input.reason : undefined,
|
|
95
104
|
context: eventContext(input),
|
|
96
105
|
}),
|