@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/README.md +23 -0
- package/extension/src/domain-tools.ts +105 -6
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +37 -1
- package/src/adapters/sqlite-discussion-round-store.ts +61 -0
- package/src/cli.ts +150 -1
- package/src/constants.ts +31 -1
- package/src/daemon.ts +9 -0
- package/src/db.ts +86 -5
- package/src/discussion-service.ts +159 -0
- 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/domain/discussion.ts +107 -0
- package/src/modules/discuss.ts +87 -0
- package/src/ops.ts +106 -1
- package/src/ports/artifact-store.ts +9 -0
- package/src/ports/discussion-round-store.ts +8 -0
- package/src/service.ts +24 -0
- package/src/task-service.ts +27 -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,29 @@ 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);
|
|
230
|
+
CREATE TABLE IF NOT EXISTS discussion_rounds (
|
|
231
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
232
|
+
discussion_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
233
|
+
round_number INTEGER NOT NULL,
|
|
234
|
+
actor TEXT NOT NULL,
|
|
235
|
+
content TEXT NOT NULL,
|
|
236
|
+
occurred_at TEXT NOT NULL,
|
|
237
|
+
event_schema_version INTEGER NOT NULL DEFAULT 1,
|
|
238
|
+
UNIQUE (discussion_id, round_number)
|
|
239
|
+
);
|
|
240
|
+
CREATE INDEX IF NOT EXISTS discussion_rounds_discussion_idx ON discussion_rounds(discussion_id, round_number, id);
|
|
241
|
+
CREATE TRIGGER IF NOT EXISTS discussion_rounds_no_update BEFORE UPDATE ON discussion_rounds
|
|
242
|
+
BEGIN SELECT RAISE(ABORT, 'discussion_rounds are append-only'); END;
|
|
243
|
+
CREATE TRIGGER IF NOT EXISTS discussion_rounds_no_delete BEFORE DELETE ON discussion_rounds
|
|
244
|
+
WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.discussion_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
245
|
+
BEGIN SELECT RAISE(ABORT, 'discussion_rounds are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
|
|
221
246
|
`;
|
|
222
247
|
|
|
223
248
|
const SEED_SQL = `
|
|
@@ -243,7 +268,7 @@ INSERT OR IGNORE INTO relation_names VALUES ('implements','This work satisfies t
|
|
|
243
268
|
INSERT OR IGNORE INTO relation_names VALUES ('follows','This work obeys that (task→rule, task→skill)');
|
|
244
269
|
INSERT OR IGNORE INTO relation_names VALUES ('depends_on','DAG ordering (task→task)');
|
|
245
270
|
INSERT OR IGNORE INTO relation_names VALUES ('documents','Describes (doc→task, doc→rule, doc→skill)');
|
|
246
|
-
INSERT OR IGNORE INTO relation_names VALUES ('blocks','Blocking relationship (task→task)');
|
|
271
|
+
INSERT OR IGNORE INTO relation_names VALUES ('blocks','Blocking relationship (task→task, or an active Discussion doc→task)');
|
|
247
272
|
INSERT OR IGNORE INTO relation_names VALUES ('supersedes','Replaces (doc→doc, rule→rule)');
|
|
248
273
|
INSERT OR IGNORE INTO relation_names VALUES ('relates_to','Catch-all (any→any)');
|
|
249
274
|
INSERT OR IGNORE INTO relation_names VALUES ('gates','This rule gates that task (rule→task)');
|
|
@@ -296,6 +321,8 @@ const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; check
|
|
|
296
321
|
{ version: 3, name: "log-domain", checksum: "c87f43c22b2608619ada9a529d7899ae74b7f38cd554135c8034116fc96e1eff" },
|
|
297
322
|
{ version: 4, name: "remove-discourse", checksum: "b923f41c44460f0aaeb2f4e60e28f8b8e1425d03f527955bd991434b46de4c82" },
|
|
298
323
|
{ version: 5, name: "session-identity", checksum: "1c6a165bbe37f82a100fd34762db70c3f8ab15ff20c3a53c2e60448edc815a5e" },
|
|
324
|
+
{ version: 6, name: "artifact-trash", checksum: "4a75dbec2892deb54bcc1afdf0d51d81f03a8d10861787d083784a29e5c7e8f9" },
|
|
325
|
+
{ version: 7, name: "discuss-native", checksum: "ab7bdd04824bd93681917807b817d6e08b9825af90161e3ccd6d6663021dc6a0" },
|
|
299
326
|
];
|
|
300
327
|
|
|
301
328
|
export function migrationLedger(db: Db): ModuleMigrationRow[] {
|
|
@@ -388,8 +415,62 @@ export interface PapyrusMigration {
|
|
|
388
415
|
up: (db: Db) => void;
|
|
389
416
|
}
|
|
390
417
|
|
|
391
|
-
|
|
392
|
-
|
|
418
|
+
const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
419
|
+
{
|
|
420
|
+
version: 14,
|
|
421
|
+
name: "artifact-trash",
|
|
422
|
+
// See domain/artifact-trash.ts for the full design rationale. The two trigger bodies here
|
|
423
|
+
// must match SCHEMA's fresh-bootstrap versions of the same triggers byte-for-byte -- DROP
|
|
424
|
+
// then CREATE is required since SQLite has no ALTER TRIGGER.
|
|
425
|
+
up: (db) => {
|
|
426
|
+
db.exec(`
|
|
427
|
+
CREATE TABLE IF NOT EXISTS artifact_trash (
|
|
428
|
+
artifact_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
429
|
+
trashed_at TEXT NOT NULL,
|
|
430
|
+
purge_after TEXT NOT NULL,
|
|
431
|
+
reason TEXT
|
|
432
|
+
);
|
|
433
|
+
CREATE INDEX IF NOT EXISTS artifact_trash_purge_idx ON artifact_trash(purge_after);
|
|
434
|
+
DROP TRIGGER IF EXISTS task_events_no_delete;
|
|
435
|
+
CREATE TRIGGER task_events_no_delete BEFORE DELETE ON task_events
|
|
436
|
+
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'))
|
|
437
|
+
BEGIN SELECT RAISE(ABORT, 'task_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
|
|
438
|
+
DROP TRIGGER IF EXISTS artifact_events_no_delete;
|
|
439
|
+
CREATE TRIGGER artifact_events_no_delete BEFORE DELETE ON artifact_events
|
|
440
|
+
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'))
|
|
441
|
+
BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
|
|
442
|
+
`);
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
version: 15,
|
|
447
|
+
name: "discuss-native",
|
|
448
|
+
// See domain/discussion.ts. discussion_rounds mirrors task_events' proven shape (append-only,
|
|
449
|
+
// with the identical trash-purge trigger carve-out); the blocks relation's description is
|
|
450
|
+
// widened to reflect that an active Discussion doc can now block a task too.
|
|
451
|
+
up: (db) => {
|
|
452
|
+
db.exec(`
|
|
453
|
+
CREATE TABLE IF NOT EXISTS discussion_rounds (
|
|
454
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
455
|
+
discussion_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
456
|
+
round_number INTEGER NOT NULL,
|
|
457
|
+
actor TEXT NOT NULL,
|
|
458
|
+
content TEXT NOT NULL,
|
|
459
|
+
occurred_at TEXT NOT NULL,
|
|
460
|
+
event_schema_version INTEGER NOT NULL DEFAULT 1,
|
|
461
|
+
UNIQUE (discussion_id, round_number)
|
|
462
|
+
);
|
|
463
|
+
CREATE INDEX IF NOT EXISTS discussion_rounds_discussion_idx ON discussion_rounds(discussion_id, round_number, id);
|
|
464
|
+
CREATE TRIGGER IF NOT EXISTS discussion_rounds_no_update BEFORE UPDATE ON discussion_rounds
|
|
465
|
+
BEGIN SELECT RAISE(ABORT, 'discussion_rounds are append-only'); END;
|
|
466
|
+
CREATE TRIGGER IF NOT EXISTS discussion_rounds_no_delete BEFORE DELETE ON discussion_rounds
|
|
467
|
+
WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.discussion_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
468
|
+
BEGIN SELECT RAISE(ABORT, 'discussion_rounds are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
|
|
469
|
+
UPDATE relation_names SET description = 'Blocking relationship (task→task, or an active Discussion doc→task)' WHERE name = 'blocks';
|
|
470
|
+
`);
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
];
|
|
393
474
|
|
|
394
475
|
/**
|
|
395
476
|
* Adapts Papyrus's own Db/inTransaction to daemon-kit's storage-agnostic
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discuss: application service composing the Discussion Doc (via ArtifactStore) with its
|
|
3
|
+
* append-only rounds (via DiscussionRoundStore). See domain/discussion.ts for the full
|
|
4
|
+
* design rationale.
|
|
5
|
+
*/
|
|
6
|
+
import { DISCUSSION_MAX_ROUNDS } from "./constants.ts";
|
|
7
|
+
import {
|
|
8
|
+
DISCUSSION_SUBTYPE,
|
|
9
|
+
isDiscussionArtifact,
|
|
10
|
+
readDiscussionExtra,
|
|
11
|
+
validateDeferReason,
|
|
12
|
+
validateDiscussionActor,
|
|
13
|
+
validateDiscussionContent,
|
|
14
|
+
validateSettlement,
|
|
15
|
+
type DiscussionExtra,
|
|
16
|
+
type DiscussionRound,
|
|
17
|
+
} from "./domain/discussion.ts";
|
|
18
|
+
import type { Artifact } from "./domain/artifact.ts";
|
|
19
|
+
import type { ArtifactEventContext } from "./domain/artifact-event.ts";
|
|
20
|
+
import type { AtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
|
|
21
|
+
import type { DiscussionRoundStore } from "./ports/discussion-round-store.ts";
|
|
22
|
+
|
|
23
|
+
export class DiscussionError extends Error {}
|
|
24
|
+
|
|
25
|
+
export interface OpenDiscussionInput {
|
|
26
|
+
title: string;
|
|
27
|
+
actor: string;
|
|
28
|
+
content: string;
|
|
29
|
+
body?: string;
|
|
30
|
+
labels?: string[];
|
|
31
|
+
blocksTaskIds?: string[];
|
|
32
|
+
projectRoot?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DiscussionAndRounds {
|
|
36
|
+
discussion: Artifact;
|
|
37
|
+
rounds: DiscussionRound[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function requireDiscussion(artifact: Artifact | null, id: string): Artifact {
|
|
41
|
+
if (!artifact) throw new DiscussionError(`discussion "${id}" not found`);
|
|
42
|
+
if (!isDiscussionArtifact(artifact)) throw new DiscussionError(`artifact "${id}" is not a Discussion`);
|
|
43
|
+
return artifact;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class Discussions {
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly artifacts: AtomicArtifactStore,
|
|
49
|
+
private readonly rounds: DiscussionRoundStore,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
private extra(discussion: Artifact): DiscussionExtra {
|
|
53
|
+
return readDiscussionExtra(discussion.extra);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
open(input: OpenDiscussionInput, context?: ArtifactEventContext): DiscussionAndRounds {
|
|
57
|
+
const actor = validateDiscussionActor(input.actor);
|
|
58
|
+
const content = validateDiscussionContent(input.content);
|
|
59
|
+
return this.artifacts.atomic(() => {
|
|
60
|
+
const discussion = this.artifacts.create({
|
|
61
|
+
kind: "doc",
|
|
62
|
+
subtype: DISCUSSION_SUBTYPE,
|
|
63
|
+
title: input.title,
|
|
64
|
+
body: input.body ?? "",
|
|
65
|
+
status: "active",
|
|
66
|
+
labels: input.labels,
|
|
67
|
+
extra: { discussion: { state: "active", roundCount: 1 } },
|
|
68
|
+
}, context);
|
|
69
|
+
const round = this.rounds.append({ discussionId: discussion.id, roundNumber: 1, actor, content }, new Date().toISOString());
|
|
70
|
+
for (const taskId of input.blocksTaskIds ?? []) this.block(discussion.id, taskId, context);
|
|
71
|
+
return { discussion: this.artifacts.get(discussion.id)!, rounds: [round] };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
reply(discussionId: string, actor: string, content: string, context?: ArtifactEventContext): DiscussionAndRounds {
|
|
76
|
+
const validActor = validateDiscussionActor(actor);
|
|
77
|
+
const validContent = validateDiscussionContent(content);
|
|
78
|
+
return this.artifacts.atomic(() => {
|
|
79
|
+
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
80
|
+
const state = this.extra(discussion);
|
|
81
|
+
if (state.state !== "active") throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; resume it before replying`);
|
|
82
|
+
if (state.roundCount >= DISCUSSION_MAX_ROUNDS) throw new DiscussionError(`discussion "${discussionId}" has reached its ${DISCUSSION_MAX_ROUNDS}-round limit; settle or defer it`);
|
|
83
|
+
const nextRound = state.roundCount + 1;
|
|
84
|
+
const round = this.rounds.append({ discussionId, roundNumber: nextRound, actor: validActor, content: validContent }, new Date().toISOString());
|
|
85
|
+
const updated = this.artifacts.setExtra(discussionId, { ...discussion.extra, discussion: { ...state, roundCount: nextRound } }, context)!;
|
|
86
|
+
return { discussion: updated, rounds: [round] };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
defer(discussionId: string, reason?: string, context?: ArtifactEventContext): Artifact {
|
|
91
|
+
const validReason = reason === undefined ? undefined : validateDeferReason(reason);
|
|
92
|
+
return this.artifacts.atomic(() => {
|
|
93
|
+
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
94
|
+
const state = this.extra(discussion);
|
|
95
|
+
if (state.state !== "active") throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; only an active Discussion can be deferred`);
|
|
96
|
+
return this.artifacts.setExtra(discussionId, {
|
|
97
|
+
...discussion.extra,
|
|
98
|
+
discussion: { ...state, state: "deferred", ...(validReason === undefined ? {} : { deferredReason: validReason }) },
|
|
99
|
+
}, context)!;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
resume(discussionId: string, context?: ArtifactEventContext): Artifact {
|
|
104
|
+
return this.artifacts.atomic(() => {
|
|
105
|
+
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
106
|
+
const state = this.extra(discussion);
|
|
107
|
+
if (state.state !== "deferred") throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; only a deferred Discussion can be resumed`);
|
|
108
|
+
const { deferredReason: _deferredReason, ...rest } = state;
|
|
109
|
+
return this.artifacts.setExtra(discussionId, { ...discussion.extra, discussion: { ...rest, state: "active" } }, context)!;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
settle(discussionId: string, settlement: string, context?: ArtifactEventContext): Artifact {
|
|
114
|
+
const validSettlement = validateSettlement(settlement);
|
|
115
|
+
return this.artifacts.atomic(() => {
|
|
116
|
+
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
117
|
+
const state = this.extra(discussion);
|
|
118
|
+
if (state.state === "settled") throw new DiscussionError(`discussion "${discussionId}" is already settled`);
|
|
119
|
+
const updated = this.artifacts.setExtra(discussionId, {
|
|
120
|
+
...discussion.extra,
|
|
121
|
+
discussion: { ...state, state: "settled", settlement: validSettlement, settledAt: new Date().toISOString() },
|
|
122
|
+
}, context)!;
|
|
123
|
+
return this.artifacts.setStatus(discussionId, "archived", context) ?? updated;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Links an existing active Discussion to a Task it blocks; refuses a non-task target or an already-settled Discussion. */
|
|
128
|
+
block(discussionId: string, taskId: string, context?: ArtifactEventContext): void {
|
|
129
|
+
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
130
|
+
if (this.extra(discussion).state === "settled") throw new DiscussionError(`discussion "${discussionId}" is settled; it can no longer block anything`);
|
|
131
|
+
const task = this.artifacts.get(taskId);
|
|
132
|
+
if (!task) throw new DiscussionError(`task "${taskId}" not found`);
|
|
133
|
+
if (task.kind !== "task") throw new DiscussionError(`artifact "${taskId}" is not a task`);
|
|
134
|
+
this.artifacts.link({ from: discussionId, relation: "blocks", to: taskId }, context);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Idempotent: unblocking an already-absent relationship is a no-op. */
|
|
138
|
+
unblock(discussionId: string, taskId: string, context?: ArtifactEventContext): boolean {
|
|
139
|
+
return this.artifacts.unlink({ from: discussionId, relation: "blocks", to: taskId }, context);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
show(discussionId: string): DiscussionAndRounds {
|
|
143
|
+
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
144
|
+
return { discussion, rounds: this.rounds.list({ discussionId }) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
listRounds(discussionId: string, afterRound?: number, limit?: number): DiscussionRound[] {
|
|
148
|
+
requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
149
|
+
return this.rounds.list({ discussionId, afterRound, limit });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
list(filter: { state?: string; limit?: number } = {}): Artifact[] {
|
|
153
|
+
const rows = this.artifacts.query({ kind: "doc", subtype: DISCUSSION_SUBTYPE, limit: filter.limit });
|
|
154
|
+
if (!filter.state) return rows;
|
|
155
|
+
return rows.filter((row) => {
|
|
156
|
+
try { return this.extra(row).state === filter.state; } catch { return false; }
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -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 {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discuss: a native Papyrus deliberation with a real lifecycle, distinct from a one-shot
|
|
3
|
+
* "ask" (see the design discussion this implements) and from Discourse's forum (kept fully
|
|
4
|
+
* standalone by design -- no dependency here, Discuss reuses none of its storage or wire
|
|
5
|
+
* shape). A Discussion is a `doc` artifact with subtype "discussion": real graph citizenship
|
|
6
|
+
* (edges, show/list) without a fifth enforced artifact kind. Its fine-grained lifecycle
|
|
7
|
+
* lives in extra.discussion rather than the shared doc status vocabulary, since Papyrus
|
|
8
|
+
* enforces status per-kind, not per-subtype -- "deferred" has no equivalent among a plain
|
|
9
|
+
* doc's draft/active/archived. The doc's own status column follows loosely: "active" while
|
|
10
|
+
* extra.discussion.state is active or deferred, "archived" once settled.
|
|
11
|
+
*
|
|
12
|
+
* Blocking is the forcing, load-bearing behavior a Discussion adds over a passive record:
|
|
13
|
+
* an "active" Discussion that `blocks` a Task refuses that Task's completion (see
|
|
14
|
+
* task-service.ts's blockingDiscussions) until the Discussion is settled or deferred.
|
|
15
|
+
* Deferred is explicitly non-blocking -- "we will get back to this," not "resolved".
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
DISCUSSION_ACTOR_MAX_LENGTH,
|
|
19
|
+
DISCUSSION_DEFER_REASON_MAX_CHARACTERS,
|
|
20
|
+
DISCUSSION_ROUND_CONTENT_MAX_CHARACTERS,
|
|
21
|
+
DISCUSSION_SETTLEMENT_MAX_CHARACTERS,
|
|
22
|
+
} from "../constants.ts";
|
|
23
|
+
|
|
24
|
+
export const DISCUSSION_SUBTYPE = "discussion";
|
|
25
|
+
|
|
26
|
+
export const DISCUSSION_STATES = ["active", "deferred", "settled"] as const;
|
|
27
|
+
export type DiscussionState = typeof DISCUSSION_STATES[number];
|
|
28
|
+
|
|
29
|
+
/** Persisted in a discussion Doc's `extra.discussion`. */
|
|
30
|
+
export interface DiscussionExtra {
|
|
31
|
+
state: DiscussionState;
|
|
32
|
+
roundCount: number;
|
|
33
|
+
deferredReason?: string;
|
|
34
|
+
settlement?: string;
|
|
35
|
+
settledAt?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One append-only round of a Discussion -- opening statement is round 1. */
|
|
39
|
+
export interface DiscussionRound {
|
|
40
|
+
id: number;
|
|
41
|
+
discussionId: string;
|
|
42
|
+
roundNumber: number;
|
|
43
|
+
actor: string;
|
|
44
|
+
content: string;
|
|
45
|
+
occurredAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AppendDiscussionRound {
|
|
49
|
+
discussionId: string;
|
|
50
|
+
roundNumber: number;
|
|
51
|
+
actor: string;
|
|
52
|
+
content: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DiscussionRoundQuery {
|
|
56
|
+
discussionId: string;
|
|
57
|
+
afterRound?: number;
|
|
58
|
+
limit?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function boundedString(value: string, field: string, maximum: number): string {
|
|
62
|
+
if (value.length === 0 || value.length > maximum) throw new Error(`${field} must be between 1 and ${maximum} characters`);
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function validateDiscussionContent(content: string): string {
|
|
67
|
+
return boundedString(content, "content", DISCUSSION_ROUND_CONTENT_MAX_CHARACTERS);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function validateDiscussionActor(actor: string): string {
|
|
71
|
+
return boundedString(actor, "actor", DISCUSSION_ACTOR_MAX_LENGTH);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function validateDeferReason(reason: string): string {
|
|
75
|
+
return boundedString(reason, "reason", DISCUSSION_DEFER_REASON_MAX_CHARACTERS);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function validateSettlement(settlement: string): string {
|
|
79
|
+
return boundedString(settlement, "settlement", DISCUSSION_SETTLEMENT_MAX_CHARACTERS);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True for any artifact (already fetched) that is a Discussion, regardless of its current lifecycle state. */
|
|
83
|
+
export function isDiscussionArtifact(artifact: { kind: string; subtype: string }): boolean {
|
|
84
|
+
return artifact.kind === "doc" && artifact.subtype === DISCUSSION_SUBTYPE;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Reads and defensively validates the extra.discussion shape; throws on a corrupt/foreign shape rather than silently treating it as some default state. */
|
|
88
|
+
export function readDiscussionExtra(extra: Record<string, unknown>): DiscussionExtra {
|
|
89
|
+
const raw = extra["discussion"];
|
|
90
|
+
if (typeof raw !== "object" || raw === null) throw new Error("artifact is not a Discussion (missing extra.discussion)");
|
|
91
|
+
const record = raw as Record<string, unknown>;
|
|
92
|
+
const state = record["state"];
|
|
93
|
+
if (typeof state !== "string" || !(DISCUSSION_STATES as readonly string[]).includes(state)) {
|
|
94
|
+
throw new Error(`invalid Discussion state "${String(state)}"`);
|
|
95
|
+
}
|
|
96
|
+
const roundCount = record["roundCount"];
|
|
97
|
+
if (typeof roundCount !== "number" || !Number.isInteger(roundCount) || roundCount < 0) {
|
|
98
|
+
throw new Error("invalid Discussion roundCount");
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
state: state as DiscussionState,
|
|
102
|
+
roundCount,
|
|
103
|
+
...(typeof record["deferredReason"] === "string" ? { deferredReason: record["deferredReason"] } : {}),
|
|
104
|
+
...(typeof record["settlement"] === "string" ? { settlement: record["settlement"] } : {}),
|
|
105
|
+
...(typeof record["settledAt"] === "string" ? { settledAt: record["settledAt"] } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* modules/discuss.ts — Discuss as a Papyrus-native registered module. See
|
|
3
|
+
* domain/discussion.ts and discussion-service.ts for the full design.
|
|
4
|
+
*/
|
|
5
|
+
import type { ArtifactEventContext } from "../domain/artifact-event.ts";
|
|
6
|
+
import type { Discussions } from "../discussion-service.ts";
|
|
7
|
+
import type { OperationDefinition } from "../module-registry.ts";
|
|
8
|
+
|
|
9
|
+
const MODULE_ID = "discuss";
|
|
10
|
+
|
|
11
|
+
type OperationInput = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
function string(input: OperationInput, key: string): string {
|
|
14
|
+
const value = input[key];
|
|
15
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function optionalString(input: OperationInput, key: string): string | undefined {
|
|
20
|
+
const value = input[key];
|
|
21
|
+
if (value === undefined) return undefined;
|
|
22
|
+
if (typeof value !== "string") throw new Error(`${key} must be a string`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function optionalStringArray(input: OperationInput, key: string): string[] | undefined {
|
|
27
|
+
const value = input[key];
|
|
28
|
+
if (value === undefined) return undefined;
|
|
29
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) throw new Error(`${key} must be an array of strings`);
|
|
30
|
+
return value as string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function optionalNumber(input: OperationInput, key: string): number | undefined {
|
|
34
|
+
const value = input[key];
|
|
35
|
+
if (value === undefined) return undefined;
|
|
36
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const eventContext = (input: OperationInput): ArtifactEventContext => ({
|
|
41
|
+
actor: optionalString(input, "actor"),
|
|
42
|
+
source: optionalString(input, "source"),
|
|
43
|
+
sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function taskId(input: OperationInput): string {
|
|
47
|
+
const value = optionalString(input, "task_id") ?? optionalString(input, "taskId");
|
|
48
|
+
if (!value) throw new Error("task_id is required");
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
|
|
53
|
+
export const DISCUSS_OPERATION_NAMES = [
|
|
54
|
+
"discuss.open", "discuss.reply", "discuss.defer", "discuss.resume", "discuss.settle",
|
|
55
|
+
"discuss.block", "discuss.unblock", "discuss.show", "discuss.rounds", "discuss.list",
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
/** Registers every discuss.* operation against one Discussions instance. */
|
|
59
|
+
export function discussOperations(discussions: Discussions): OperationDefinition[] {
|
|
60
|
+
const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
|
|
61
|
+
name, moduleId: MODULE_ID, execute,
|
|
62
|
+
});
|
|
63
|
+
return [
|
|
64
|
+
define("discuss.open", (input: OperationInput) => discussions.open({
|
|
65
|
+
title: string(input, "title"),
|
|
66
|
+
actor: string(input, "actor"),
|
|
67
|
+
content: string(input, "content"),
|
|
68
|
+
body: optionalString(input, "body"),
|
|
69
|
+
labels: optionalStringArray(input, "labels"),
|
|
70
|
+
blocksTaskIds: optionalStringArray(input, "blocks_task_ids") ?? optionalStringArray(input, "blocksTaskIds"),
|
|
71
|
+
}, eventContext(input))),
|
|
72
|
+
define("discuss.reply", (input: OperationInput) => discussions.reply(string(input, "id"), string(input, "actor"), string(input, "content"), eventContext(input))),
|
|
73
|
+
define("discuss.defer", (input: OperationInput) => discussions.defer(string(input, "id"), optionalString(input, "reason"), eventContext(input))),
|
|
74
|
+
define("discuss.resume", (input: OperationInput) => discussions.resume(string(input, "id"), eventContext(input))),
|
|
75
|
+
define("discuss.settle", (input: OperationInput) => discussions.settle(string(input, "id"), string(input, "settlement"), eventContext(input))),
|
|
76
|
+
define("discuss.block", (input: OperationInput) => {
|
|
77
|
+
discussions.block(string(input, "id"), taskId(input), eventContext(input));
|
|
78
|
+
return { blocked: true };
|
|
79
|
+
}),
|
|
80
|
+
define("discuss.unblock", (input: OperationInput) => ({
|
|
81
|
+
unblocked: discussions.unblock(string(input, "id"), taskId(input), eventContext(input)),
|
|
82
|
+
})),
|
|
83
|
+
define("discuss.show", (input: OperationInput) => discussions.show(string(input, "id"))),
|
|
84
|
+
define("discuss.rounds", (input: OperationInput) => discussions.listRounds(string(input, "id"), optionalNumber(input, "after_round") ?? optionalNumber(input, "afterRound"), optionalNumber(input, "limit"))),
|
|
85
|
+
define("discuss.list", (input: OperationInput) => discussions.list({ state: optionalString(input, "state"), limit: optionalNumber(input, "limit") })),
|
|
86
|
+
];
|
|
87
|
+
}
|