@danypops/papyrus 0.17.2 → 0.18.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/README.md +6 -0
- package/extension/src/domain-tools.ts +35 -6
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +37 -1
- package/src/cli.ts +35 -1
- package/src/constants.ts +10 -1
- package/src/daemon.ts +9 -0
- package/src/db.ts +40 -4
- package/src/domain/artifact-event.ts +1 -1
- package/src/domain/artifact-trash.ts +29 -0
- package/src/domain/artifact.ts +2 -0
- package/src/ops.ts +106 -1
- package/src/ports/artifact-store.ts +9 -0
- package/src/service.ts +8 -0
package/README.md
CHANGED
|
@@ -100,6 +100,12 @@ papyrus skills run <skill-id> \
|
|
|
100
100
|
|
|
101
101
|
The existing `artifact-template` skill subtype remains a compatibility mechanism for one-artifact templates with metadata `{targetKind, defaults, required}`. Instantiate it through `papyrus_create` with `template_id`; defaults merge recursively, explicit arrays replace defaults, required paths such as `extra.owner` are validated, and target-kind mismatches are rejected.
|
|
102
102
|
|
|
103
|
+
### Removing an artifact
|
|
104
|
+
|
|
105
|
+
Artifacts are never hard-deleted on request: every artifact gets a permanent, immutable `created` row in the mutation event log the moment it exists, so removal is a real, time-gated trash rather than a status flip. `remove` (any of the `tasks`/`docs`/`rules`/`skills` domain tools, or `papyrus artifact remove <id> [--reason <text>]`) moves an artifact to the trash: it is immediately excluded from every list/query, still directly reachable by id, and fully recoverable via `restore` until its purge deadline (30 days later) passes. `remove` refuses a Task that is the live Task Focus in any scope.
|
|
106
|
+
|
|
107
|
+
Once the deadline passes, the daemon's periodic sweep performs a real, cascading, irreversible deletion — the one deliberate, narrow exception to Papyrus's otherwise-absolute append-only history, enforced by the database itself (not merely application code) via a trigger condition checked at delete time.
|
|
108
|
+
|
|
103
109
|
## Tools
|
|
104
110
|
|
|
105
111
|
The `papyrus_*` tools are the low-level graph-store API:
|
|
@@ -31,6 +31,26 @@ function artifactLine(artifact: Artifact): string {
|
|
|
31
31
|
return `${artifact.id} [${artifact.status}] ${artifact.title}`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Shared "remove"/"restore" dispatch for every domain tool (tasks/docs/rules/skills) --
|
|
36
|
+
* artifact.remove/restore are kind-agnostic composition-root operations (see service.ts),
|
|
37
|
+
* not owned by any one domain module, so every domain tool exposes the same two actions
|
|
38
|
+
* over the same two operations rather than reinventing trash semantics four times.
|
|
39
|
+
* Returns null when action is neither, so callers fall through to their own dispatch.
|
|
40
|
+
*/
|
|
41
|
+
async function handleArtifactRemoveRestore(action: unknown, params: Record<string, unknown>): Promise<ReturnType<typeof text> | null> {
|
|
42
|
+
if (action === "remove") {
|
|
43
|
+
const record = await callService<Record<string, unknown>, { artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }>("artifact.remove", params);
|
|
44
|
+
return text(`Trashed ${record.artifactId}, eligible for purge at ${record.purgeAfter}.`, createPreviewDetails("artifact.remove", "Trashed", record.artifactId));
|
|
45
|
+
}
|
|
46
|
+
if (action === "restore") {
|
|
47
|
+
const outcome = await callService<Record<string, unknown>, { restored: boolean }>("artifact.restore", params);
|
|
48
|
+
const output = outcome.restored ? `Restored ${params["id"]}.` : `${params["id"]} was not trashed.`;
|
|
49
|
+
return text(output, createPreviewDetails("artifact.restore", "Restored", output));
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
const proofReferenceSchema = Type.Object({
|
|
35
55
|
type: Type.Union(PROOF_TYPES.map((type) => Type.Literal(type))),
|
|
36
56
|
target: Type.String(),
|
|
@@ -45,7 +65,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
45
65
|
pi.registerTool({
|
|
46
66
|
name: "tasks",
|
|
47
67
|
label: "Tasks",
|
|
48
|
-
description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, undepend, contain, uncontain. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. Prefer this over low-level papyrus_* tools for task work.",
|
|
68
|
+
description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, undepend, contain, uncontain, remove, restore. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. remove moves a Task to a time-gated trash (restorable via restore until the purge deadline; refuses if it is the live Task Focus). Prefer this over low-level papyrus_* tools for task work.",
|
|
49
69
|
parameters: Type.Object({
|
|
50
70
|
action: Type.String(),
|
|
51
71
|
id: Type.Optional(Type.String()),
|
|
@@ -181,6 +201,8 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
181
201
|
}))),
|
|
182
202
|
);
|
|
183
203
|
}
|
|
204
|
+
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
205
|
+
if (trashResult) return trashResult;
|
|
184
206
|
const operations = {
|
|
185
207
|
focus: "tasks.focus",
|
|
186
208
|
start: "tasks.start",
|
|
@@ -257,7 +279,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
257
279
|
pi.registerTool({
|
|
258
280
|
name: "docs",
|
|
259
281
|
label: "Documents",
|
|
260
|
-
description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link, assign_project. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. Prefer this over low-level papyrus_* tools for document work.",
|
|
282
|
+
description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link, assign_project, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. remove moves a Doc to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. Prefer this over low-level papyrus_* tools for document work.",
|
|
261
283
|
parameters: Type.Object({
|
|
262
284
|
action: Type.String(),
|
|
263
285
|
id: Type.Optional(Type.String()),
|
|
@@ -273,6 +295,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
273
295
|
relation: Type.Optional(Type.String()),
|
|
274
296
|
target_id: Type.Optional(Type.String()),
|
|
275
297
|
project_root: Type.Optional(Type.String()),
|
|
298
|
+
reason: Type.Optional(Type.String()),
|
|
276
299
|
}),
|
|
277
300
|
renderCall(args, theme) { return renderPapyrusToolCall("Documents", args, theme); },
|
|
278
301
|
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
@@ -291,6 +314,8 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
291
314
|
const artifact = await callService<Record<string, unknown>, Artifact>("docs.show", params);
|
|
292
315
|
return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("docs.show", artifact));
|
|
293
316
|
}
|
|
317
|
+
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
318
|
+
if (trashResult) return trashResult;
|
|
294
319
|
const operations = { activate: "docs.activate", archive: "docs.archive", reopen: "docs.reopen", link: "docs.link", assign_project: "docs.assign_project" } as const;
|
|
295
320
|
const operation = operations[action as keyof typeof operations];
|
|
296
321
|
if (!operation) throw new Error(`unknown docs action: ${action}`);
|
|
@@ -305,14 +330,14 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
305
330
|
pi.registerTool({
|
|
306
331
|
name: "rules",
|
|
307
332
|
label: "Rules",
|
|
308
|
-
description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate, assign_project. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. Active rules inject into the agent system prompt.",
|
|
333
|
+
description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate, assign_project, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. Active rules inject into the agent system prompt. remove moves a Rule to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline.",
|
|
309
334
|
parameters: Type.Object({
|
|
310
335
|
action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
311
336
|
body: Type.Optional(Type.String()), condition: Type.Optional(Type.String()), rule_action: Type.Optional(Type.String()),
|
|
312
337
|
severity: Type.Optional(Type.String()), labels: Type.Optional(Type.Array(Type.String())),
|
|
313
338
|
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
|
|
314
339
|
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), task_id: Type.Optional(Type.String()),
|
|
315
|
-
project_root: Type.Optional(Type.String()),
|
|
340
|
+
project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
|
|
316
341
|
}),
|
|
317
342
|
renderCall(args, theme) { return renderPapyrusToolCall("Rules", args, theme); },
|
|
318
343
|
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
@@ -331,6 +356,8 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
331
356
|
const preview = await callService<Record<string, unknown>, string>("rules.preview", params);
|
|
332
357
|
return text(preview, createPreviewDetails("rules.preview", "Rule preview", preview));
|
|
333
358
|
}
|
|
359
|
+
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
360
|
+
if (trashResult) return trashResult;
|
|
334
361
|
const operations = { show: "rules.show", enable: "rules.enable", disable: "rules.disable", gate: "rules.gate", assign_project: "rules.assign_project" } as const;
|
|
335
362
|
const operation = operations[action as keyof typeof operations];
|
|
336
363
|
if (!operation) throw new Error(`unknown rules action: ${action}`);
|
|
@@ -345,7 +372,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
345
372
|
pi.registerTool({
|
|
346
373
|
name: "skills",
|
|
347
374
|
label: "Skills",
|
|
348
|
-
description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, run, enable, disable, instantiate, assign_project. run validates arguments and atomically creates one scoped workflow run. project_root is optional at creation (omitted = unscoped) for create/create_template; assign_project reassigns it later, or unscopes when project_root is omitted.",
|
|
375
|
+
description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, run, enable, disable, instantiate, assign_project, remove, restore. run validates arguments and atomically creates one scoped workflow run. project_root is optional at creation (omitted = unscoped) for create/create_template; assign_project reassigns it later, or unscopes when project_root is omitted. remove moves a Skill to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline.",
|
|
349
376
|
parameters: Type.Object({
|
|
350
377
|
action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
351
378
|
body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
|
|
@@ -356,7 +383,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
356
383
|
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
|
|
357
384
|
target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
358
385
|
required: Type.Optional(Type.Array(Type.String())), kind: Type.Optional(Type.String()), subtype: Type.Optional(Type.String()),
|
|
359
|
-
project_root: Type.Optional(Type.String()),
|
|
386
|
+
project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
|
|
360
387
|
}),
|
|
361
388
|
renderCall(args, theme) { return renderPapyrusToolCall("Skills", args, theme); },
|
|
362
389
|
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
@@ -393,6 +420,8 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
393
420
|
roots: run.rootTaskIds,
|
|
394
421
|
}));
|
|
395
422
|
}
|
|
423
|
+
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
424
|
+
if (trashResult) return trashResult;
|
|
396
425
|
const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate", assign_project: "skills.assign_project" } as const;
|
|
397
426
|
const operation = operations[action as keyof typeof operations];
|
|
398
427
|
if (!operation) throw new Error(`unknown skills action: ${action}`);
|
package/package.json
CHANGED
|
@@ -12,7 +12,23 @@ import type {
|
|
|
12
12
|
UpdateArtifactInput,
|
|
13
13
|
} from "../domain/artifact.ts";
|
|
14
14
|
import type { ArtifactEventContext, ArtifactEventPage, ArtifactEventQuery } from "../domain/artifact-event.ts";
|
|
15
|
-
import {
|
|
15
|
+
import type { ArtifactTrashRecord } from "../domain/artifact-trash.ts";
|
|
16
|
+
import {
|
|
17
|
+
createArtifact,
|
|
18
|
+
getArtifact,
|
|
19
|
+
getArtifactTrash,
|
|
20
|
+
linkArtifacts,
|
|
21
|
+
listArtifactTrash,
|
|
22
|
+
purgeDueArtifacts,
|
|
23
|
+
queryArtifactEvents,
|
|
24
|
+
queryArtifacts,
|
|
25
|
+
restoreArtifact,
|
|
26
|
+
trashArtifact,
|
|
27
|
+
unlinkArtifacts,
|
|
28
|
+
updateArtifactContent,
|
|
29
|
+
updateExtra,
|
|
30
|
+
updateStatus,
|
|
31
|
+
} from "../ops.ts";
|
|
16
32
|
|
|
17
33
|
export class SQLiteArtifactStore implements AtomicArtifactStore {
|
|
18
34
|
constructor(private readonly db: Db) {}
|
|
@@ -57,6 +73,26 @@ export class SQLiteArtifactStore implements AtomicArtifactStore {
|
|
|
57
73
|
return queryArtifactEvents(this.db, query);
|
|
58
74
|
}
|
|
59
75
|
|
|
76
|
+
trash(id: string, options?: { reason?: string; context?: ArtifactEventContext }): ArtifactTrashRecord {
|
|
77
|
+
return trashArtifact(this.db, id, { reason: options?.reason, context: options?.context });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
restore(id: string, context?: ArtifactEventContext): { restored: boolean } {
|
|
81
|
+
return restoreArtifact(this.db, id, context);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
trashStatus(id: string): ArtifactTrashRecord | null {
|
|
85
|
+
return getArtifactTrash(this.db, id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
listTrash(): ArtifactTrashRecord[] {
|
|
89
|
+
return listArtifactTrash(this.db);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
purgeDueTrash(): number {
|
|
93
|
+
return purgeDueArtifacts(this.db);
|
|
94
|
+
}
|
|
95
|
+
|
|
60
96
|
relationships(filter: RelationshipQuery = {}): ArtifactEdge[] {
|
|
61
97
|
if (filter.artifactIds?.length === 0) return [];
|
|
62
98
|
const conditions: string[] = [];
|
package/src/cli.ts
CHANGED
|
@@ -82,6 +82,10 @@ const USAGE = `Usage:
|
|
|
82
82
|
papyrus artifact create --kind <kind> [--title <title>] [--status <status>] [--subtype <subtype>] [--body <body>] [--labels-json <json>] [--extra-json <json>] [--template-id <id>] [--json]
|
|
83
83
|
papyrus artifact query [--kind <kind>] [--status <status>] [--text <query>] [--limit <count>] [--json]
|
|
84
84
|
papyrus artifact show <id> [--depth <n>] [--max-nodes <n>] [--json]
|
|
85
|
+
papyrus artifact remove <id> [--reason <text>] [--json]
|
|
86
|
+
papyrus artifact restore <id> [--json]
|
|
87
|
+
papyrus artifact trash-status <id> [--json]
|
|
88
|
+
papyrus artifact trash-list [--json]
|
|
85
89
|
papyrus docs create --title <title> [--body <body>] [--subtype <subtype>] [--labels-json <json>] [--extra-json <json>] [--template-id <id>] [--project-root <path>] [--json]
|
|
86
90
|
papyrus docs list [--status <status>] [--text <query>] [--limit <count>] [--project-root <path>] [--json]
|
|
87
91
|
papyrus docs show <id> [--json]
|
|
@@ -738,6 +742,7 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
|
|
|
738
742
|
let labels: string[] | undefined;
|
|
739
743
|
let extra: Record<string, unknown> | undefined;
|
|
740
744
|
let templateId: string | undefined;
|
|
745
|
+
let reason: string | undefined;
|
|
741
746
|
let text: string | undefined;
|
|
742
747
|
let limit: number | undefined;
|
|
743
748
|
let depth: number | undefined;
|
|
@@ -753,6 +758,7 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
|
|
|
753
758
|
if (argument === "--labels-json") { labels = parseJsonStringArrayFlag(args[++index], "--labels-json"); continue; }
|
|
754
759
|
if (argument === "--extra-json") { extra = parseJsonObjectFlag(args[++index], "--extra-json"); continue; }
|
|
755
760
|
if (argument === "--template-id") { templateId = args[++index]; if (!templateId) throw new Error("--template-id requires a value"); continue; }
|
|
761
|
+
if (argument === "--reason") { reason = args[++index]; if (reason === undefined) throw new Error("--reason requires a value"); continue; }
|
|
756
762
|
if (argument === "--text") { text = args[++index]; if (text === undefined) throw new Error("--text requires a value"); continue; }
|
|
757
763
|
if (argument === "--limit") {
|
|
758
764
|
const value = args[++index];
|
|
@@ -804,8 +810,36 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
|
|
|
804
810
|
human = `${artifactLabel(artifact)}\n\n${artifact.body ?? ""}`;
|
|
805
811
|
break;
|
|
806
812
|
}
|
|
813
|
+
case "remove": {
|
|
814
|
+
if (!id) throw new Error("artifact remove requires exactly one artifact id");
|
|
815
|
+
const record = await client.call<Record<string, unknown>, { artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }>("artifact.remove", { id, reason });
|
|
816
|
+
result = record;
|
|
817
|
+
human = `Trashed ${record.artifactId}: eligible for purge at ${record.purgeAfter}`;
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
case "restore": {
|
|
821
|
+
if (!id) throw new Error("artifact restore requires exactly one artifact id");
|
|
822
|
+
const outcome = await client.call<Record<string, unknown>, { restored: boolean }>("artifact.restore", { id });
|
|
823
|
+
result = outcome;
|
|
824
|
+
human = outcome.restored ? `Restored ${id}` : `${id} was not trashed`;
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
case "trash-status": {
|
|
828
|
+
if (!id) throw new Error("artifact trash-status requires exactly one artifact id");
|
|
829
|
+
const record = await client.call<Record<string, unknown>, { artifactId: string; trashedAt: string; purgeAfter: string; reason?: string } | null>("artifact.trash_status", { id });
|
|
830
|
+
result = record;
|
|
831
|
+
human = record ? `${record.artifactId}: trashed at ${record.trashedAt}, purge eligible at ${record.purgeAfter}` : `${id} is not trashed`;
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
case "trash-list": {
|
|
835
|
+
if (id) throw new Error("artifact trash-list accepts no positional arguments");
|
|
836
|
+
const rows = await client.call<Record<string, unknown>, Array<{ artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }>>("artifact.trash_list", {});
|
|
837
|
+
result = rows;
|
|
838
|
+
human = rows.length === 0 ? "Trash is empty." : rows.map((row) => `${row.artifactId}: purge eligible at ${row.purgeAfter}`).join("\n");
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
807
841
|
default:
|
|
808
|
-
throw new Error("artifact action must be create, query, or
|
|
842
|
+
throw new Error("artifact action must be create, query, show, remove, restore, trash-status, or trash-list");
|
|
809
843
|
}
|
|
810
844
|
return json ? JSON.stringify(result) : human;
|
|
811
845
|
}
|
package/src/constants.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
|
7
7
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
8
8
|
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
9
9
|
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
10
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
10
|
+
export const SQLITE_SCHEMA_VERSION = 14;
|
|
11
11
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
12
12
|
|
|
13
13
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
@@ -159,6 +159,15 @@ export const TASK_FOCUS_MAX_SCOPES = 500;
|
|
|
159
159
|
export const TASK_FOCUS_STALE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
|
160
160
|
/** Hard cap on registered session_identities rows (see domain/session-identity.ts); oldest-seen identity is evicted beyond this, mirroring TASK_FOCUS_MAX_SCOPES. */
|
|
161
161
|
export const SESSION_IDENTITY_MAX_ROWS = 2_000;
|
|
162
|
+
/**
|
|
163
|
+
* Grace period between artifact.remove (trash) and artifact purge eligibility -- see
|
|
164
|
+
* domain/artifact-trash.ts. 30 days, matching TASK_FOCUS_STALE_AFTER_MS's convention: long
|
|
165
|
+
* enough that a mistaken removal is still recoverable via artifact.restore, short enough to
|
|
166
|
+
* actually bound trash accumulation. Enforced twice: the daemon's periodic sweep only
|
|
167
|
+
* selects rows past this deadline, and the SQLite triggers that otherwise forbid deleting
|
|
168
|
+
* artifact_events/task_events independently re-check the same deadline at delete time.
|
|
169
|
+
*/
|
|
170
|
+
export const ARTIFACT_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
162
171
|
/** Persisted project and focused-graph Task view bounds. */
|
|
163
172
|
export const TASK_SCOPE_MAX_TASKS = 1_000;
|
|
164
173
|
/** Docs/Rules/Skills project scope listing bound, mirroring TASK_SCOPE_MAX_TASKS. */
|
package/src/daemon.ts
CHANGED
|
@@ -34,6 +34,14 @@ export function serveMain(): void {
|
|
|
34
34
|
if (removed > 0) logEvent("info", "stale_focus_reaped", { removed });
|
|
35
35
|
} catch (error) { logEvent("error", "reap_stale_focus_failed", { message: error instanceof Error ? error.message : String(error) }); }
|
|
36
36
|
}, DB_OPTIMIZE_INTERVAL_MS);
|
|
37
|
+
// Same daily cadence: ARTIFACT_TRASH_RETENTION_MS is 30 days, so a daily sweep finds newly
|
|
38
|
+
// due artifacts promptly without needing its own tighter interval -- see domain/artifact-trash.ts.
|
|
39
|
+
const purgeTrashTimer = setInterval(() => {
|
|
40
|
+
try {
|
|
41
|
+
const purged = service.purgeDueTrash();
|
|
42
|
+
if (purged > 0) logEvent("info", "artifact_trash_purged", { purged });
|
|
43
|
+
} catch (error) { logEvent("error", "purge_trash_failed", { message: error instanceof Error ? error.message : String(error) }); }
|
|
44
|
+
}, DB_OPTIMIZE_INTERVAL_MS);
|
|
37
45
|
let stopping = false;
|
|
38
46
|
const shutdown = () => {
|
|
39
47
|
if (stopping) return;
|
|
@@ -41,6 +49,7 @@ export function serveMain(): void {
|
|
|
41
49
|
clearInterval(checkpointTimer);
|
|
42
50
|
clearInterval(optimizeTimer);
|
|
43
51
|
clearInterval(reapFocusTimer);
|
|
52
|
+
clearInterval(purgeTrashTimer);
|
|
44
53
|
clearDaemonPort(stateDir);
|
|
45
54
|
service.close();
|
|
46
55
|
void server.stop(true).finally(() => process.exit(0));
|
package/src/db.ts
CHANGED
|
@@ -134,7 +134,8 @@ CREATE INDEX IF NOT EXISTS task_events_history_idx ON task_events(task_id, occur
|
|
|
134
134
|
CREATE TRIGGER IF NOT EXISTS task_events_no_update BEFORE UPDATE ON task_events
|
|
135
135
|
BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
|
|
136
136
|
CREATE TRIGGER IF NOT EXISTS task_events_no_delete BEFORE DELETE ON task_events
|
|
137
|
-
|
|
137
|
+
WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.task_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
138
|
+
BEGIN SELECT RAISE(ABORT, 'task_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
|
|
138
139
|
CREATE TABLE IF NOT EXISTS task_scopes (
|
|
139
140
|
task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
140
141
|
project_root TEXT,
|
|
@@ -170,7 +171,8 @@ CREATE INDEX IF NOT EXISTS artifact_events_session_idx ON artifact_events(sessio
|
|
|
170
171
|
CREATE TRIGGER IF NOT EXISTS artifact_events_no_update BEFORE UPDATE ON artifact_events
|
|
171
172
|
BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only'); END;
|
|
172
173
|
CREATE TRIGGER IF NOT EXISTS artifact_events_no_delete BEFORE DELETE ON artifact_events
|
|
173
|
-
|
|
174
|
+
WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.artifact_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
175
|
+
BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
|
|
174
176
|
CREATE TABLE IF NOT EXISTS graph_projection_checkpoints (
|
|
175
177
|
producer_id TEXT PRIMARY KEY,
|
|
176
178
|
last_sequence INTEGER NOT NULL,
|
|
@@ -218,6 +220,13 @@ CREATE TABLE IF NOT EXISTS session_identities (
|
|
|
218
220
|
registered_at TEXT NOT NULL,
|
|
219
221
|
last_seen_at TEXT NOT NULL
|
|
220
222
|
);
|
|
223
|
+
CREATE TABLE IF NOT EXISTS artifact_trash (
|
|
224
|
+
artifact_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
225
|
+
trashed_at TEXT NOT NULL,
|
|
226
|
+
purge_after TEXT NOT NULL,
|
|
227
|
+
reason TEXT
|
|
228
|
+
);
|
|
229
|
+
CREATE INDEX IF NOT EXISTS artifact_trash_purge_idx ON artifact_trash(purge_after);
|
|
221
230
|
`;
|
|
222
231
|
|
|
223
232
|
const SEED_SQL = `
|
|
@@ -296,6 +305,7 @@ const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; check
|
|
|
296
305
|
{ version: 3, name: "log-domain", checksum: "c87f43c22b2608619ada9a529d7899ae74b7f38cd554135c8034116fc96e1eff" },
|
|
297
306
|
{ version: 4, name: "remove-discourse", checksum: "b923f41c44460f0aaeb2f4e60e28f8b8e1425d03f527955bd991434b46de4c82" },
|
|
298
307
|
{ version: 5, name: "session-identity", checksum: "1c6a165bbe37f82a100fd34762db70c3f8ab15ff20c3a53c2e60448edc815a5e" },
|
|
308
|
+
{ version: 6, name: "artifact-trash", checksum: "4a75dbec2892deb54bcc1afdf0d51d81f03a8d10861787d083784a29e5c7e8f9" },
|
|
299
309
|
];
|
|
300
310
|
|
|
301
311
|
export function migrationLedger(db: Db): ModuleMigrationRow[] {
|
|
@@ -388,8 +398,34 @@ export interface PapyrusMigration {
|
|
|
388
398
|
up: (db: Db) => void;
|
|
389
399
|
}
|
|
390
400
|
|
|
391
|
-
|
|
392
|
-
|
|
401
|
+
const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
402
|
+
{
|
|
403
|
+
version: 14,
|
|
404
|
+
name: "artifact-trash",
|
|
405
|
+
// See domain/artifact-trash.ts for the full design rationale. The two trigger bodies here
|
|
406
|
+
// must match SCHEMA's fresh-bootstrap versions of the same triggers byte-for-byte -- DROP
|
|
407
|
+
// then CREATE is required since SQLite has no ALTER TRIGGER.
|
|
408
|
+
up: (db) => {
|
|
409
|
+
db.exec(`
|
|
410
|
+
CREATE TABLE IF NOT EXISTS artifact_trash (
|
|
411
|
+
artifact_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
412
|
+
trashed_at TEXT NOT NULL,
|
|
413
|
+
purge_after TEXT NOT NULL,
|
|
414
|
+
reason TEXT
|
|
415
|
+
);
|
|
416
|
+
CREATE INDEX IF NOT EXISTS artifact_trash_purge_idx ON artifact_trash(purge_after);
|
|
417
|
+
DROP TRIGGER IF EXISTS task_events_no_delete;
|
|
418
|
+
CREATE TRIGGER task_events_no_delete BEFORE DELETE ON task_events
|
|
419
|
+
WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.task_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
420
|
+
BEGIN SELECT RAISE(ABORT, 'task_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
|
|
421
|
+
DROP TRIGGER IF EXISTS artifact_events_no_delete;
|
|
422
|
+
CREATE TRIGGER artifact_events_no_delete BEFORE DELETE ON artifact_events
|
|
423
|
+
WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.artifact_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
424
|
+
BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
|
|
425
|
+
`);
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
];
|
|
393
429
|
|
|
394
430
|
/**
|
|
395
431
|
* Adapts Papyrus's own Db/inTransaction to daemon-kit's storage-agnostic
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { ARTIFACT_EVENT_ACTOR_MAX_LENGTH, ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT, ARTIFACT_EVENT_HISTORY_MAX_LIMIT } from "../constants.ts";
|
|
12
12
|
|
|
13
|
-
export const ARTIFACT_EVENT_TYPES = ["created", "updated", "status_changed", "extra_set", "linked", "unlinked"] as const;
|
|
13
|
+
export const ARTIFACT_EVENT_TYPES = ["created", "updated", "status_changed", "extra_set", "linked", "unlinked", "trashed", "restored"] as const;
|
|
14
14
|
export type ArtifactEventType = typeof ARTIFACT_EVENT_TYPES[number];
|
|
15
15
|
export type ArtifactEventDirection = "asc" | "desc";
|
|
16
16
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact trash: Option B from the design discussion (Doc-worthy decision, recorded here
|
|
3
|
+
* since there is no other durable home for it yet) -- `artifact.remove` is a real, narrow,
|
|
4
|
+
* time-gated exception to Papyrus's otherwise-absolute append-only invariant, not a mere
|
|
5
|
+
* status flip.
|
|
6
|
+
*
|
|
7
|
+
* The constraint that shaped this design: every artifact gets a mandatory "created" row in
|
|
8
|
+
* artifact_events at creation time (see ops.ts's createArtifact), and artifact_events is
|
|
9
|
+
* DB-trigger-enforced immutable (no UPDATE, no DELETE, ever). That means a literal
|
|
10
|
+
* `DELETE FROM artifacts` can never succeed for ANY artifact while that FK and that trigger
|
|
11
|
+
* both hold unconditionally -- there is no such thing as an artifact with "no history to
|
|
12
|
+
* protect". A real purge therefore requires the DB's own append-only triggers to carry an
|
|
13
|
+
* explicit, narrow carve-out (see db.ts's artifact_events_no_delete / task_events_no_delete),
|
|
14
|
+
* gated on the exact same elapsed-time deadline recorded here -- enforced by the database
|
|
15
|
+
* itself, not merely by application-code discipline, so a bug in the purge sweep cannot
|
|
16
|
+
* delete history before its own stated deadline.
|
|
17
|
+
*
|
|
18
|
+
* Removing an artifact does not touch it immediately: it inserts one row here recording when
|
|
19
|
+
* it becomes eligible, and from that moment the artifact is excluded from ordinary listings
|
|
20
|
+
* (see ops.ts's queryArtifacts) but still directly reachable by id (get/show) and fully
|
|
21
|
+
* restorable via artifact.restore, until purgeAfter passes and the daemon's periodic sweep
|
|
22
|
+
* (see daemon.ts) performs the real, cascading, irreversible deletion.
|
|
23
|
+
*/
|
|
24
|
+
export interface ArtifactTrashRecord {
|
|
25
|
+
artifactId: string;
|
|
26
|
+
trashedAt: string;
|
|
27
|
+
purgeAfter: string;
|
|
28
|
+
reason?: string;
|
|
29
|
+
}
|
package/src/domain/artifact.ts
CHANGED
|
@@ -46,6 +46,8 @@ export interface ArtifactQuery {
|
|
|
46
46
|
labels?: string[];
|
|
47
47
|
extraEquals?: Record<string, string | number | boolean>;
|
|
48
48
|
limit?: number;
|
|
49
|
+
/** 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. */
|
|
50
|
+
includeTrashed?: boolean;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export interface ArtifactGraphOptions {
|
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
|
}
|
package/src/service.ts
CHANGED
|
@@ -49,6 +49,7 @@ import { tasksOperations, TASKS_OPERATION_NAMES } from "./modules/tasks.ts";
|
|
|
49
49
|
*/
|
|
50
50
|
const COMPOSITION_ROOT_OPERATION_NAMES = [
|
|
51
51
|
"system.migrate", "artifact.create", "artifact.query", "artifact.show",
|
|
52
|
+
"artifact.remove", "artifact.restore", "artifact.trash_status", "artifact.trash_list",
|
|
52
53
|
"graph.link", "graph.unlink", "graph.tree", "graph.status", "graph.history", "gates.run",
|
|
53
54
|
"rules.injectable", "skills.instantiate",
|
|
54
55
|
] as const;
|
|
@@ -173,6 +174,8 @@ export interface PapyrusService {
|
|
|
173
174
|
optimize(): void;
|
|
174
175
|
/** Time-based Task Focus reclamation (see Tasks.reapStaleFocus); returns how many rows were removed, for daemon logging. */
|
|
175
176
|
reapStaleFocus(): number;
|
|
177
|
+
/** Real, cascading deletion of every artifact past its trash purge deadline (see domain/artifact-trash.ts); returns how many were purged, for daemon logging. */
|
|
178
|
+
purgeDueTrash(): number;
|
|
176
179
|
close(): void;
|
|
177
180
|
}
|
|
178
181
|
|
|
@@ -241,6 +244,10 @@ function handlers(
|
|
|
241
244
|
depth: optionalNumber(input, "depth"),
|
|
242
245
|
maxNodes: optionalNumber(input, "max_nodes") ?? optionalNumber(input, "maxNodes"),
|
|
243
246
|
}),
|
|
247
|
+
"artifact.remove": (input) => artifacts.trash(string(input, "id"), { reason: optionalString(input, "reason"), context: eventContext(input) }),
|
|
248
|
+
"artifact.restore": (input) => artifacts.restore(string(input, "id"), eventContext(input)),
|
|
249
|
+
"artifact.trash_status": (input) => artifacts.trashStatus(string(input, "id")),
|
|
250
|
+
"artifact.trash_list": () => artifacts.listTrash(),
|
|
244
251
|
"graph.link": (input) => {
|
|
245
252
|
const from = string(input, "from");
|
|
246
253
|
const relation = string(input, "relation");
|
|
@@ -421,6 +428,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
421
428
|
checkpoint: () => { db.exec("PRAGMA wal_checkpoint(PASSIVE)"); },
|
|
422
429
|
optimize: () => { db.exec("PRAGMA optimize"); },
|
|
423
430
|
reapStaleFocus: () => tasks.reapStaleFocus(),
|
|
431
|
+
purgeDueTrash: () => artifacts.purgeDueTrash(),
|
|
424
432
|
close: () => {
|
|
425
433
|
db.exec("PRAGMA optimize");
|
|
426
434
|
db.close();
|