@danypops/papyrus 0.60.1 → 0.60.3
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 -1
- package/src/artifact/artifact.ts +9 -0
- 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/note-command.ts +29 -0
- package/src/cli/task-command.ts +51 -0
- package/src/cli.ts +16 -0
- package/src/constants.ts +9 -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/notes.ts +15 -0
- package/src/handlers/registry.ts +2 -0
- package/src/handlers/tasks.ts +13 -0
- package/src/index.ts +8 -0
- package/src/modules/binders.ts +171 -0
- package/src/modules/notes.ts +10 -0
- package/src/modules/tasks.ts +9 -0
- package/src/note/note-service.ts +72 -0
- package/src/ops.ts +7 -1
- package/src/service.ts +46 -4
- package/src/task/task-service.ts +96 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.60.
|
|
3
|
+
"version": "0.60.3",
|
|
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",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/index.ts",
|
|
10
10
|
"./artifact": "./src/artifact/index.ts",
|
|
11
|
+
"./binder": "./src/binder/index.ts",
|
|
11
12
|
"./client": "./src/client.ts",
|
|
12
13
|
"./discussion": "./src/discussion/index.ts",
|
|
13
14
|
"./domain": "./src/domain/index.ts",
|
package/src/artifact/artifact.ts
CHANGED
|
@@ -64,6 +64,11 @@ export interface UpdateArtifactInput {
|
|
|
64
64
|
alias?: string;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
export interface ArtifactQueryCursor {
|
|
68
|
+
createdAt: string;
|
|
69
|
+
id: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
67
72
|
export interface ArtifactQuery {
|
|
68
73
|
kind?: string;
|
|
69
74
|
status?: string;
|
|
@@ -74,6 +79,10 @@ export interface ArtifactQuery {
|
|
|
74
79
|
labels?: string[];
|
|
75
80
|
extraEquals?: Record<string, string | number | boolean>;
|
|
76
81
|
limit?: number;
|
|
82
|
+
/** Stable inventory ordering, independent of content updates. Defaults to updated_at descending. */
|
|
83
|
+
order?: "updated_desc" | "created_desc";
|
|
84
|
+
/** Exclusive keyset cursor for created_desc ordering. */
|
|
85
|
+
after?: ArtifactQueryCursor;
|
|
77
86
|
/** Trashed artifacts (see artifact-trash.ts) are excluded from every query by default; set true to include them, e.g. for a trash-listing view. */
|
|
78
87
|
includeTrashed?: boolean;
|
|
79
88
|
/**
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { type Artifact, requireLocallyOwnedContent } from "../artifact/artifact.ts";
|
|
2
|
+
import type { ArtifactEventContext } from "../artifact/artifact-event.ts";
|
|
3
|
+
import type { ArtifactScope, ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
|
|
4
|
+
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
5
|
+
import type { ArtifactTrashRecord } from "../artifact/artifact-trash.ts";
|
|
6
|
+
import type { ArtifactTrashStore } from "../artifact/artifact-trash-store.ts";
|
|
7
|
+
import { requireAtomicArtifactStore } from "../artifact/atomic-artifact-store.ts";
|
|
8
|
+
import {
|
|
9
|
+
BINDER_EFFECTIVE_LABEL_MAX_COUNT,
|
|
10
|
+
BINDER_TREE_MAX_ARTIFACTS,
|
|
11
|
+
BINDER_TREE_MAX_DEPTH,
|
|
12
|
+
BINDER_TREE_MAX_RELATIONSHIPS,
|
|
13
|
+
} from "../constants.ts";
|
|
14
|
+
import {
|
|
15
|
+
addArtifactScopeGroup,
|
|
16
|
+
addArtifactScopeProject,
|
|
17
|
+
assertLabelsBounds,
|
|
18
|
+
assertTitleBounds,
|
|
19
|
+
type ListFilter,
|
|
20
|
+
listScoped,
|
|
21
|
+
removeArtifactScopeGroup,
|
|
22
|
+
removeArtifactScopeProject,
|
|
23
|
+
replaceArtifactScopeGroups,
|
|
24
|
+
replaceArtifactScopeProjects,
|
|
25
|
+
setArtifactScopeNone,
|
|
26
|
+
} from "../domain-service-shared.ts";
|
|
27
|
+
import { resolveProjectReference } from "../project-registry/project-registry.ts";
|
|
28
|
+
import type { ProjectRegistryStore } from "../project-registry/project-registry-store.ts";
|
|
29
|
+
import { normalizeProjectRoot } from "../project-registry/scope-source.ts";
|
|
30
|
+
import type { ScopeGroupStore } from "../scope-group/scope-group-store.ts";
|
|
31
|
+
import {
|
|
32
|
+
BINDER_FILED_IN_RELATION,
|
|
33
|
+
BINDER_KIND,
|
|
34
|
+
BINDER_ORGANIZES_RELATION,
|
|
35
|
+
type BinderArtifactPlacement,
|
|
36
|
+
type BinderNode,
|
|
37
|
+
type BinderTree,
|
|
38
|
+
} from "./binder.ts";
|
|
39
|
+
|
|
40
|
+
export interface CreateBinderInput {
|
|
41
|
+
title: string;
|
|
42
|
+
labels?: string[];
|
|
43
|
+
parentId?: string;
|
|
44
|
+
projectRoot?: string;
|
|
45
|
+
projectReferences?: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UpdateBinderInput {
|
|
49
|
+
title?: string;
|
|
50
|
+
labels?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BinderTreeInput {
|
|
54
|
+
projectRoot?: string;
|
|
55
|
+
artifactIds?: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validateBinderName(title: string): string {
|
|
59
|
+
assertTitleBounds(title);
|
|
60
|
+
const normalized = title.trim();
|
|
61
|
+
if (normalized === "." || normalized === ".." || normalized.includes("/")) {
|
|
62
|
+
throw new Error('binder names cannot be ".", "..", or contain "/"');
|
|
63
|
+
}
|
|
64
|
+
return normalized;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function appendUnique(target: string[], values: readonly string[]): string[] {
|
|
68
|
+
for (const value of values) if (!target.includes(value)) target.push(value);
|
|
69
|
+
if (target.length > BINDER_EFFECTIVE_LABEL_MAX_COUNT) {
|
|
70
|
+
throw new Error(`effective Binder labels cannot exceed ${BINDER_EFFECTIVE_LABEL_MAX_COUNT} entries`);
|
|
71
|
+
}
|
|
72
|
+
return target;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function requireBinder(artifacts: ArtifactStore, id: string): Artifact {
|
|
76
|
+
const binder = artifacts.get(id);
|
|
77
|
+
if (!binder) throw new Error(`binder artifact "${id}" not found`);
|
|
78
|
+
if (binder.kind !== BINDER_KIND) throw new Error(`artifact "${id}" is not a binder`);
|
|
79
|
+
return binder;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function listBinders(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
|
|
83
|
+
return listScoped(artifacts, scopes, BINDER_KIND, filter);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function applicableBinders(artifacts: ArtifactStore, scopes: ArtifactScopeStore, projectRoot: string | undefined): Artifact[] {
|
|
87
|
+
return listBinders(
|
|
88
|
+
artifacts,
|
|
89
|
+
scopes,
|
|
90
|
+
projectRoot === undefined
|
|
91
|
+
? { limit: BINDER_TREE_MAX_ARTIFACTS }
|
|
92
|
+
: { applicableToProjectRoot: normalizeProjectRoot(projectRoot), limit: BINDER_TREE_MAX_ARTIFACTS },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function edgesFor(artifacts: ArtifactStore, ids: readonly string[]) {
|
|
97
|
+
if (ids.length === 0) return [];
|
|
98
|
+
const edges = artifacts.relationships({ artifactIds: [...ids], limit: BINDER_TREE_MAX_RELATIONSHIPS + 1 });
|
|
99
|
+
if (edges.length > BINDER_TREE_MAX_RELATIONSHIPS) {
|
|
100
|
+
throw new Error(`binder tree cannot exceed ${BINDER_TREE_MAX_RELATIONSHIPS} relationships`);
|
|
101
|
+
}
|
|
102
|
+
return edges;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function candidateParents(
|
|
106
|
+
artifacts: ArtifactStore,
|
|
107
|
+
binderIds: ReadonlySet<string>,
|
|
108
|
+
childIds: ReadonlySet<string>,
|
|
109
|
+
): Map<string, Set<string>> {
|
|
110
|
+
const parents = new Map<string, Set<string>>();
|
|
111
|
+
const add = (childId: string, parentId: string): void => {
|
|
112
|
+
if (!binderIds.has(parentId) || !childIds.has(childId) || childId === parentId) return;
|
|
113
|
+
const values = parents.get(childId) ?? new Set<string>();
|
|
114
|
+
values.add(parentId);
|
|
115
|
+
parents.set(childId, values);
|
|
116
|
+
};
|
|
117
|
+
for (const edge of edgesFor(artifacts, [...new Set([...binderIds, ...childIds])])) {
|
|
118
|
+
if (edge.relation === BINDER_ORGANIZES_RELATION) add(edge.to, edge.from);
|
|
119
|
+
else if (edge.relation === BINDER_FILED_IN_RELATION) add(edge.from, edge.to);
|
|
120
|
+
}
|
|
121
|
+
return parents;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function selectedParents(binders: ReadonlyMap<string, Artifact>, parents: ReadonlyMap<string, ReadonlySet<string>>): Map<string, string> {
|
|
125
|
+
const selected = new Map<string, string>();
|
|
126
|
+
for (const [childId, candidates] of parents) {
|
|
127
|
+
const parentId = [...candidates].sort((left, right) => {
|
|
128
|
+
const leftBinder = binders.get(left);
|
|
129
|
+
const rightBinder = binders.get(right);
|
|
130
|
+
return (leftBinder?.title ?? left).localeCompare(rightBinder?.title ?? right) || left.localeCompare(right);
|
|
131
|
+
})[0];
|
|
132
|
+
if (parentId) selected.set(childId, parentId);
|
|
133
|
+
}
|
|
134
|
+
return selected;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function binderTree(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: BinderTreeInput = {}): BinderTree {
|
|
138
|
+
const binders = applicableBinders(artifacts, scopes, input.projectRoot);
|
|
139
|
+
const binderById = new Map(binders.map((binder) => [binder.id, binder]));
|
|
140
|
+
const requestedArtifactIds = [...new Set(input.artifactIds ?? [])];
|
|
141
|
+
if (requestedArtifactIds.length > BINDER_TREE_MAX_ARTIFACTS) {
|
|
142
|
+
throw new Error(`binder tree cannot project more than ${BINDER_TREE_MAX_ARTIFACTS} artifacts`);
|
|
143
|
+
}
|
|
144
|
+
const requestedArtifacts =
|
|
145
|
+
requestedArtifactIds.length === 0
|
|
146
|
+
? []
|
|
147
|
+
: artifacts.query({ ids: requestedArtifactIds }).filter((artifact) => artifact.kind !== BINDER_KIND);
|
|
148
|
+
const artifactById = new Map(requestedArtifacts.map((artifact) => [artifact.id, artifact]));
|
|
149
|
+
const visibleChildIds = new Set([...binderById.keys(), ...artifactById.keys()]);
|
|
150
|
+
const parents = candidateParents(artifacts, new Set(binderById.keys()), visibleChildIds);
|
|
151
|
+
if (input.projectRoot !== undefined) {
|
|
152
|
+
const conflict = [...parents].find(([, candidates]) => candidates.size > 1);
|
|
153
|
+
if (conflict) {
|
|
154
|
+
throw new Error(`artifact "${conflict[0]}" has multiple visible Binder parents in this project context; refile it to one Binder`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const parentByChild = selectedParents(binderById, parents);
|
|
158
|
+
|
|
159
|
+
const resolved = new Map<string, BinderNode>();
|
|
160
|
+
const resolveNode = (id: string, stack: Set<string>): BinderNode => {
|
|
161
|
+
const cached = resolved.get(id);
|
|
162
|
+
if (cached) return cached;
|
|
163
|
+
const binder = binderById.get(id);
|
|
164
|
+
if (!binder) throw new Error(`binder artifact "${id}" not found`);
|
|
165
|
+
if (stack.has(id)) throw new Error("binder hierarchy contains a cycle");
|
|
166
|
+
if (stack.size >= BINDER_TREE_MAX_DEPTH) throw new Error(`binder hierarchy cannot exceed ${BINDER_TREE_MAX_DEPTH} levels`);
|
|
167
|
+
const nextStack = new Set(stack).add(id);
|
|
168
|
+
const parentId = parentByChild.get(id);
|
|
169
|
+
const parent = parentId === undefined ? undefined : resolveNode(parentId, nextStack);
|
|
170
|
+
const inheritedLabels = parent ? [...parent.effectiveLabels] : [];
|
|
171
|
+
const effectiveLabels = appendUnique([...inheritedLabels], binder.labels);
|
|
172
|
+
const node: BinderNode = {
|
|
173
|
+
binder,
|
|
174
|
+
...(parentId === undefined ? {} : { parentId }),
|
|
175
|
+
childIds: [],
|
|
176
|
+
path: parent ? `${parent.path}/${binder.title}` : `/${binder.title}`,
|
|
177
|
+
inheritedLabels,
|
|
178
|
+
effectiveLabels,
|
|
179
|
+
};
|
|
180
|
+
resolved.set(id, node);
|
|
181
|
+
return node;
|
|
182
|
+
};
|
|
183
|
+
for (const binder of binders) resolveNode(binder.id, new Set());
|
|
184
|
+
for (const node of resolved.values()) {
|
|
185
|
+
if (node.parentId) resolved.get(node.parentId)?.childIds.push(node.binder.id);
|
|
186
|
+
}
|
|
187
|
+
for (const node of resolved.values()) {
|
|
188
|
+
node.childIds.sort((left, right) => {
|
|
189
|
+
const a = binderById.get(left)!;
|
|
190
|
+
const b = binderById.get(right)!;
|
|
191
|
+
return a.title.localeCompare(b.title) || a.id.localeCompare(b.id);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const placements: BinderArtifactPlacement[] = requestedArtifactIds.map((artifactId) => {
|
|
196
|
+
const artifact = artifactById.get(artifactId);
|
|
197
|
+
if (!artifact) return { artifactId, inheritedLabels: [], effectiveLabels: [] };
|
|
198
|
+
const binderId = parentByChild.get(artifactId);
|
|
199
|
+
const inheritedLabels = binderId ? [...resolveNode(binderId, new Set()).effectiveLabels] : [];
|
|
200
|
+
return {
|
|
201
|
+
artifactId,
|
|
202
|
+
...(binderId === undefined ? {} : { binderId }),
|
|
203
|
+
inheritedLabels,
|
|
204
|
+
effectiveLabels: appendUnique([...inheritedLabels], artifact.labels),
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
nodes: [...resolved.values()].sort(
|
|
210
|
+
(left, right) => left.path.localeCompare(right.path) || left.binder.id.localeCompare(right.binder.id),
|
|
211
|
+
),
|
|
212
|
+
rootIds: [...resolved.values()]
|
|
213
|
+
.filter((node) => node.parentId === undefined)
|
|
214
|
+
.sort((left, right) => left.binder.title.localeCompare(right.binder.title) || left.binder.id.localeCompare(right.binder.id))
|
|
215
|
+
.map((node) => node.binder.id),
|
|
216
|
+
artifacts: placements,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function requireVisibleBinder(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, projectRoot: string | undefined): Artifact {
|
|
221
|
+
const binder = requireBinder(artifacts, id);
|
|
222
|
+
if (projectRoot !== undefined && !scopes.appliesToProjectRoot(id, normalizeProjectRoot(projectRoot))) {
|
|
223
|
+
throw new Error(`binder "${binder.title}" is outside project scope`);
|
|
224
|
+
}
|
|
225
|
+
return binder;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function assertUniqueSibling(
|
|
229
|
+
artifacts: ArtifactStore,
|
|
230
|
+
scopes: ArtifactScopeStore,
|
|
231
|
+
title: string,
|
|
232
|
+
parentId: string | undefined,
|
|
233
|
+
projectRoot: string | undefined,
|
|
234
|
+
excludeId?: string,
|
|
235
|
+
): void {
|
|
236
|
+
const normalized = title.toLowerCase();
|
|
237
|
+
const duplicate = binderTree(artifacts, scopes, { projectRoot }).nodes.find(
|
|
238
|
+
(node) => node.binder.id !== excludeId && node.parentId === parentId && node.binder.title.toLowerCase() === normalized,
|
|
239
|
+
);
|
|
240
|
+
if (duplicate) throw new Error(`binder "${title}" already exists at ${parentId ? "that path" : "the root"}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function rawVisibleParentIds(
|
|
244
|
+
artifacts: ArtifactStore,
|
|
245
|
+
scopes: ArtifactScopeStore,
|
|
246
|
+
childId: string,
|
|
247
|
+
projectRoot: string | undefined,
|
|
248
|
+
): string[] {
|
|
249
|
+
const visibleIds = new Set(applicableBinders(artifacts, scopes, projectRoot).map((binder) => binder.id));
|
|
250
|
+
return [...(candidateParents(artifacts, visibleIds, new Set([childId])).get(childId) ?? [])];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function linkPlacement(artifacts: ArtifactStore, parentId: string, childId: string, context?: ArtifactEventContext): void {
|
|
254
|
+
artifacts.link({ from: parentId, relation: BINDER_ORGANIZES_RELATION, to: childId }, context);
|
|
255
|
+
artifacts.link({ from: childId, relation: BINDER_FILED_IN_RELATION, to: parentId }, context);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function unlinkPlacement(artifacts: ArtifactStore, parentId: string, childId: string, context?: ArtifactEventContext): void {
|
|
259
|
+
artifacts.unlink({ from: parentId, relation: BINDER_ORGANIZES_RELATION, to: childId }, context);
|
|
260
|
+
artifacts.unlink({ from: childId, relation: BINDER_FILED_IN_RELATION, to: parentId }, context);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function createBinder(
|
|
264
|
+
artifacts: ArtifactStore,
|
|
265
|
+
scopes: ArtifactScopeStore,
|
|
266
|
+
input: CreateBinderInput,
|
|
267
|
+
context?: ArtifactEventContext,
|
|
268
|
+
registry?: ProjectRegistryStore,
|
|
269
|
+
): Artifact {
|
|
270
|
+
const title = validateBinderName(input.title);
|
|
271
|
+
assertLabelsBounds(input.labels);
|
|
272
|
+
if (input.projectReferences !== undefined && input.projectReferences.length > 0 && registry === undefined) {
|
|
273
|
+
throw new Error("projectReferences requires a project registry");
|
|
274
|
+
}
|
|
275
|
+
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
276
|
+
const projectContexts =
|
|
277
|
+
input.projectReferences !== undefined && input.projectReferences.length > 0
|
|
278
|
+
? input.projectReferences.map((reference) => resolveProjectReference(registry!, reference).projectRoot)
|
|
279
|
+
: [projectRoot];
|
|
280
|
+
for (const contextRoot of projectContexts) {
|
|
281
|
+
if (input.parentId) requireVisibleBinder(artifacts, scopes, input.parentId, contextRoot);
|
|
282
|
+
assertUniqueSibling(artifacts, scopes, title, input.parentId, contextRoot);
|
|
283
|
+
}
|
|
284
|
+
const binder = artifacts.create({ kind: BINDER_KIND, status: "active", title, labels: input.labels }, context);
|
|
285
|
+
if (input.projectReferences !== undefined && input.projectReferences.length > 0) {
|
|
286
|
+
replaceArtifactScopeProjects(scopes, registry!, binder.id, input.projectReferences);
|
|
287
|
+
} else {
|
|
288
|
+
scopes.assign(binder.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
|
|
289
|
+
}
|
|
290
|
+
if (input.parentId) requireAtomicArtifactStore(artifacts).atomic(() => linkPlacement(artifacts, input.parentId!, binder.id, context));
|
|
291
|
+
return artifacts.get(binder.id)!;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function assertNoBinderCycle(artifacts: ArtifactStore, binderId: string, parentId: string): void {
|
|
295
|
+
if (binderId === parentId) throw new Error("a binder cannot be moved inside itself");
|
|
296
|
+
const binders = artifacts.query({ kind: BINDER_KIND, limit: BINDER_TREE_MAX_ARTIFACTS });
|
|
297
|
+
const ids = new Set(binders.map((binder) => binder.id));
|
|
298
|
+
const hierarchyEdges = artifacts.relationships({ kind: BINDER_KIND, limit: BINDER_TREE_MAX_RELATIONSHIPS + 1 });
|
|
299
|
+
if (hierarchyEdges.length > BINDER_TREE_MAX_RELATIONSHIPS) {
|
|
300
|
+
throw new Error(`binder hierarchy cannot exceed ${BINDER_TREE_MAX_RELATIONSHIPS} relationships`);
|
|
301
|
+
}
|
|
302
|
+
const children = new Map<string, string[]>();
|
|
303
|
+
for (const edge of hierarchyEdges) {
|
|
304
|
+
if (edge.relation !== BINDER_ORGANIZES_RELATION || !ids.has(edge.from) || !ids.has(edge.to)) continue;
|
|
305
|
+
const values = children.get(edge.from) ?? [];
|
|
306
|
+
values.push(edge.to);
|
|
307
|
+
children.set(edge.from, values);
|
|
308
|
+
}
|
|
309
|
+
const pending = [binderId];
|
|
310
|
+
const visited = new Set<string>();
|
|
311
|
+
while (pending.length > 0) {
|
|
312
|
+
const current = pending.pop()!;
|
|
313
|
+
if (visited.has(current)) continue;
|
|
314
|
+
visited.add(current);
|
|
315
|
+
if (current === parentId) throw new Error("a binder cannot be moved inside one of its descendants");
|
|
316
|
+
if (visited.size > BINDER_TREE_MAX_ARTIFACTS) throw new Error(`binder hierarchy cannot exceed ${BINDER_TREE_MAX_ARTIFACTS} nodes`);
|
|
317
|
+
pending.push(...(children.get(current) ?? []));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function moveBinder(
|
|
322
|
+
artifacts: ArtifactStore,
|
|
323
|
+
scopes: ArtifactScopeStore,
|
|
324
|
+
id: string,
|
|
325
|
+
parentId: string | undefined,
|
|
326
|
+
projectRoot: string | undefined,
|
|
327
|
+
context?: ArtifactEventContext,
|
|
328
|
+
): BinderNode {
|
|
329
|
+
const binder = requireVisibleBinder(artifacts, scopes, id, projectRoot);
|
|
330
|
+
if (parentId) {
|
|
331
|
+
requireVisibleBinder(artifacts, scopes, parentId, projectRoot);
|
|
332
|
+
assertNoBinderCycle(artifacts, id, parentId);
|
|
333
|
+
}
|
|
334
|
+
assertUniqueSibling(artifacts, scopes, binder.title, parentId, projectRoot, id);
|
|
335
|
+
const oldParents = rawVisibleParentIds(artifacts, scopes, id, projectRoot);
|
|
336
|
+
if (oldParents.length === 1 && oldParents[0] === parentId)
|
|
337
|
+
return binderTree(artifacts, scopes, { projectRoot }).nodes.find((node) => node.binder.id === id)!;
|
|
338
|
+
requireAtomicArtifactStore(artifacts).atomic(() => {
|
|
339
|
+
for (const oldParentId of oldParents) unlinkPlacement(artifacts, oldParentId, id, context);
|
|
340
|
+
if (parentId) linkPlacement(artifacts, parentId, id, context);
|
|
341
|
+
});
|
|
342
|
+
return binderTree(artifacts, scopes, { projectRoot }).nodes.find((node) => node.binder.id === id)!;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function fileArtifact(
|
|
346
|
+
artifacts: ArtifactStore,
|
|
347
|
+
scopes: ArtifactScopeStore,
|
|
348
|
+
artifactId: string,
|
|
349
|
+
binderId: string,
|
|
350
|
+
projectRoot: string | undefined,
|
|
351
|
+
context?: ArtifactEventContext,
|
|
352
|
+
): BinderArtifactPlacement {
|
|
353
|
+
const artifact = artifacts.get(artifactId);
|
|
354
|
+
if (!artifact) throw new Error(`artifact "${artifactId}" not found`);
|
|
355
|
+
if (artifact.kind === BINDER_KIND) throw new Error("use binders.move to move a Binder");
|
|
356
|
+
requireVisibleBinder(artifacts, scopes, binderId, projectRoot);
|
|
357
|
+
const oldParents = rawVisibleParentIds(artifacts, scopes, artifactId, projectRoot);
|
|
358
|
+
if (!(oldParents.length === 1 && oldParents[0] === binderId)) {
|
|
359
|
+
requireAtomicArtifactStore(artifacts).atomic(() => {
|
|
360
|
+
for (const oldParentId of oldParents) unlinkPlacement(artifacts, oldParentId, artifactId, context);
|
|
361
|
+
linkPlacement(artifacts, binderId, artifactId, context);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
return binderTree(artifacts, scopes, { projectRoot, artifactIds: [artifactId] }).artifacts[0]!;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function unfileArtifact(
|
|
368
|
+
artifacts: ArtifactStore,
|
|
369
|
+
scopes: ArtifactScopeStore,
|
|
370
|
+
artifactId: string,
|
|
371
|
+
projectRoot: string | undefined,
|
|
372
|
+
context?: ArtifactEventContext,
|
|
373
|
+
): BinderArtifactPlacement {
|
|
374
|
+
const artifact = artifacts.get(artifactId);
|
|
375
|
+
if (!artifact) throw new Error(`artifact "${artifactId}" not found`);
|
|
376
|
+
if (artifact.kind === BINDER_KIND) throw new Error("use binders.move without a parent to move a Binder to root");
|
|
377
|
+
const oldParents = rawVisibleParentIds(artifacts, scopes, artifactId, projectRoot);
|
|
378
|
+
requireAtomicArtifactStore(artifacts).atomic(() => {
|
|
379
|
+
for (const oldParentId of oldParents) unlinkPlacement(artifacts, oldParentId, artifactId, context);
|
|
380
|
+
});
|
|
381
|
+
return binderTree(artifacts, scopes, { projectRoot, artifactIds: [artifactId] }).artifacts[0]!;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function updateBinder(
|
|
385
|
+
artifacts: ArtifactStore,
|
|
386
|
+
scopes: ArtifactScopeStore,
|
|
387
|
+
id: string,
|
|
388
|
+
input: UpdateBinderInput,
|
|
389
|
+
projectRoot: string | undefined,
|
|
390
|
+
context?: ArtifactEventContext,
|
|
391
|
+
): Artifact {
|
|
392
|
+
requireLocallyOwnedContent(requireVisibleBinder(artifacts, scopes, id, projectRoot));
|
|
393
|
+
if (input.title === undefined && input.labels === undefined) throw new Error("binder update requires title or labels");
|
|
394
|
+
const title = input.title === undefined ? undefined : validateBinderName(input.title);
|
|
395
|
+
assertLabelsBounds(input.labels);
|
|
396
|
+
if (title !== undefined) {
|
|
397
|
+
const current = binderTree(artifacts, scopes, { projectRoot }).nodes.find((node) => node.binder.id === id);
|
|
398
|
+
assertUniqueSibling(artifacts, scopes, title, current?.parentId, projectRoot, id);
|
|
399
|
+
}
|
|
400
|
+
return artifacts.updateContent(id, { title, labels: input.labels }, context)!;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function removeBinder(
|
|
404
|
+
artifacts: ArtifactStore & ArtifactTrashStore,
|
|
405
|
+
id: string,
|
|
406
|
+
context?: ArtifactEventContext,
|
|
407
|
+
reason?: string,
|
|
408
|
+
): ArtifactTrashRecord {
|
|
409
|
+
const binder = requireLocallyOwnedContent(requireBinder(artifacts, id));
|
|
410
|
+
const edges = edgesFor(artifacts, [id]);
|
|
411
|
+
if (
|
|
412
|
+
edges.some(
|
|
413
|
+
(edge) =>
|
|
414
|
+
(edge.from === id && edge.relation === BINDER_ORGANIZES_RELATION) || (edge.to === id && edge.relation === BINDER_FILED_IN_RELATION),
|
|
415
|
+
)
|
|
416
|
+
) {
|
|
417
|
+
throw new Error(`binder "${binder.title}" is not empty; move or unfile its contents first`);
|
|
418
|
+
}
|
|
419
|
+
requireAtomicArtifactStore(artifacts).atomic(() => {
|
|
420
|
+
for (const edge of edges) {
|
|
421
|
+
if (edge.to === id && edge.relation === BINDER_ORGANIZES_RELATION) unlinkPlacement(artifacts, edge.from, id, context);
|
|
422
|
+
if (edge.from === id && edge.relation === BINDER_FILED_IN_RELATION) unlinkPlacement(artifacts, edge.to, id, context);
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
return artifacts.trash(id, { reason, context });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function binderScope(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string): ArtifactScope {
|
|
429
|
+
requireBinder(artifacts, id);
|
|
430
|
+
return scopes.scope(id);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function setBinderGlobal(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string): ArtifactScope {
|
|
434
|
+
requireBinder(artifacts, id);
|
|
435
|
+
return scopes.setAll(id, "explicit");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function setBinderNone(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string): ArtifactScope {
|
|
439
|
+
requireBinder(artifacts, id);
|
|
440
|
+
return setArtifactScopeNone(scopes, id);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function addBinderProject(
|
|
444
|
+
artifacts: ArtifactStore,
|
|
445
|
+
scopes: ArtifactScopeStore,
|
|
446
|
+
registry: ProjectRegistryStore,
|
|
447
|
+
id: string,
|
|
448
|
+
project: string,
|
|
449
|
+
): ArtifactScope {
|
|
450
|
+
requireBinder(artifacts, id);
|
|
451
|
+
return addArtifactScopeProject(scopes, registry, id, project);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function removeBinderProject(
|
|
455
|
+
artifacts: ArtifactStore,
|
|
456
|
+
scopes: ArtifactScopeStore,
|
|
457
|
+
registry: ProjectRegistryStore,
|
|
458
|
+
id: string,
|
|
459
|
+
project: string,
|
|
460
|
+
): ArtifactScope {
|
|
461
|
+
requireBinder(artifacts, id);
|
|
462
|
+
return removeArtifactScopeProject(scopes, registry, id, project);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function replaceBinderProjects(
|
|
466
|
+
artifacts: ArtifactStore,
|
|
467
|
+
scopes: ArtifactScopeStore,
|
|
468
|
+
registry: ProjectRegistryStore,
|
|
469
|
+
id: string,
|
|
470
|
+
projects: readonly string[],
|
|
471
|
+
): ArtifactScope {
|
|
472
|
+
requireBinder(artifacts, id);
|
|
473
|
+
return replaceArtifactScopeProjects(scopes, registry, id, projects);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function addBinderGroup(
|
|
477
|
+
artifacts: ArtifactStore,
|
|
478
|
+
scopes: ArtifactScopeStore,
|
|
479
|
+
groups: ScopeGroupStore,
|
|
480
|
+
id: string,
|
|
481
|
+
group: string,
|
|
482
|
+
): ArtifactScope {
|
|
483
|
+
requireBinder(artifacts, id);
|
|
484
|
+
return addArtifactScopeGroup(scopes, groups, id, group);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function removeBinderGroup(
|
|
488
|
+
artifacts: ArtifactStore,
|
|
489
|
+
scopes: ArtifactScopeStore,
|
|
490
|
+
groups: ScopeGroupStore,
|
|
491
|
+
id: string,
|
|
492
|
+
group: string,
|
|
493
|
+
): ArtifactScope {
|
|
494
|
+
requireBinder(artifacts, id);
|
|
495
|
+
return removeArtifactScopeGroup(scopes, groups, id, group);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function replaceBinderGroups(
|
|
499
|
+
artifacts: ArtifactStore,
|
|
500
|
+
scopes: ArtifactScopeStore,
|
|
501
|
+
groups: ScopeGroupStore,
|
|
502
|
+
id: string,
|
|
503
|
+
groupRefs: readonly string[],
|
|
504
|
+
): ArtifactScope {
|
|
505
|
+
requireBinder(artifacts, id);
|
|
506
|
+
return replaceArtifactScopeGroups(scopes, groups, id, groupRefs);
|
|
507
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Artifact } from "../artifact/artifact.ts";
|
|
2
|
+
|
|
3
|
+
/** A Binder is an organizational artifact. Its edges never participate in Task/Playbook execution. */
|
|
4
|
+
export const BINDER_KIND = "binder";
|
|
5
|
+
export const BINDER_ORGANIZES_RELATION = "organizes";
|
|
6
|
+
export const BINDER_FILED_IN_RELATION = "filed_in";
|
|
7
|
+
|
|
8
|
+
export interface BinderNode {
|
|
9
|
+
binder: Artifact;
|
|
10
|
+
/** The one visible parent selected for this project-context tree. */
|
|
11
|
+
parentId?: string;
|
|
12
|
+
childIds: string[];
|
|
13
|
+
path: string;
|
|
14
|
+
/** Labels contributed by ancestors, ordered root to immediate parent. */
|
|
15
|
+
inheritedLabels: string[];
|
|
16
|
+
/** Ancestor labels followed by this Binder's own direct labels, de-duplicated. */
|
|
17
|
+
effectiveLabels: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BinderArtifactPlacement {
|
|
21
|
+
artifactId: string;
|
|
22
|
+
/** Omitted for an artifact at this project context's root. */
|
|
23
|
+
binderId?: string;
|
|
24
|
+
/** Labels contributed by the containing Binder and all of its ancestors. */
|
|
25
|
+
inheritedLabels: string[];
|
|
26
|
+
/** Inherited labels followed by the artifact's own direct labels, de-duplicated. */
|
|
27
|
+
effectiveLabels: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BinderTree {
|
|
31
|
+
nodes: BinderNode[];
|
|
32
|
+
rootIds: string[];
|
|
33
|
+
artifacts: BinderArtifactPlacement[];
|
|
34
|
+
}
|