@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/src/service.ts CHANGED
@@ -12,6 +12,7 @@ import type { ArtifactTrashStore } from "./artifact/artifact-trash-store.ts";
12
12
  import { SQLiteArtifactScopeStore } from "./artifact/sqlite-artifact-scope-store.ts";
13
13
  import { SQLiteArtifactStore } from "./artifact/sqlite-artifact-store.ts";
14
14
  import { type AuthorityClaim, AuthorityRegistry, AuthorizedArtifactWriter } from "./authority-registry.ts";
15
+ import { BINDER_FILED_IN_RELATION, BINDER_KIND, BINDER_ORGANIZES_RELATION } from "./binder/binder.ts";
15
16
  import { SERVICE_MAX_BODY_BYTES, SQLITE_SCHEMA_VERSION } from "./constants.ts";
16
17
  import { migrateDb, openDb, schemaVersion } from "./db.ts";
17
18
  import { Discussions } from "./discussion/discussion-service.ts";
@@ -25,6 +26,7 @@ import { logEvent } from "./log/log.ts";
25
26
  import { Logs } from "./log/log-service.ts";
26
27
  import { SQLiteLogStore } from "./log/sqlite-log-store.ts";
27
28
  import { OperationRegistry } from "./module-registry.ts";
29
+ import { BINDERS_OPERATION_NAMES, bindersOperations } from "./modules/binders.ts";
28
30
  import { DISCUSS_OPERATION_NAMES, discussOperations } from "./modules/discuss.ts";
29
31
  import { DOCS_OPERATION_NAMES, docsOperations } from "./modules/docs.ts";
30
32
  import { GRAPH_PROJECTION_OPERATION_NAMES, graphProjectionOperations } from "./modules/graph-projection.ts";
@@ -99,6 +101,7 @@ const COMPOSITION_ROOT_OPERATION_NAMES = [
99
101
  export const EXPECTED_OPERATION_NAMES = [
100
102
  ...COMPOSITION_ROOT_OPERATION_NAMES,
101
103
  ...TASKS_OPERATION_NAMES,
104
+ ...BINDERS_OPERATION_NAMES,
102
105
  ...DOCS_OPERATION_NAMES,
103
106
  ...NOTES_OPERATION_NAMES,
104
107
  ...RULES_OPERATION_NAMES,
@@ -163,6 +166,13 @@ const tasksAuthorityClaim: AuthorityClaim = {
163
166
  denyMessage: () => "task lifecycle changes require a tasks.* operation so history and review invariants are preserved",
164
167
  };
165
168
 
169
+ const bindersAuthorityClaim: AuthorityClaim = {
170
+ owner: "binders",
171
+ matchesArtifact: (kind) => kind === BINDER_KIND,
172
+ matchesRelation: (relation) => relation === BINDER_ORGANIZES_RELATION || relation === BINDER_FILED_IN_RELATION,
173
+ denyMessage: () => "Binder creation, hierarchy, and filing require a binders.* operation so path and cycle invariants are preserved",
174
+ };
175
+
166
176
  /**
167
177
  * The same status-bypass protection Tasks and Notes already have, extended to every other kind
168
178
  * with its own validated transition set (Doc's draft/active/archived, Rule/Playbook's
@@ -183,6 +193,7 @@ export function createAuthorityRegistry(): AuthorityRegistry {
183
193
  authority.claimAll([
184
194
  notesAuthorityClaim,
185
195
  tasksAuthorityClaim,
196
+ bindersAuthorityClaim,
186
197
  lifecycleAuthorityClaim("docs", "doc"),
187
198
  lifecycleAuthorityClaim("rules", "rule"),
188
199
  lifecycleAuthorityClaim("playbooks", "playbook"),
@@ -320,10 +331,20 @@ function handlers(
320
331
  depth: optionalNumber(input, "depth"),
321
332
  maxNodes: optionalNumber(input, "max_nodes") ?? optionalNumber(input, "maxNodes"),
322
333
  }),
323
- "artifact.remove": (input) =>
324
- artifacts.trash(string(input, "id"), { reason: optionalString(input, "reason"), context: eventContext(input) }),
325
- "artifact.remove_subtree": (input) =>
326
- removeArtifactSubtree(artifacts, string(input, "id"), { reason: optionalString(input, "reason"), context: eventContext(input) }),
334
+ "artifact.remove": (input) => {
335
+ const id = string(input, "id");
336
+ if (artifacts.get(id)?.kind === BINDER_KIND) {
337
+ throw new Error("Binder removal requires binders.remove so a non-empty directory cannot be orphaned");
338
+ }
339
+ return artifacts.trash(id, { reason: optionalString(input, "reason"), context: eventContext(input) });
340
+ },
341
+ "artifact.remove_subtree": (input) => {
342
+ const id = string(input, "id");
343
+ if (artifacts.get(id)?.kind === BINDER_KIND) {
344
+ throw new Error("Binder removal requires binders.remove so a non-empty directory cannot be orphaned");
345
+ }
346
+ return removeArtifactSubtree(artifacts, id, { reason: optionalString(input, "reason"), context: eventContext(input) });
347
+ },
327
348
  "artifact.restore": (input) => artifacts.restore(string(input, "id"), eventContext(input)),
328
349
  "artifact.trash_status": (input) => artifacts.trashStatus(string(input, "id")),
329
350
  "artifact.trash_list": () => artifacts.listTrash(),
@@ -388,6 +409,7 @@ function handlers(
388
409
  "tasks.create": forwardToModule("tasks.create"),
389
410
  "tasks.update": forwardToModule("tasks.update"),
390
411
  "tasks.list": forwardToModule("tasks.list"),
412
+ "tasks.list_page": forwardToModule("tasks.list_page"),
391
413
  "tasks.graph": forwardToModule("tasks.graph"),
392
414
  "tasks.plan": forwardToModule("tasks.plan"),
393
415
  "tasks.show": forwardToModule("tasks.show"),
@@ -428,6 +450,24 @@ function handlers(
428
450
  "tasks.reap_stale_leases": forwardToModule("tasks.reap_stale_leases"),
429
451
  "tasks.event_feed": forwardToModule("tasks.event_feed"),
430
452
  "tasks.reap_stale_focus": forwardToModule("tasks.reap_stale_focus"),
453
+ "binders.create": forwardToModule("binders.create"),
454
+ "binders.list": forwardToModule("binders.list"),
455
+ "binders.tree": forwardToModule("binders.tree"),
456
+ "binders.show": forwardToModule("binders.show"),
457
+ "binders.update": forwardToModule("binders.update"),
458
+ "binders.move": forwardToModule("binders.move"),
459
+ "binders.file": forwardToModule("binders.file"),
460
+ "binders.unfile": forwardToModule("binders.unfile"),
461
+ "binders.remove": forwardToModule("binders.remove"),
462
+ "binders.scope": forwardToModule("binders.scope"),
463
+ "binders.set_global": forwardToModule("binders.set_global"),
464
+ "binders.set_none": forwardToModule("binders.set_none"),
465
+ "binders.add_project": forwardToModule("binders.add_project"),
466
+ "binders.remove_project": forwardToModule("binders.remove_project"),
467
+ "binders.replace_projects": forwardToModule("binders.replace_projects"),
468
+ "binders.add_group": forwardToModule("binders.add_group"),
469
+ "binders.remove_group": forwardToModule("binders.remove_group"),
470
+ "binders.replace_groups": forwardToModule("binders.replace_groups"),
431
471
  "docs.create": forwardToModule("docs.create"),
432
472
  "docs.list": forwardToModule("docs.list"),
433
473
  "docs.show": forwardToModule("docs.show"),
@@ -448,6 +488,7 @@ function handlers(
448
488
  "docs.update": forwardToModule("docs.update"),
449
489
  "notes.capture": forwardToModule("notes.capture"),
450
490
  "notes.list": forwardToModule("notes.list"),
491
+ "notes.list_page": forwardToModule("notes.list_page"),
451
492
  "notes.show": forwardToModule("notes.show"),
452
493
  "notes.history": forwardToModule("notes.history"),
453
494
  "notes.consume": forwardToModule("notes.consume"),
@@ -563,6 +604,7 @@ export function createPapyrusService(path: string): PapyrusService {
563
604
  moduleRegistry.registerAll(sessionIdentityOperations(sessionIdentity));
564
605
  moduleRegistry.registerAll(discussOperations(discussions));
565
606
  moduleRegistry.registerAll(tasksOperations(tasks, artifacts, sessionIdentity));
607
+ moduleRegistry.registerAll(bindersOperations(artifacts, artifactScopes, projectRegistry, scopeGroups));
566
608
  moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority, projectRegistry, scopeGroups));
567
609
  moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes, projectRegistry, scopeGroups));
568
610
  moduleRegistry.registerAll(
@@ -11,6 +11,8 @@ import {
11
11
  TASK_EXECUTION_MAX_NODES,
12
12
  TASK_LABEL_MAX_COUNT,
13
13
  TASK_LABEL_MAX_LENGTH,
14
+ TASK_LIST_PAGE_DEFAULT_LIMIT,
15
+ TASK_LIST_PAGE_MAX_LIMIT,
14
16
  TASK_SCOPE_MAX_TASKS,
15
17
  TASK_TITLE_MAX_LENGTH,
16
18
  } from "../constants.ts";
@@ -98,6 +100,15 @@ export interface TaskFilter {
98
100
  labels?: string[];
99
101
  }
100
102
 
103
+ export interface TaskPageFilter extends TaskFilter {
104
+ cursor?: string;
105
+ }
106
+
107
+ export interface TaskPage {
108
+ items: Artifact[];
109
+ nextCursor?: string;
110
+ }
111
+
101
112
  export type TaskStatus = TaskLifecycleStatus;
102
113
 
103
114
  export interface TaskMutationMetadata {
@@ -207,6 +218,45 @@ function canonicalJson(value: unknown): string {
207
218
  return JSON.stringify(value) ?? "null";
208
219
  }
209
220
 
221
+ interface TaskPageCursor {
222
+ v: 1;
223
+ createdAt: string;
224
+ id: string;
225
+ filterHash: string;
226
+ }
227
+
228
+ function taskPageFilterHash(filter: TaskFilter, selection: TaskViewSelection): string {
229
+ return createHash("sha256")
230
+ .update(
231
+ canonicalJson({
232
+ status: filter.status,
233
+ text: filter.text,
234
+ labels: [...(filter.labels ?? [])].sort(),
235
+ mode: selection.mode,
236
+ projectRoot: selection.projectRoot,
237
+ rootTaskId: selection.rootTaskId,
238
+ }),
239
+ )
240
+ .digest("base64url");
241
+ }
242
+
243
+ function encodeTaskPageCursor(task: Artifact, filterHash: string): string {
244
+ return Buffer.from(JSON.stringify({ v: 1, createdAt: task.created_at, id: task.id, filterHash } satisfies TaskPageCursor)).toString(
245
+ "base64url",
246
+ );
247
+ }
248
+
249
+ function decodeTaskPageCursor(cursor: string | undefined, filterHash: string): TaskPageCursor | undefined {
250
+ if (cursor === undefined) return undefined;
251
+ try {
252
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Partial<TaskPageCursor>;
253
+ if (parsed.v !== 1 || !parsed.createdAt || !parsed.id || parsed.filterHash !== filterHash) throw new Error("invalid cursor");
254
+ return parsed as TaskPageCursor;
255
+ } catch {
256
+ throw new Error("task page cursor is invalid or does not match the requested filters");
257
+ }
258
+ }
259
+
210
260
  export class Tasks {
211
261
  constructor(
212
262
  private readonly artifacts: ArtifactStore,
@@ -428,6 +478,52 @@ export class Tasks {
428
478
  .slice(0, limit);
429
479
  }
430
480
 
481
+ /** Stable, cursor-paged Task inventory. Creation order is immutable, so content updates cannot move an item between pages. */
482
+ listPage(filter: TaskPageFilter = {}): TaskPage {
483
+ const selection = this.projectScope.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
484
+ const limit = filter.limit ?? TASK_LIST_PAGE_DEFAULT_LIMIT;
485
+ if (!Number.isInteger(limit) || limit < 1 || limit > TASK_LIST_PAGE_MAX_LIMIT) {
486
+ throw new Error(`task page limit must be between 1 and ${TASK_LIST_PAGE_MAX_LIMIT}`);
487
+ }
488
+ const filterHash = taskPageFilterHash(filter, selection);
489
+ const cursor = decodeTaskPageCursor(filter.cursor, filterHash);
490
+ let candidates: Artifact[];
491
+ if (selection.mode === "all") {
492
+ candidates = this.artifacts.query({
493
+ kind: "task",
494
+ excludeSubtype: DISCUSSION_SUBTYPE,
495
+ status: filter.status,
496
+ text: filter.text,
497
+ labels: filter.labels,
498
+ order: "created_desc",
499
+ ...(cursor ? { after: { createdAt: cursor.createdAt, id: cursor.id } } : {}),
500
+ limit: limit + 1,
501
+ });
502
+ } else {
503
+ const ids = this.scopes.taskIds(selection.projectRoot, TASK_SCOPE_MAX_TASKS + 1);
504
+ if (ids.length > TASK_SCOPE_MAX_TASKS) throw new Error(`task project scope exceeds ${TASK_SCOPE_MAX_TASKS} tasks`);
505
+ const selectedIds = selection.mode === "graph" ? this.descendantIds(selection.rootTaskId!, ids) : new Set(ids);
506
+ const text = filter.text?.toLowerCase();
507
+ const labels = filter.labels ?? [];
508
+ candidates = this.artifacts
509
+ .query({ kind: "task", excludeSubtype: DISCUSSION_SUBTYPE, ids: [...selectedIds] })
510
+ .filter((task) => filter.status === undefined || task.status === filter.status)
511
+ .filter((task) => text === undefined || task.title.toLowerCase().includes(text) || task.body.toLowerCase().includes(text))
512
+ .filter((task) => labels.every((label) => task.labels.includes(label)))
513
+ .sort((left, right) => right.created_at.localeCompare(left.created_at) || left.id.localeCompare(right.id))
514
+ .filter(
515
+ (task) =>
516
+ cursor === undefined || task.created_at < cursor.createdAt || (task.created_at === cursor.createdAt && task.id > cursor.id),
517
+ )
518
+ .slice(0, limit + 1);
519
+ }
520
+ const items = candidates.slice(0, limit);
521
+ return {
522
+ items,
523
+ ...(candidates.length > limit && items.length > 0 ? { nextCursor: encodeTaskPageCursor(items.at(-1)!, filterHash) } : {}),
524
+ };
525
+ }
526
+
431
527
  scopeSelection(projectRoot?: string, mode?: TaskViewMode, rootTaskId?: string): TaskViewSelection {
432
528
  return this.projectScope.scopeSelection(projectRoot, mode, rootTaskId);
433
529
  }