@danypops/papyrus 0.17.2 → 0.19.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/src/ops.ts CHANGED
@@ -6,8 +6,10 @@ import { createRequire } from "node:module";
6
6
  import { exec } from "node:child_process";
7
7
  import type { Db } from "./db.ts";
8
8
  import { inTransaction } from "./db.ts";
9
- import { DEFAULT_STATUS_BY_KIND } from "./constants.ts";
9
+ import { ARTIFACT_TRASH_RETENTION_MS, DEFAULT_STATUS_BY_KIND } from "./constants.ts";
10
10
  import type { Artifact, ArtifactQuery, CreateArtifactInput, UpdateArtifactInput } from "./domain/artifact.ts";
11
+ import type { ArtifactTrashRecord } from "./domain/artifact-trash.ts";
12
+ export type { ArtifactTrashRecord } from "./domain/artifact-trash.ts";
11
13
  import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
12
14
  import {
13
15
  normalizeArtifactEventQuery,
@@ -288,6 +290,7 @@ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
288
290
  let sql = "SELECT * FROM artifacts";
289
291
  const conditions: string[] = [];
290
292
  const params: unknown[] = [];
293
+ if (!filter.includeTrashed) conditions.push("id NOT IN (SELECT artifact_id FROM artifact_trash)");
291
294
  if (filter.kind) { conditions.push("kind = ?"); params.push(filter.kind); }
292
295
  if (filter.status) { conditions.push("status = ?"); params.push(filter.status); }
293
296
  if (filter.statuses) {
@@ -318,6 +321,108 @@ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
318
321
  return rows.map(rowToArtifact);
319
322
  }
320
323
 
324
+ function rowToTrashRecord(row: Record<string, unknown>): ArtifactTrashRecord {
325
+ return {
326
+ artifactId: row["artifact_id"] as string,
327
+ trashedAt: row["trashed_at"] as string,
328
+ purgeAfter: row["purge_after"] as string,
329
+ ...(row["reason"] == null ? {} : { reason: row["reason"] as string }),
330
+ };
331
+ }
332
+
333
+ export function getArtifactTrash(db: Db, id: string): ArtifactTrashRecord | null {
334
+ const row = db.prepare("SELECT artifact_id, trashed_at, purge_after, reason FROM artifact_trash WHERE artifact_id = ?").get(id) as Record<string, unknown> | null;
335
+ return row ? rowToTrashRecord(row) : null;
336
+ }
337
+
338
+ export function listArtifactTrash(db: Db): ArtifactTrashRecord[] {
339
+ const rows = db.prepare("SELECT artifact_id, trashed_at, purge_after, reason FROM artifact_trash ORDER BY purge_after ASC").all() as Record<string, unknown>[];
340
+ return rows.map(rowToTrashRecord);
341
+ }
342
+
343
+ /**
344
+ * Moves an artifact to the trash: it becomes ineligible for purge_after ms (see
345
+ * ARTIFACT_TRASH_RETENTION_MS), immediately excluded from queryArtifacts by default, still
346
+ * directly reachable via getArtifact, and fully restorable via restoreArtifact until the
347
+ * daemon's periodic sweep (purgeDueArtifacts) actually deletes it. Re-removing an
348
+ * already-trashed artifact resets its clock rather than erroring -- the same "most recent
349
+ * intent wins" semantics as registerSessionIdentity's rotation.
350
+ *
351
+ * Refuses to trash a Task that is the live Task Focus in any scope: Focus is active,
352
+ * behavior-affecting state, and trashing out from under it would silently discard work a
353
+ * caller is not necessarily looking at right now. No other kind has an analogous "currently
354
+ * in use" signal to check.
355
+ */
356
+ export function trashArtifact(db: Db, id: string, options?: { reason?: string; now?: () => string; context?: ArtifactEventContext }): ArtifactTrashRecord {
357
+ const artifact = getArtifact(db, id);
358
+ if (!artifact) throw new Error(`artifact "${id}" not found`);
359
+ const focusedScope = db.prepare("SELECT scope FROM task_focus WHERE task_id = ? LIMIT 1").get(id) as { scope: string } | null;
360
+ if (focusedScope) throw new Error(`artifact "${id}" is the active Task Focus in scope "${focusedScope.scope}"; clear focus before removing it`);
361
+ const now = options?.now ?? (() => new Date().toISOString());
362
+ const trashedAt = now();
363
+ const purgeAfter = new Date(new Date(trashedAt).getTime() + ARTIFACT_TRASH_RETENTION_MS).toISOString();
364
+ const record: ArtifactTrashRecord = { artifactId: id, trashedAt, purgeAfter, ...(options?.reason ? { reason: options.reason } : {}) };
365
+ inTransaction(db, () => {
366
+ db.prepare(`
367
+ INSERT INTO artifact_trash (artifact_id, trashed_at, purge_after, reason) VALUES (?, ?, ?, ?)
368
+ ON CONFLICT (artifact_id) DO UPDATE SET trashed_at = excluded.trashed_at, purge_after = excluded.purge_after, reason = excluded.reason
369
+ `).run(record.artifactId, record.trashedAt, record.purgeAfter, record.reason ?? null);
370
+ appendArtifactEvent(db, { artifactId: id, type: "trashed", ...(options?.context ?? {}) });
371
+ });
372
+ return record;
373
+ }
374
+
375
+ /** Idempotent: restoring an artifact that is not currently trashed is a real no-op, not an error -- mirrors releaseSessionIdentity's idempotence. */
376
+ export function restoreArtifact(db: Db, id: string, context?: ArtifactEventContext): { restored: boolean } {
377
+ const wasTrashed = getArtifactTrash(db, id) !== null;
378
+ if (!wasTrashed) return { restored: false };
379
+ inTransaction(db, () => {
380
+ db.prepare("DELETE FROM artifact_trash WHERE artifact_id = ?").run(id);
381
+ appendArtifactEvent(db, { artifactId: id, type: "restored", ...(context ?? {}) });
382
+ });
383
+ return { restored: true };
384
+ }
385
+
386
+ /**
387
+ * Real, cascading, irreversible deletion of every artifact whose purge_after has passed.
388
+ * Never called with anything but the real current time in production -- see daemon.ts's
389
+ * periodic sweep; a directly-injected `now` exists only so tests can exercise this without
390
+ * waiting out ARTIFACT_TRASH_RETENTION_MS for real.
391
+ *
392
+ * Deletes, in FK-safe order, every row across every table that can reference artifacts(id)
393
+ * (see the grep-verified list in domain/artifact-trash.ts's design comment): edges (both
394
+ * directions), task_focus, task_scopes, task_views (by root_task_id), graph_projection_
395
+ * identities, artifact_scopes, then task_events and artifact_events -- the latter two
396
+ * succeed only because the artifact_trash row placed here by trashArtifact still exists
397
+ * with an elapsed purge_after, which is exactly what db.ts's task_events_no_delete /
398
+ * artifact_events_no_delete trigger carve-outs check themselves. Only THEN artifact_trash's
399
+ * own row (it is itself a child of artifacts via a real FK, so it must go before artifacts,
400
+ * but only after the event tables that depend on its continued presence), and artifacts
401
+ * itself last of all. One artifact at a time in its own transaction, so one failure never
402
+ * blocks any other due artifact.
403
+ */
404
+ export function purgeDueArtifacts(db: Db, now: () => string = () => new Date().toISOString()): number {
405
+ const nowIso = now();
406
+ const due = (db.prepare("SELECT artifact_id FROM artifact_trash WHERE purge_after <= ?").all(nowIso) as Array<{ artifact_id: string }>).map((row) => row.artifact_id);
407
+ let purged = 0;
408
+ for (const id of due) {
409
+ inTransaction(db, () => {
410
+ db.prepare("DELETE FROM edges WHERE from_id = ? OR to_id = ?").run(id, id);
411
+ db.prepare("DELETE FROM task_focus WHERE task_id = ?").run(id);
412
+ db.prepare("DELETE FROM task_scopes WHERE task_id = ?").run(id);
413
+ db.prepare("DELETE FROM task_views WHERE root_task_id = ?").run(id);
414
+ db.prepare("DELETE FROM graph_projection_identities WHERE artifact_id = ?").run(id);
415
+ db.prepare("DELETE FROM artifact_scopes WHERE artifact_id = ?").run(id);
416
+ db.prepare("DELETE FROM task_events WHERE task_id = ?").run(id);
417
+ db.prepare("DELETE FROM artifact_events WHERE artifact_id = ?").run(id);
418
+ db.prepare("DELETE FROM artifact_trash WHERE artifact_id = ?").run(id);
419
+ db.prepare("DELETE FROM artifacts WHERE id = ?").run(id);
420
+ });
421
+ purged += 1;
422
+ }
423
+ return purged;
424
+ }
425
+
321
426
  export function linkArtifacts(db: Db, fromId: string, relation: string, toId: string, context?: ArtifactEventContext): void {
322
427
  const fromArt = getArtifact(db, fromId);
323
428
  const toArt = getArtifact(db, toId);
@@ -9,6 +9,7 @@ import type {
9
9
  UpdateArtifactInput,
10
10
  } from "../domain/artifact.ts";
11
11
  import type { ArtifactEventContext, ArtifactEventPage, ArtifactEventQuery } from "../domain/artifact-event.ts";
12
+ import type { ArtifactTrashRecord } from "../domain/artifact-trash.ts";
12
13
 
13
14
  export interface ArtifactStore {
14
15
  create(input: CreateArtifactInput, context?: ArtifactEventContext): Artifact;
@@ -23,4 +24,12 @@ export interface ArtifactStore {
23
24
  relationships(filter?: RelationshipQuery): ArtifactEdge[];
24
25
  /** Bounded query over the generic mutation event log shared by every kind. */
25
26
  events(query: ArtifactEventQuery): ArtifactEventPage;
27
+ /** See domain/artifact-trash.ts. Moves an artifact to the trash; throws if it does not exist or is the live Task Focus in any scope. */
28
+ trash(id: string, options?: { reason?: string; context?: ArtifactEventContext }): ArtifactTrashRecord;
29
+ /** Idempotent: restoring an artifact that is not currently trashed is a real no-op. */
30
+ restore(id: string, context?: ArtifactEventContext): { restored: boolean };
31
+ trashStatus(id: string): ArtifactTrashRecord | null;
32
+ listTrash(): ArtifactTrashRecord[];
33
+ /** Real, cascading, irreversible deletion of every artifact past its purge deadline; returns how many were purged. */
34
+ purgeDueTrash(): number;
26
35
  }
@@ -0,0 +1,8 @@
1
+ import type { AppendDiscussionRound, DiscussionRound, DiscussionRoundQuery } from "../domain/discussion.ts";
2
+
3
+ /** Persistence port for a Discussion's append-only rounds (see domain/discussion.ts). */
4
+ export interface DiscussionRoundStore {
5
+ append(round: AppendDiscussionRound, occurredAt: string): DiscussionRound;
6
+ list(query: DiscussionRoundQuery): DiscussionRound[];
7
+ count(discussionId: string): number;
8
+ }
package/src/service.ts CHANGED
@@ -34,7 +34,10 @@ import { notesOperations, NOTES_OPERATION_NAMES } from "./modules/notes.ts";
34
34
  import { rulesOperations, RULES_OPERATION_NAMES } from "./modules/rules.ts";
35
35
  import { skillsOperations, SKILLS_OPERATION_NAMES } from "./modules/skills.ts";
36
36
  import { sessionIdentityOperations, SESSION_IDENTITY_OPERATION_NAMES } from "./modules/session-identity.ts";
37
+ import { discussOperations, DISCUSS_OPERATION_NAMES } from "./modules/discuss.ts";
37
38
  import { tasksOperations, TASKS_OPERATION_NAMES } from "./modules/tasks.ts";
39
+ import { Discussions } from "./discussion-service.ts";
40
+ import { SQLiteDiscussionRoundStore } from "./adapters/sqlite-discussion-round-store.ts";
38
41
 
39
42
  /**
40
43
  * Operations with no registered module: the generic, cross-cutting kernel surface
@@ -49,6 +52,7 @@ import { tasksOperations, TASKS_OPERATION_NAMES } from "./modules/tasks.ts";
49
52
  */
50
53
  const COMPOSITION_ROOT_OPERATION_NAMES = [
51
54
  "system.migrate", "artifact.create", "artifact.query", "artifact.show",
55
+ "artifact.remove", "artifact.restore", "artifact.trash_status", "artifact.trash_list",
52
56
  "graph.link", "graph.unlink", "graph.tree", "graph.status", "graph.history", "gates.run",
53
57
  "rules.injectable", "skills.instantiate",
54
58
  ] as const;
@@ -72,6 +76,7 @@ export const EXPECTED_OPERATION_NAMES = [
72
76
  ...GRAPH_PROJECTION_OPERATION_NAMES,
73
77
  ...LOGS_OPERATION_NAMES,
74
78
  ...SESSION_IDENTITY_OPERATION_NAMES,
79
+ ...DISCUSS_OPERATION_NAMES,
75
80
  ] as const;
76
81
 
77
82
  export type OperationName = typeof EXPECTED_OPERATION_NAMES[number];
@@ -173,6 +178,8 @@ export interface PapyrusService {
173
178
  optimize(): void;
174
179
  /** Time-based Task Focus reclamation (see Tasks.reapStaleFocus); returns how many rows were removed, for daemon logging. */
175
180
  reapStaleFocus(): number;
181
+ /** Real, cascading deletion of every artifact past its trash purge deadline (see domain/artifact-trash.ts); returns how many were purged, for daemon logging. */
182
+ purgeDueTrash(): number;
176
183
  close(): void;
177
184
  }
178
185
 
@@ -241,6 +248,10 @@ function handlers(
241
248
  depth: optionalNumber(input, "depth"),
242
249
  maxNodes: optionalNumber(input, "max_nodes") ?? optionalNumber(input, "maxNodes"),
243
250
  }),
251
+ "artifact.remove": (input) => artifacts.trash(string(input, "id"), { reason: optionalString(input, "reason"), context: eventContext(input) }),
252
+ "artifact.restore": (input) => artifacts.restore(string(input, "id"), eventContext(input)),
253
+ "artifact.trash_status": (input) => artifacts.trashStatus(string(input, "id")),
254
+ "artifact.trash_list": () => artifacts.listTrash(),
244
255
  "graph.link": (input) => {
245
256
  const from = string(input, "from");
246
257
  const relation = string(input, "relation");
@@ -376,6 +387,16 @@ function handlers(
376
387
  "logs.query": forwardToModule("logs.query"),
377
388
  "session.register": forwardToModule("session.register"),
378
389
  "session.release": forwardToModule("session.release"),
390
+ "discuss.open": forwardToModule("discuss.open"),
391
+ "discuss.reply": forwardToModule("discuss.reply"),
392
+ "discuss.defer": forwardToModule("discuss.defer"),
393
+ "discuss.resume": forwardToModule("discuss.resume"),
394
+ "discuss.settle": forwardToModule("discuss.settle"),
395
+ "discuss.block": forwardToModule("discuss.block"),
396
+ "discuss.unblock": forwardToModule("discuss.unblock"),
397
+ "discuss.show": forwardToModule("discuss.show"),
398
+ "discuss.rounds": forwardToModule("discuss.rounds"),
399
+ "discuss.list": forwardToModule("discuss.list"),
379
400
  };
380
401
  }
381
402
 
@@ -392,11 +413,13 @@ export function createPapyrusService(path: string): PapyrusService {
392
413
  const artifactScopes = new SQLiteArtifactScopeStore(db);
393
414
  const logs = new Logs(new SQLiteLogStore(db));
394
415
  const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
416
+ const discussions = new Discussions(artifacts, new SQLiteDiscussionRoundStore(db));
395
417
  const authority = createAuthorityRegistry();
396
418
  const moduleRegistry = new OperationRegistry();
397
419
  moduleRegistry.registerAll(notesOperations(notes));
398
420
  moduleRegistry.registerAll(logsOperations(logs));
399
421
  moduleRegistry.registerAll(sessionIdentityOperations(sessionIdentity));
422
+ moduleRegistry.registerAll(discussOperations(discussions));
400
423
  moduleRegistry.registerAll(tasksOperations(tasks, artifacts, sessionIdentity));
401
424
  moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority));
402
425
  moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));
@@ -421,6 +444,7 @@ export function createPapyrusService(path: string): PapyrusService {
421
444
  checkpoint: () => { db.exec("PRAGMA wal_checkpoint(PASSIVE)"); },
422
445
  optimize: () => { db.exec("PRAGMA optimize"); },
423
446
  reapStaleFocus: () => tasks.reapStaleFocus(),
447
+ purgeDueTrash: () => artifacts.purgeDueTrash(),
424
448
  close: () => {
425
449
  db.exec("PRAGMA optimize");
426
450
  db.close();
@@ -11,6 +11,7 @@ import {
11
11
  } from "./constants.ts";
12
12
  import type { Artifact } from "./domain/artifact.ts";
13
13
  import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
14
+ import { isDiscussionArtifact, readDiscussionExtra } from "./domain/discussion.ts";
14
15
  import type { Gate, GateResult } from "./domain/gate.ts";
15
16
  import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
16
17
  import { normalizeProjectRoot, taskScopeLabel, type TaskScopeSource, type TaskViewMode, type TaskViewSelection } from "./domain/task-scope.ts";
@@ -419,6 +420,7 @@ export class Tasks {
419
420
 
420
421
  complete(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): TaskCompletion {
421
422
  const task = this.requireReview(id);
423
+ this.requireNotBlocked(id);
422
424
  const attemptId = crypto.randomUUID();
423
425
  this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
424
426
  const checklist = this.reviewChecklist(task);
@@ -428,6 +430,7 @@ export class Tasks {
428
430
 
429
431
  async completeAsync(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): Promise<TaskCompletion> {
430
432
  const task = this.requireReview(id);
433
+ this.requireNotBlocked(id);
431
434
  const attemptId = crypto.randomUUID();
432
435
  this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
433
436
  const checklist = this.reviewChecklist(task);
@@ -678,4 +681,28 @@ export class Tasks {
678
681
  if (task.status !== "review") throw new Error(`cannot complete task from ${task.status}`);
679
682
  return task;
680
683
  }
684
+
685
+ /**
686
+ * Discuss's forcing behavior (see domain/discussion.ts): an active Discussion doc that
687
+ * `blocks` this task refuses its completion until settled or deferred. A discussion whose
688
+ * extra.discussion shape is missing or corrupt is treated as non-blocking rather than
689
+ * crashing completion -- the same fail-open posture Task Focus's opt-in armor uses for an
690
+ * unrecognized shape.
691
+ */
692
+ private blockingDiscussions(id: string): Artifact[] {
693
+ return this.artifacts.relationships({ artifactIds: [id] })
694
+ .filter((edge) => edge.relation === "blocks" && edge.to === id)
695
+ .map((edge) => this.artifacts.get(edge.from))
696
+ .filter((source): source is Artifact => source !== null && isDiscussionArtifact(source))
697
+ .filter((discussion) => {
698
+ try { return readDiscussionExtra(discussion.extra).state === "active"; } catch { return false; }
699
+ });
700
+ }
701
+
702
+ private requireNotBlocked(id: string): void {
703
+ const blockers = this.blockingDiscussions(id);
704
+ if (blockers.length > 0) {
705
+ throw new Error(`task "${id}" is blocked by ${blockers.length} active Discussion(s): ${blockers.map((discussion) => discussion.id).join(", ")}`);
706
+ }
707
+ }
681
708
  }