@danypops/papyrus 0.14.0 → 0.15.1
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/extension/src/context-view.ts +41 -14
- package/package.json +1 -1
- package/src/authority-registry.ts +1 -1
- package/src/cli.ts +0 -36
- package/src/constants.ts +7 -10
- package/src/db.ts +25 -63
- package/src/domain/log-entry.ts +7 -5
- package/src/domain-services.ts +3 -2
- package/src/id-migration.ts +1 -5
- package/src/ports/log-store.ts +5 -4
- package/src/service.ts +10 -22
- package/src/adapters/in-memory-conversation-journal-store.ts +0 -48
- package/src/adapters/sqlite-discourse-store.ts +0 -325
- package/src/conversation-journal-service.ts +0 -87
- package/src/domain/conversation-journal.ts +0 -168
- package/src/domain/discourse-store.ts +0 -142
- package/src/ports/conversation-journal-store.ts +0 -17
|
@@ -117,7 +117,7 @@ class ContextViewport {
|
|
|
117
117
|
} else {
|
|
118
118
|
lines.push(theme.fg("dim", "No real usage reported yet — sizes below are Papyrus's own estimates only"));
|
|
119
119
|
}
|
|
120
|
-
lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth, this.breakdown.effectiveBudget ?? undefined));
|
|
120
|
+
lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth, this.breakdown.effectiveBudget ?? undefined, this.breakdown.totalTokens ?? undefined));
|
|
121
121
|
if (this.breakdown.overshootTokens > 0) {
|
|
122
122
|
lines.push(truncateToWidth(theme.fg("warning", `Estimates exceed real total by ~${this.breakdown.overshootTokens} tok — sizes below are approximate, not exact`), contentWidth, ""));
|
|
123
123
|
}
|
|
@@ -161,6 +161,26 @@ class ContextViewport {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Distributes `totalCells` proportionally across `weights` (parallel arrays), guaranteeing
|
|
166
|
+
* every genuinely-positive weight gets at least one cell when there is room for all of them
|
|
167
|
+
* to (totalCells >= weights.length) -- a real, nonzero segment must stay visible even when
|
|
168
|
+
* dwarfed by a much larger one, not round away to nothing. The largest resulting cell count
|
|
169
|
+
* absorbs whatever rounding leaves over or short, so the sum always equals totalCells exactly.
|
|
170
|
+
*/
|
|
171
|
+
function distributeCells(weights: readonly number[], totalCells: number): number[] {
|
|
172
|
+
const sum = weights.reduce((a, b) => a + b, 0);
|
|
173
|
+
if (sum <= 0 || totalCells <= 0 || weights.length === 0) return weights.map(() => 0);
|
|
174
|
+
let cells = weights.map((weight) => Math.round((weight / sum) * totalCells));
|
|
175
|
+
if (totalCells >= weights.length) cells = cells.map((count) => (count === 0 ? 1 : count));
|
|
176
|
+
const diff = totalCells - cells.reduce((a, b) => a + b, 0);
|
|
177
|
+
if (diff !== 0) {
|
|
178
|
+
const maxIndex = cells.indexOf(Math.max(...cells));
|
|
179
|
+
cells[maxIndex] = (cells[maxIndex] ?? 0) + diff;
|
|
180
|
+
}
|
|
181
|
+
return cells;
|
|
182
|
+
}
|
|
183
|
+
|
|
164
184
|
/**
|
|
165
185
|
* Renders the context window as one horizontal stacked bar: one colored run of block
|
|
166
186
|
* characters per USED segment, followed by a gray/dim run of "░" cells for the remaining,
|
|
@@ -169,23 +189,30 @@ class ContextViewport {
|
|
|
169
189
|
* divide-by-zero, since 0 used really does mean the whole window is empty right now.
|
|
170
190
|
*
|
|
171
191
|
* `capacity` is the real denominator (Papyrus's own effectiveBudget, matching the percentage
|
|
172
|
-
* already shown in the text line above this bar)
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
192
|
+
* already shown in the text line above this bar). `usedTokens` is the real, ground-truth used
|
|
193
|
+
* amount (breakdown.totalTokens) the used-vs-unused split is measured against -- NOT the sum of
|
|
194
|
+
* `segments`' own estimates. That distinction is load-bearing: a live-reported bug showed a
|
|
195
|
+
* fully solid bar with zero gray even though the header read "55.9% of usable budget", because
|
|
196
|
+
* the old code compared `capacity` against the SUM of estimated segments, which independently
|
|
197
|
+
* overshot both the real total and the capacity itself (a session whose message-history
|
|
198
|
+
* estimate alone summed to over 1.5M tokens against a real ~550k total) -- the exact estimate-
|
|
199
|
+
* overshoot dishonesty `overshootTokens` exists to surface elsewhere was silently defeating the
|
|
200
|
+
* bar's own gray/used split. `usedTokens` defaults to the segment sum only when omitted, for
|
|
201
|
+
* callers with no real total available. Segments still split the USED portion proportionally to
|
|
202
|
+
* their own estimated share of each other (via distributeCells, which also guarantees a tiny
|
|
203
|
+
* nonzero segment stays visible rather than rounding to nothing next to a much larger one).
|
|
176
204
|
*/
|
|
177
|
-
export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number, capacity?: number): string {
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
180
|
-
const
|
|
181
|
-
const usedWidth = capacity !== undefined
|
|
205
|
+
export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number, capacity?: number, usedTokens?: number): string {
|
|
206
|
+
const estimatedSum = segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
207
|
+
if (estimatedSum <= 0 || width <= 0) return theme.fg("dim", "░".repeat(Math.max(0, width)));
|
|
208
|
+
const realUsed = usedTokens ?? estimatedSum;
|
|
209
|
+
const usedWidth = capacity !== undefined ? Math.max(0, Math.min(width, Math.round((realUsed / capacity) * width))) : width;
|
|
182
210
|
|
|
183
|
-
|
|
211
|
+
const nonZero = segments.filter((segment) => segment.estimatedTokens > 0);
|
|
212
|
+
const cellCounts = distributeCells(nonZero.map((segment) => segment.estimatedTokens), usedWidth);
|
|
184
213
|
let output = "";
|
|
185
214
|
nonZero.forEach((segment, index) => {
|
|
186
|
-
const
|
|
187
|
-
const cells = isLast ? usedWidth - used : Math.round((segment.estimatedTokens / total) * usedWidth);
|
|
188
|
-
used += cells;
|
|
215
|
+
const cells = cellCounts[index] ?? 0;
|
|
189
216
|
if (cells > 0) output += theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(cells));
|
|
190
217
|
});
|
|
191
218
|
const emptyWidth = width - usedWidth;
|
package/package.json
CHANGED
|
@@ -25,7 +25,7 @@ import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
|
25
25
|
export type ArtifactAction = "create" | "link" | "status";
|
|
26
26
|
|
|
27
27
|
export interface AuthorityClaim {
|
|
28
|
-
/** Module id that owns this kind/subtype/relation, e.g. "
|
|
28
|
+
/** Module id that owns this kind/subtype/relation, e.g. "notes", "tasks". */
|
|
29
29
|
readonly owner: string;
|
|
30
30
|
/** kind may be undefined at a call site that has not yet resolved an artifact's effective kind (e.g. pre-template-resolution). */
|
|
31
31
|
matchesArtifact(kind: string | undefined, subtype: string | undefined): boolean;
|
package/src/cli.ts
CHANGED
|
@@ -71,7 +71,6 @@ const USAGE = `Usage:
|
|
|
71
71
|
papyrus migrate-ids mirror [--db <path>] --out <mirror-path> [--json]
|
|
72
72
|
papyrus migrate-ids validate --mirror <mirror-path> [--idmap <path>] [--json]
|
|
73
73
|
papyrus migrate-ids promote --mirror <mirror-path> [--db <path>] [--idmap <path>] [--force] [--json]
|
|
74
|
-
papyrus discourse store <action> --store-id <id> [--input-json <json>] [--json]
|
|
75
74
|
papyrus graph link <from> <relation> <to> [--json]
|
|
76
75
|
papyrus graph unlink <from> <relation> <to> [--json]
|
|
77
76
|
papyrus graph tree <id> [--depth <n>] [--max-nodes <n>] [--json]
|
|
@@ -308,36 +307,6 @@ export function runIdMigrationCli(args: string[]): string {
|
|
|
308
307
|
throw new Error("migrate-ids requires one of: mirror, validate, promote");
|
|
309
308
|
}
|
|
310
309
|
|
|
311
|
-
export async function runDiscourseCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
312
|
-
const json = args.includes("--json");
|
|
313
|
-
const positional: string[] = [];
|
|
314
|
-
let storeId: string | undefined;
|
|
315
|
-
let operationInput: Record<string, unknown> = {};
|
|
316
|
-
for (let index = 0; index < args.length; index++) {
|
|
317
|
-
const argument = args[index]!;
|
|
318
|
-
if (argument === "--json") continue;
|
|
319
|
-
if (argument === "--store-id" || argument === "--input-json") {
|
|
320
|
-
const value = args[++index];
|
|
321
|
-
if (!value) throw new Error(`${argument} requires a value`);
|
|
322
|
-
if (argument === "--store-id") storeId = value;
|
|
323
|
-
else {
|
|
324
|
-
const parsed = JSON.parse(value) as unknown;
|
|
325
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("--input-json must be a JSON object");
|
|
326
|
-
operationInput = parsed as Record<string, unknown>;
|
|
327
|
-
}
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
if (argument.startsWith("--")) throw new Error(`unknown discourse option ${argument}`);
|
|
331
|
-
positional.push(argument);
|
|
332
|
-
}
|
|
333
|
-
if (positional.length !== 2 || positional[0] !== "store") throw new Error("discourse requires `store <action>`");
|
|
334
|
-
if (!storeId) throw new Error("discourse store requires --store-id");
|
|
335
|
-
const result = await client.call<Record<string, unknown>, unknown>("discourse.store", {
|
|
336
|
-
action: positional[1], store_id: storeId, ...operationInput,
|
|
337
|
-
});
|
|
338
|
-
return json ? JSON.stringify(result) : `Discourse store ${positional[1]} completed.`;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
310
|
export async function runSkillCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
|
|
342
311
|
const json = args.includes("--json");
|
|
343
312
|
const positional: string[] = [];
|
|
@@ -1324,11 +1293,6 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
|
|
|
1324
1293
|
console.log(await runTaskCli(args.slice(1), client));
|
|
1325
1294
|
return;
|
|
1326
1295
|
}
|
|
1327
|
-
if (command === "discourse") {
|
|
1328
|
-
const client = await connectPapyrusClient();
|
|
1329
|
-
console.log(await runDiscourseCli(args.slice(1), client));
|
|
1330
|
-
return;
|
|
1331
|
-
}
|
|
1332
1296
|
if (command === "skills") {
|
|
1333
1297
|
const client = await connectPapyrusClient();
|
|
1334
1298
|
console.log(await runSkillCli(args.slice(1), client));
|
package/src/constants.ts
CHANGED
|
@@ -7,14 +7,9 @@ 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 = 12;
|
|
11
11
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
12
|
-
|
|
13
|
-
export const DISCOURSE_QUERY_MAX_LIMIT = 100;
|
|
14
|
-
export const DISCOURSE_CONTENT_MAX_BYTES = 65_536;
|
|
15
|
-
export const DISCOURSE_EVENT_RETENTION_DEFAULT = 1_000;
|
|
16
|
-
export const DISCOURSE_EVENT_RETENTION_MAX = 10_000;
|
|
17
|
-
export const DISCOURSE_PARTICIPANT_MAX_COUNT = 100;
|
|
12
|
+
|
|
18
13
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
19
14
|
export const DB_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60_000;
|
|
20
15
|
export const GATE_COMMAND_TIMEOUT_MS = 30_000;
|
|
@@ -35,7 +30,8 @@ export const CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN = 4;
|
|
|
35
30
|
* tree when estimating /context's message-history and task segments. Both are genuine trees
|
|
36
31
|
* built from external, mutable state (a session file; the live Task graph) -- the node bound
|
|
37
32
|
* is a defensive measure against a corrupted/adversarial parentId chain forming an accidental
|
|
38
|
-
* cycle, matching the same cycle-safety discipline
|
|
33
|
+
* cycle, matching the same cycle-safety discipline established by the (since-removed;
|
|
34
|
+
* see Doc "ConversationJournal design record") ConversationJournal domain's own reply-chain
|
|
39
35
|
* traversal and deliberately hardening past a real, confirmed gap in Pi's own getBranch() (no
|
|
40
36
|
* cycle guard at all). Set generously: a real, ordinary (non-branching) long-running session
|
|
41
37
|
* is one long linear chain, so a naively small bound truncates the walk after counting only a
|
|
@@ -97,8 +93,9 @@ export const SKILL_MAX_RENDERED_BYTES = 1_048_576;
|
|
|
97
93
|
* to (existing Tasks/Rules/Docs via ordinary edges, not just its own static body/extra
|
|
98
94
|
* fields), and a Skill can link to and invoke other Skills. Both traversals are bounded and
|
|
99
95
|
* cycle-safe -- a skill-calls-skill edge cycle must not infinite-loop invocation, matching
|
|
100
|
-
* the cycle-safety discipline
|
|
101
|
-
*
|
|
96
|
+
* the same cycle-safety discipline established by task dependency graphs and the
|
|
97
|
+
* (since-removed; see Doc "ConversationJournal design record") ConversationJournal domain's
|
|
98
|
+
* own reply chains.
|
|
102
99
|
*/
|
|
103
100
|
export const SKILL_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
|
|
104
101
|
export const SKILL_INVOCATION_MAX_CALL_DEPTH = 4;
|
package/src/db.ts
CHANGED
|
@@ -147,67 +147,6 @@ CREATE TABLE IF NOT EXISTS task_views (
|
|
|
147
147
|
updated_at TEXT NOT NULL,
|
|
148
148
|
CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
|
|
149
149
|
);
|
|
150
|
-
CREATE TABLE IF NOT EXISTS discourse_threads (
|
|
151
|
-
store_id TEXT NOT NULL,
|
|
152
|
-
forum_id TEXT NOT NULL,
|
|
153
|
-
topic_id TEXT NOT NULL,
|
|
154
|
-
thread_id TEXT NOT NULL,
|
|
155
|
-
artifact_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
|
|
156
|
-
PRIMARY KEY (store_id, forum_id, topic_id, thread_id)
|
|
157
|
-
);
|
|
158
|
-
CREATE TABLE IF NOT EXISTS discourse_posts (
|
|
159
|
-
store_id TEXT NOT NULL,
|
|
160
|
-
sequence INTEGER NOT NULL,
|
|
161
|
-
id TEXT NOT NULL,
|
|
162
|
-
artifact_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
|
|
163
|
-
operation_id TEXT NOT NULL,
|
|
164
|
-
command_json TEXT NOT NULL,
|
|
165
|
-
forum_id TEXT NOT NULL,
|
|
166
|
-
topic_id TEXT NOT NULL,
|
|
167
|
-
thread_id TEXT NOT NULL,
|
|
168
|
-
author_id TEXT NOT NULL,
|
|
169
|
-
content_json TEXT NOT NULL,
|
|
170
|
-
timestamp INTEGER NOT NULL,
|
|
171
|
-
correlation_id TEXT,
|
|
172
|
-
causation_id TEXT,
|
|
173
|
-
reply_to_post_id TEXT,
|
|
174
|
-
references_json TEXT NOT NULL,
|
|
175
|
-
question_type TEXT CHECK (question_type IN ('question', 'answer')),
|
|
176
|
-
response_id TEXT,
|
|
177
|
-
target_id TEXT,
|
|
178
|
-
PRIMARY KEY (store_id, id),
|
|
179
|
-
UNIQUE (store_id, operation_id),
|
|
180
|
-
UNIQUE (store_id, sequence)
|
|
181
|
-
);
|
|
182
|
-
CREATE INDEX IF NOT EXISTS discourse_posts_thread_idx ON discourse_posts(store_id, forum_id, topic_id, thread_id, sequence);
|
|
183
|
-
CREATE TABLE IF NOT EXISTS discourse_events (
|
|
184
|
-
store_id TEXT NOT NULL,
|
|
185
|
-
sequence INTEGER NOT NULL,
|
|
186
|
-
event_json TEXT NOT NULL,
|
|
187
|
-
PRIMARY KEY (store_id, sequence)
|
|
188
|
-
);
|
|
189
|
-
CREATE TABLE IF NOT EXISTS discourse_cursors (
|
|
190
|
-
store_id TEXT NOT NULL,
|
|
191
|
-
consumer_id TEXT NOT NULL,
|
|
192
|
-
sequence INTEGER NOT NULL,
|
|
193
|
-
PRIMARY KEY (store_id, consumer_id)
|
|
194
|
-
);
|
|
195
|
-
CREATE TABLE IF NOT EXISTS discourse_projection_cursors (
|
|
196
|
-
store_id TEXT NOT NULL,
|
|
197
|
-
projection_id TEXT NOT NULL,
|
|
198
|
-
sequence INTEGER NOT NULL,
|
|
199
|
-
PRIMARY KEY (store_id, projection_id)
|
|
200
|
-
);
|
|
201
|
-
CREATE TRIGGER IF NOT EXISTS discourse_threads_artifact_type BEFORE INSERT ON discourse_threads
|
|
202
|
-
WHEN NOT EXISTS (SELECT 1 FROM artifacts WHERE id = NEW.artifact_id AND kind = 'doc' AND subtype = 'context-thread')
|
|
203
|
-
BEGIN SELECT RAISE(ABORT, 'discourse thread artifact must be a context-thread Doc'); END;
|
|
204
|
-
CREATE TRIGGER IF NOT EXISTS discourse_posts_artifact_type BEFORE INSERT ON discourse_posts
|
|
205
|
-
WHEN NOT EXISTS (SELECT 1 FROM artifacts WHERE id = NEW.artifact_id AND kind = 'doc' AND subtype = 'context-message')
|
|
206
|
-
BEGIN SELECT RAISE(ABORT, 'discourse post artifact must be a context-message Doc'); END;
|
|
207
|
-
CREATE TRIGGER IF NOT EXISTS discourse_artifact_type_immutable BEFORE UPDATE OF kind, subtype ON artifacts
|
|
208
|
-
WHEN (EXISTS (SELECT 1 FROM discourse_threads WHERE artifact_id = OLD.id) AND (NEW.kind != 'doc' OR NEW.subtype != 'context-thread'))
|
|
209
|
-
OR (EXISTS (SELECT 1 FROM discourse_posts WHERE artifact_id = OLD.id) AND (NEW.kind != 'doc' OR NEW.subtype != 'context-message'))
|
|
210
|
-
BEGIN SELECT RAISE(ABORT, 'discourse Context Mesh artifact type is immutable'); END;
|
|
211
150
|
CREATE TABLE IF NOT EXISTS artifact_events (
|
|
212
151
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
213
152
|
artifact_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
@@ -303,8 +242,6 @@ INSERT OR IGNORE INTO relation_names VALUES ('gates','This rule gates that task
|
|
|
303
242
|
INSERT OR IGNORE INTO relation_names VALUES ('triggers','This skill applies to that work (skill→task)');
|
|
304
243
|
INSERT OR IGNORE INTO relation_names VALUES ('contains','Parent contains a nested artifact (any→any)');
|
|
305
244
|
INSERT OR IGNORE INTO relation_names VALUES ('part_of','Artifact belongs to a parent artifact (any→any)');
|
|
306
|
-
INSERT OR IGNORE INTO relation_names VALUES ('reply_to','Append-only message replies to another message in the same thread');
|
|
307
|
-
INSERT OR IGNORE INTO relation_names VALUES ('discusses','Message or turn concerns a verified artifact');
|
|
308
245
|
CREATE INDEX IF NOT EXISTS edges_to_id_idx ON edges(to_id);
|
|
309
246
|
`;
|
|
310
247
|
|
|
@@ -349,6 +286,7 @@ const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; check
|
|
|
349
286
|
{ version: 1, name: "baseline", checksum: "af81e9f51d915ba538af3f468dc044bda5e2c5a5f5037e9c7c01540f87288763" },
|
|
350
287
|
{ version: 2, name: "docs-rules-skills-project-scope", checksum: "8b16d8f631ad628f4799ff09b1ebe8be28343e4f677d52bf2a39a8bedc19e64e" },
|
|
351
288
|
{ version: 3, name: "log-domain", checksum: "c87f43c22b2608619ada9a529d7899ae74b7f38cd554135c8034116fc96e1eff" },
|
|
289
|
+
{ version: 4, name: "remove-discourse", checksum: "b923f41c44460f0aaeb2f4e60e28f8b8e1425d03f527955bd991434b46de4c82" },
|
|
352
290
|
];
|
|
353
291
|
|
|
354
292
|
export function migrationLedger(db: Db): ModuleMigrationRow[] {
|
|
@@ -675,6 +613,30 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
675
613
|
`);
|
|
676
614
|
applied.push("log-domain");
|
|
677
615
|
}
|
|
616
|
+
if (schemaVersion(db) === 11) {
|
|
617
|
+
// Removes Discourse's Papyrus-embedded storage entirely: confirmed zero rows in every
|
|
618
|
+
// discourse_* table and zero Docs carrying the reserved context-thread/context-message
|
|
619
|
+
// subtypes in the real production database before this was written -- Discourse's real
|
|
620
|
+
// home is now the standalone @danypops/discourse package plus host adapters, and
|
|
621
|
+
// Papyrus's own copy never had a single real caller since it was built. IF EXISTS
|
|
622
|
+
// throughout: a database that never actually reached the v5->v6 discourse-context-mesh
|
|
623
|
+
// step in the first place (e.g. a test fixture that starts partway through the chain)
|
|
624
|
+
// must not fail here just because there was nothing to remove.
|
|
625
|
+
db.exec(`
|
|
626
|
+
DROP TRIGGER IF EXISTS discourse_artifact_type_immutable;
|
|
627
|
+
DROP TRIGGER IF EXISTS discourse_posts_artifact_type;
|
|
628
|
+
DROP TRIGGER IF EXISTS discourse_threads_artifact_type;
|
|
629
|
+
DROP INDEX IF EXISTS discourse_posts_thread_idx;
|
|
630
|
+
DROP TABLE IF EXISTS discourse_projection_cursors;
|
|
631
|
+
DROP TABLE IF EXISTS discourse_cursors;
|
|
632
|
+
DROP TABLE IF EXISTS discourse_events;
|
|
633
|
+
DROP TABLE IF EXISTS discourse_posts;
|
|
634
|
+
DROP TABLE IF EXISTS discourse_threads;
|
|
635
|
+
DELETE FROM relation_names WHERE name IN ('reply_to', 'discusses');
|
|
636
|
+
PRAGMA user_version = 12;
|
|
637
|
+
`);
|
|
638
|
+
applied.push("remove-discourse");
|
|
639
|
+
}
|
|
678
640
|
if (schemaVersion(db) !== SQLITE_SCHEMA_VERSION) throw new Error(`no explicit migration path from database schema ${from}`);
|
|
679
641
|
});
|
|
680
642
|
if (schemaVersion(db) === SQLITE_SCHEMA_VERSION) ensureCoreLedger(db, true);
|
package/src/domain/log-entry.ts
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* first-class concern here in a way it deliberately is NOT for the permanent
|
|
11
11
|
* artifact_events/task_events audit trails: logs are meant to be rotated, not kept forever.
|
|
12
12
|
*
|
|
13
|
-
* Mirrors
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Mirrors the (since-removed; see Doc "ConversationJournal design record") ConversationJournal
|
|
14
|
+
* domain's own discipline (idempotency via a caller-constructed composite operationId,
|
|
15
|
+
* explicit non-silent truncation) since both are append-only, externally-sourced record
|
|
16
|
+
* streams -- but logs have no reply structure and do carry a
|
|
16
17
|
* retention policy, which a durable conversation record deliberately does not.
|
|
17
18
|
*/
|
|
18
19
|
|
|
@@ -49,8 +50,9 @@ export interface LogEntry {
|
|
|
49
50
|
/**
|
|
50
51
|
* Idempotency key. Must be a composite the caller constructs (e.g. `${sessionId}:${turn}`),
|
|
51
52
|
* never a bare upstream id alone -- a source's own local ids are commonly unique only
|
|
52
|
-
* within one recording run, not globally, matching
|
|
53
|
-
*
|
|
53
|
+
* within one recording run, not globally, matching the same operationId discipline
|
|
54
|
+
* established by the (since-removed) ConversationJournal domain and the concrete case
|
|
55
|
+
* it generalizes from (Pi's /tree lessons).
|
|
54
56
|
*/
|
|
55
57
|
readonly operationId: string;
|
|
56
58
|
readonly sessionId?: string;
|
package/src/domain-services.ts
CHANGED
|
@@ -388,8 +388,9 @@ function skillInvocationBody(skill: Artifact): string {
|
|
|
388
388
|
* workflow execution already uses for skill-to-task edges): invoking the parent recursively
|
|
389
389
|
* composes the linked skill's own invocation. Bounded and cycle-safe -- a skill-calls-skill
|
|
390
390
|
* edge cycle degrades to a marker instead of infinite-looping, matching the cycle-safety
|
|
391
|
-
* discipline
|
|
392
|
-
*
|
|
391
|
+
* discipline established by task dependency graphs and the (since-removed; see Doc
|
|
392
|
+
* "ConversationJournal design record") ConversationJournal domain's own reply chains.
|
|
393
|
+
* `visited` and `depth` are recursion-internal; callers should not pass them.
|
|
393
394
|
*/
|
|
394
395
|
export function skillInvocation(artifacts: ArtifactStore, id: string, visited: Set<string> = new Set(), depth = 0): string {
|
|
395
396
|
const skill = requireKind(artifacts, id, "skill");
|
package/src/id-migration.ts
CHANGED
|
@@ -23,9 +23,7 @@
|
|
|
23
23
|
* replaces old ids wherever they appear inside a known set of free-text/JSON columns (title,
|
|
24
24
|
* body, extra, and the two Task-event text fields) — this is how a prose cross-reference like
|
|
25
25
|
* "see task some-old-id for the parent epic" keeps pointing at the right artifact after its id
|
|
26
|
-
* changes.
|
|
27
|
-
* explicitly NOT scanned — that is Discourse-internal structure this tool does not have enough
|
|
28
|
-
* context on yet, tracked as a known limitation rather than guessed at.
|
|
26
|
+
* changes.
|
|
29
27
|
*/
|
|
30
28
|
import type { Db } from "./db.ts";
|
|
31
29
|
import { inTransaction } from "./db.ts";
|
|
@@ -56,8 +54,6 @@ const FK_COLUMNS: ReadonlyArray<{ table: string; column: string }> = [
|
|
|
56
54
|
{ table: "task_events", column: "task_id" },
|
|
57
55
|
{ table: "task_scopes", column: "task_id" },
|
|
58
56
|
{ table: "task_views", column: "root_task_id" },
|
|
59
|
-
{ table: "discourse_threads", column: "artifact_id" },
|
|
60
|
-
{ table: "discourse_posts", column: "artifact_id" },
|
|
61
57
|
{ table: "artifact_events", column: "artifact_id" },
|
|
62
58
|
{ table: "artifact_events", column: "related_id" },
|
|
63
59
|
];
|
package/src/ports/log-store.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { LogEntry, LogSource } from "../domain/log-entry.ts";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Persistence port for the `log` domain. Deliberately minimal, matching
|
|
5
|
-
*
|
|
6
|
-
* insert, bounded-at-the-service-layer reads)
|
|
7
|
-
* only it knows real row counts -- retention
|
|
4
|
+
* Persistence port for the `log` domain. Deliberately minimal, matching the split established
|
|
5
|
+
* by the (since-removed; see Doc "ConversationJournal design record") ConversationJournalStore:
|
|
6
|
+
* this is dumb storage (idempotency-key lookup, insert, bounded-at-the-service-layer reads)
|
|
7
|
+
* plus one operation the store must own because only it knows real row counts -- retention
|
|
8
|
+
* trimming.
|
|
8
9
|
*/
|
|
9
10
|
export interface LogStore {
|
|
10
11
|
ensureSource(sourceId: string, label: string, projectRoot: string | null): LogSource;
|
package/src/service.ts
CHANGED
|
@@ -3,14 +3,12 @@ import { VERSION } from "./version.ts";
|
|
|
3
3
|
import { migrateDb, openDb, schemaVersion } from "./db.ts";
|
|
4
4
|
import { SQLiteArtifactStore } from "./adapters/sqlite-artifact-store.ts";
|
|
5
5
|
import { SQLiteGateRunner } from "./adapters/sqlite-gate-runner.ts";
|
|
6
|
-
import { SQLiteDiscourseStore } from "./adapters/sqlite-discourse-store.ts";
|
|
7
6
|
import { SQLiteArtifactScopeStore } from "./adapters/sqlite-artifact-scope-store.ts";
|
|
8
7
|
import { SQLiteGraphProjectionStore } from "./adapters/sqlite-graph-projection-store.ts";
|
|
9
8
|
import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
|
|
10
9
|
import { SQLiteTaskEventStore } from "./adapters/sqlite-task-event-store.ts";
|
|
11
10
|
import { SQLiteTaskScopeStore } from "./adapters/sqlite-task-scope-store.ts";
|
|
12
11
|
import type { CreateArtifactInput } from "./domain/artifact.ts";
|
|
13
|
-
import { DISCOURSE_RELATIONS, isDiscourseSubtype } from "./domain/discourse-store.ts";
|
|
14
12
|
import { AuthorityRegistry, AuthorizedArtifactWriter, type AuthorityClaim } from "./authority-registry.ts";
|
|
15
13
|
import type { TaskEventContext } from "./domain/task-event.ts";
|
|
16
14
|
import type { TaskViewMode } from "./domain/task-scope.ts";
|
|
@@ -41,12 +39,13 @@ import { tasksOperations, TASKS_OPERATION_NAMES } from "./modules/tasks.ts";
|
|
|
41
39
|
* no domain owns creation/linking/traversal for every kind, the same way system.migrate
|
|
42
40
|
* has no owning module) and two permanent composition-root exceptions (rules.injectable
|
|
43
41
|
* needs tasks.active(); skills.instantiate branches into tasks.create()) -- see
|
|
44
|
-
* src/modules/rules.ts and src/modules/skills.ts's module comments.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
42
|
+
* src/modules/rules.ts and src/modules/skills.ts's module comments. Discourse's own
|
|
43
|
+
* Papyrus-embedded storage (discourse.store) was removed entirely -- zero real callers
|
|
44
|
+
* were ever confirmed against it; Discourse's real home is the standalone
|
|
45
|
+
* @danypops/discourse package plus host adapters.
|
|
47
46
|
*/
|
|
48
47
|
const COMPOSITION_ROOT_OPERATION_NAMES = [
|
|
49
|
-
"system.migrate", "
|
|
48
|
+
"system.migrate", "artifact.create", "artifact.query", "artifact.show",
|
|
50
49
|
"graph.link", "graph.unlink", "graph.tree", "graph.status", "graph.history", "gates.run",
|
|
51
50
|
"rules.injectable", "skills.instantiate",
|
|
52
51
|
] as const;
|
|
@@ -129,13 +128,6 @@ function templateSubtype(artifacts: ArtifactStore, templateId: string | undefine
|
|
|
129
128
|
*/
|
|
130
129
|
const GENERIC_CALLER = "generic";
|
|
131
130
|
|
|
132
|
-
const discourseAuthorityClaim: AuthorityClaim = {
|
|
133
|
-
owner: "discourse",
|
|
134
|
-
matchesArtifact: (_kind, subtype) => isDiscourseSubtype(subtype),
|
|
135
|
-
matchesRelation: (relation) => DISCOURSE_RELATIONS.has(relation),
|
|
136
|
-
denyMessage: (action) => action === "link" ? "forum-owned Context Mesh links require discourse.store" : "forum-owned Context Mesh Docs require discourse.store",
|
|
137
|
-
};
|
|
138
|
-
|
|
139
131
|
const notesAuthorityClaim: AuthorityClaim = {
|
|
140
132
|
owner: "notes",
|
|
141
133
|
matchesArtifact: (kind, subtype) => kind === "doc" && subtype === NOTE_SUBTYPE,
|
|
@@ -158,7 +150,7 @@ const tasksAuthorityClaim: AuthorityClaim = {
|
|
|
158
150
|
|
|
159
151
|
function createAuthorityRegistry(): AuthorityRegistry {
|
|
160
152
|
const authority = new AuthorityRegistry();
|
|
161
|
-
authority.claimAll([
|
|
153
|
+
authority.claimAll([notesAuthorityClaim, tasksAuthorityClaim]);
|
|
162
154
|
return authority;
|
|
163
155
|
}
|
|
164
156
|
|
|
@@ -182,7 +174,6 @@ function handlers(
|
|
|
182
174
|
gates: GateRunner,
|
|
183
175
|
tasks: Tasks,
|
|
184
176
|
notes: Notes,
|
|
185
|
-
discourse: SQLiteDiscourseStore,
|
|
186
177
|
events: TaskEventStore,
|
|
187
178
|
scopes: TaskScopeStore,
|
|
188
179
|
migrate: () => unknown,
|
|
@@ -219,7 +210,6 @@ function handlers(
|
|
|
219
210
|
});
|
|
220
211
|
return {
|
|
221
212
|
"system.migrate": () => migrate(),
|
|
222
|
-
"discourse.store": (input) => discourse.execute(input),
|
|
223
213
|
"artifact.create": (input) => {
|
|
224
214
|
const normalized = normalizeCreateInput(input);
|
|
225
215
|
authority.requireArtifactAllowed(normalized.kind, normalized.subtype ?? templateSubtype(artifacts, normalized.templateId), "create", GENERIC_CALLER);
|
|
@@ -357,10 +347,9 @@ function handlers(
|
|
|
357
347
|
"skills.instantiate": (input) => {
|
|
358
348
|
const templateId = string(input, "template_id");
|
|
359
349
|
const template = artifacts.get(templateId);
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
authority.requireArtifactAllowed(undefined, templateSubtype(artifacts, templateId), "create", GENERIC_CALLER);
|
|
350
|
+
// Note ownership for a non-task template target is enforced inside instantiateTemplate's
|
|
351
|
+
// own rejectsNoteTemplate for the non-task branch below -- nothing else currently claims
|
|
352
|
+
// an unresolved (pre-template-resolution) kind, so there is no check to perform here.
|
|
364
353
|
if (template?.extra["targetKind"] !== "task") return instantiateTemplate(artifacts, templateId, normalizeCreateInput(input), authority, eventContext(input));
|
|
365
354
|
return tasks.create({
|
|
366
355
|
title: optionalString(input, "title") as string,
|
|
@@ -389,7 +378,6 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
389
378
|
const scopes = new SQLiteTaskScopeStore(db);
|
|
390
379
|
const tasks = new Tasks(artifacts, gates, focus, events, scopes);
|
|
391
380
|
const notes = new Notes(artifacts);
|
|
392
|
-
const discourse = new SQLiteDiscourseStore(db, artifacts);
|
|
393
381
|
const projections = new SQLiteGraphProjectionStore(db);
|
|
394
382
|
const artifactScopes = new SQLiteArtifactScopeStore(db);
|
|
395
383
|
const logs = new Logs(new SQLiteLogStore(db));
|
|
@@ -402,7 +390,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
402
390
|
moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));
|
|
403
391
|
moduleRegistry.registerAll(skillsOperations({ artifacts, events, scopes, artifactScopes, authority }));
|
|
404
392
|
moduleRegistry.registerAll(graphProjectionOperations(artifacts, projections, authority));
|
|
405
|
-
const registry = handlers(artifacts, gates, tasks, notes,
|
|
393
|
+
const registry = handlers(artifacts, gates, tasks, notes, events, scopes, () => migrateDb(db), moduleRegistry, authority);
|
|
406
394
|
const state = (): SchemaState => {
|
|
407
395
|
const current = schemaVersion(db);
|
|
408
396
|
return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import type { JournalPost, JournalThread } from "../domain/conversation-journal.ts";
|
|
2
|
-
import type { ConversationJournalStore } from "../ports/conversation-journal-store.ts";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Bounded in-memory conformance fixture -- the reference implementation the
|
|
6
|
-
* conversationJournalConformanceSuite is proven against first, before any real
|
|
7
|
-
* persistence backend needs to satisfy the same contract.
|
|
8
|
-
*/
|
|
9
|
-
export class InMemoryConversationJournalStore implements ConversationJournalStore {
|
|
10
|
-
private readonly threads = new Map<string, JournalThread>();
|
|
11
|
-
private readonly posts = new Map<string, JournalPost>();
|
|
12
|
-
private readonly postIdsByOperationId = new Map<string, string>();
|
|
13
|
-
private readonly postIdsByThread = new Map<string, string[]>();
|
|
14
|
-
|
|
15
|
-
ensureThread(threadId: string): JournalThread {
|
|
16
|
-
const existing = this.threads.get(threadId);
|
|
17
|
-
if (existing) return existing;
|
|
18
|
-
const thread: JournalThread = { id: threadId, createdAt: new Date().toISOString() };
|
|
19
|
-
this.threads.set(threadId, thread);
|
|
20
|
-
return thread;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
getThread(threadId: string): JournalThread | undefined {
|
|
24
|
-
return this.threads.get(threadId);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
findPostByOperationId(operationId: string): JournalPost | undefined {
|
|
28
|
-
const postId = this.postIdsByOperationId.get(operationId);
|
|
29
|
-
return postId ? this.posts.get(postId) : undefined;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
insertPost(post: JournalPost): void {
|
|
33
|
-
this.posts.set(post.id, post);
|
|
34
|
-
this.postIdsByOperationId.set(post.operationId, post.id);
|
|
35
|
-
const ids = this.postIdsByThread.get(post.threadId) ?? [];
|
|
36
|
-
ids.push(post.id);
|
|
37
|
-
this.postIdsByThread.set(post.threadId, ids);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
getPost(id: string): JournalPost | undefined {
|
|
41
|
-
return this.posts.get(id);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
postsForThread(threadId: string): readonly JournalPost[] {
|
|
45
|
-
const ids = this.postIdsByThread.get(threadId) ?? [];
|
|
46
|
-
return ids.map((id) => this.posts.get(id)!);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
@@ -1,325 +0,0 @@
|
|
|
1
|
-
import { DISCOURSE_PARTICIPANT_MAX_COUNT } from "../constants.ts";
|
|
2
|
-
import type { Db } from "../db.ts";
|
|
3
|
-
import { inTransaction } from "../db.ts";
|
|
4
|
-
import {
|
|
5
|
-
appendCommand,
|
|
6
|
-
DISCOURSE_MESSAGE_SUBTYPE,
|
|
7
|
-
DISCOURSE_THREAD_SUBTYPE,
|
|
8
|
-
eventRetention,
|
|
9
|
-
nonNegativeInteger,
|
|
10
|
-
optionalString,
|
|
11
|
-
queryLimit,
|
|
12
|
-
requiredString,
|
|
13
|
-
threadAddress,
|
|
14
|
-
type AppendPostCommand,
|
|
15
|
-
type DiscourseEvent,
|
|
16
|
-
type DiscourseEventType,
|
|
17
|
-
type JsonValue,
|
|
18
|
-
type OpenQuestion,
|
|
19
|
-
type Page,
|
|
20
|
-
type Post,
|
|
21
|
-
type ProjectionRecord,
|
|
22
|
-
type ThreadSummary,
|
|
23
|
-
type TopicSummary,
|
|
24
|
-
} from "../domain/discourse-store.ts";
|
|
25
|
-
import type { AtomicArtifactStore } from "../ports/atomic-artifact-store.ts";
|
|
26
|
-
|
|
27
|
-
interface QuestionColumns {
|
|
28
|
-
questionType?: "question" | "answer";
|
|
29
|
-
responseId?: string;
|
|
30
|
-
targetId?: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
type Row = Record<string, unknown>;
|
|
34
|
-
|
|
35
|
-
function rowString(row: Row, name: string): string {
|
|
36
|
-
const value = row[name];
|
|
37
|
-
if (typeof value !== "string") throw new Error(`invalid persisted ${name}`);
|
|
38
|
-
return value;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function rowOptionalString(row: Row, name: string): string | undefined {
|
|
42
|
-
const value = row[name];
|
|
43
|
-
return typeof value === "string" ? value : undefined;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function rowNumber(row: Row, name: string): number {
|
|
47
|
-
const value = Number(row[name]);
|
|
48
|
-
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid persisted ${name}`);
|
|
49
|
-
return value;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function parseJson<T>(row: Row, name: string): T {
|
|
53
|
-
return JSON.parse(rowString(row, name)) as T;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function postFromRow(row: Row): Post {
|
|
57
|
-
return {
|
|
58
|
-
id: rowString(row, "id"),
|
|
59
|
-
sequence: rowNumber(row, "sequence"),
|
|
60
|
-
operationId: rowString(row, "operation_id"),
|
|
61
|
-
forumId: rowString(row, "forum_id"),
|
|
62
|
-
topicId: rowString(row, "topic_id"),
|
|
63
|
-
threadId: rowString(row, "thread_id"),
|
|
64
|
-
authorId: rowString(row, "author_id"),
|
|
65
|
-
content: parseJson<JsonValue>(row, "content_json"),
|
|
66
|
-
timestamp: rowNumber(row, "timestamp"),
|
|
67
|
-
references: parseJson(row, "references_json"),
|
|
68
|
-
...(rowOptionalString(row, "correlation_id") ? { correlationId: rowString(row, "correlation_id") } : {}),
|
|
69
|
-
...(rowOptionalString(row, "causation_id") ? { causationId: rowString(row, "causation_id") } : {}),
|
|
70
|
-
...(rowOptionalString(row, "reply_to_post_id") ? { replyToPostId: rowString(row, "reply_to_post_id") } : {}),
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function page<T>(items: T[], limit: number, sequenceOf?: (item: T) => number): Page<T> {
|
|
75
|
-
const truncated = items.length > limit;
|
|
76
|
-
const selected = items.slice(0, limit);
|
|
77
|
-
const last = selected.at(-1);
|
|
78
|
-
return {
|
|
79
|
-
items: selected,
|
|
80
|
-
truncated,
|
|
81
|
-
completeness: truncated ? "truncated" : "complete",
|
|
82
|
-
...(truncated && last !== undefined && sequenceOf ? { nextSequence: sequenceOf(last) } : {}),
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function questionColumns(content: JsonValue): QuestionColumns {
|
|
87
|
-
if (typeof content !== "object" || content === null || Array.isArray(content)) return {};
|
|
88
|
-
const type = content["type"];
|
|
89
|
-
const responseId = content["responseId"];
|
|
90
|
-
const targetId = content["targetId"];
|
|
91
|
-
if ((type !== "question" && type !== "answer") || typeof responseId !== "string" || responseId.length === 0) return {};
|
|
92
|
-
return { questionType: type, responseId, ...(typeof targetId === "string" && targetId.length > 0 ? { targetId } : {}) };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function eventsFor(command: AppendPostCommand, postId: string, timestamp: number, firstSequence: number): DiscourseEvent[] {
|
|
96
|
-
const question = questionColumns(command.content);
|
|
97
|
-
const metadata: Array<{ type: DiscourseEventType; responseId?: string }> = [
|
|
98
|
-
{ type: "post-added" },
|
|
99
|
-
{ type: "thread-changed" },
|
|
100
|
-
];
|
|
101
|
-
if (question.questionType) {
|
|
102
|
-
metadata.push({
|
|
103
|
-
type: question.questionType === "question" ? "question-opened" : "question-answered",
|
|
104
|
-
responseId: question.responseId,
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
return metadata.map((event, index) => ({
|
|
108
|
-
schemaVersion: "discourse.event.v1",
|
|
109
|
-
type: event.type,
|
|
110
|
-
sequence: firstSequence + index,
|
|
111
|
-
timestamp,
|
|
112
|
-
forumId: command.forumId,
|
|
113
|
-
topicId: command.topicId,
|
|
114
|
-
threadId: command.threadId,
|
|
115
|
-
postId,
|
|
116
|
-
operationId: command.operationId,
|
|
117
|
-
...(command.correlationId ? { correlationId: command.correlationId } : {}),
|
|
118
|
-
...(command.causationId ? { causationId: command.causationId } : {}),
|
|
119
|
-
...(event.responseId ? { responseId: event.responseId } : {}),
|
|
120
|
-
}));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function bodyFor(content: JsonValue): string {
|
|
124
|
-
return typeof content === "string" ? content : JSON.stringify(content, null, 2);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Durable graph adapter used only behind the Discourse application mutation boundary. */
|
|
128
|
-
export class SQLiteDiscourseStore {
|
|
129
|
-
constructor(private readonly db: Db, private readonly artifacts: AtomicArtifactStore) {}
|
|
130
|
-
|
|
131
|
-
execute(input: Record<string, unknown>): unknown {
|
|
132
|
-
const action = requiredString(input["action"], "action");
|
|
133
|
-
const storeId = requiredString(input["store_id"], "store_id");
|
|
134
|
-
switch (action) {
|
|
135
|
-
case "append":
|
|
136
|
-
return this.append(
|
|
137
|
-
storeId,
|
|
138
|
-
appendCommand(input["command"]),
|
|
139
|
-
requiredString(input["post_id"], "post_id"),
|
|
140
|
-
nonNegativeInteger(input["timestamp"], "timestamp"),
|
|
141
|
-
eventRetention(input["event_retention"]),
|
|
142
|
-
);
|
|
143
|
-
case "read_thread":
|
|
144
|
-
return this.readThread(storeId, input);
|
|
145
|
-
case "list_topics":
|
|
146
|
-
return this.listTopics(storeId, requiredString(input["forumId"], "forumId"), queryLimit(input["limit"]));
|
|
147
|
-
case "list_threads":
|
|
148
|
-
return this.listThreads(storeId, requiredString(input["forumId"], "forumId"), requiredString(input["topicId"], "topicId"), queryLimit(input["limit"]));
|
|
149
|
-
case "open_questions":
|
|
150
|
-
return this.openQuestions(storeId, optionalString(input["forumId"], "forumId"), optionalString(input["targetId"], "targetId"), queryLimit(input["limit"]));
|
|
151
|
-
case "replay":
|
|
152
|
-
return this.replay(storeId, nonNegativeInteger(input["after_sequence"], "after_sequence"), queryLimit(input["limit"]));
|
|
153
|
-
case "snapshot":
|
|
154
|
-
return this.snapshot(storeId, input);
|
|
155
|
-
case "acknowledge":
|
|
156
|
-
return this.acknowledge(storeId, requiredString(input["consumer_id"], "consumer_id"), nonNegativeInteger(input["sequence"], "sequence"));
|
|
157
|
-
case "consumer_cursor":
|
|
158
|
-
return this.cursor("discourse_cursors", "consumer_id", storeId, requiredString(input["consumer_id"], "consumer_id"));
|
|
159
|
-
case "read_projection_outbox":
|
|
160
|
-
return this.projectionOutbox(storeId, requiredString(input["projection_id"], "projection_id"), queryLimit(input["limit"]));
|
|
161
|
-
case "acknowledge_projection":
|
|
162
|
-
this.acknowledgeProjection(storeId, requiredString(input["projection_id"], "projection_id"), nonNegativeInteger(input["sequence"], "sequence"));
|
|
163
|
-
return { ok: true };
|
|
164
|
-
case "projection_checkpoint":
|
|
165
|
-
return this.cursor("discourse_projection_cursors", "projection_id", storeId, requiredString(input["projection_id"], "projection_id"));
|
|
166
|
-
case "projection_pending":
|
|
167
|
-
return this.projectionPending(storeId, requiredString(input["projection_id"], "projection_id"));
|
|
168
|
-
case "latest_post_sequence":
|
|
169
|
-
return this.maximum(storeId, "discourse_posts");
|
|
170
|
-
default:
|
|
171
|
-
throw new Error(`unknown discourse store action "${action}"`);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
private append(storeId: string, command: AppendPostCommand, postId: string, timestamp: number, retention: number): { post: Post; replayed: boolean; events: DiscourseEvent[] } {
|
|
176
|
-
return inTransaction(this.db, () => {
|
|
177
|
-
const prior = this.db.prepare("SELECT * FROM discourse_posts WHERE store_id = ? AND operation_id = ?").get(storeId, command.operationId) as Row | null;
|
|
178
|
-
const commandJson = JSON.stringify(command);
|
|
179
|
-
if (prior) {
|
|
180
|
-
if (rowString(prior, "command_json") !== commandJson) throw new Error(`operation conflict: ${command.operationId}`);
|
|
181
|
-
return { post: postFromRow(prior), replayed: true, events: [] };
|
|
182
|
-
}
|
|
183
|
-
for (const reference of command.references ?? []) {
|
|
184
|
-
const artifact = this.artifacts.get(reference.id);
|
|
185
|
-
if (!artifact || artifact.kind !== reference.kind) throw new Error(`artifact reference not verified: ${reference.kind}:${reference.id}`);
|
|
186
|
-
}
|
|
187
|
-
let replyArtifactId: string | undefined;
|
|
188
|
-
if (command.replyToPostId) {
|
|
189
|
-
const parent = this.db.prepare("SELECT forum_id, topic_id, thread_id, artifact_id FROM discourse_posts WHERE store_id = ? AND id = ?").get(storeId, command.replyToPostId) as Row | null;
|
|
190
|
-
if (!parent) throw new Error(`reply target not found: ${command.replyToPostId}`);
|
|
191
|
-
if (rowString(parent, "forum_id") !== command.forumId || rowString(parent, "topic_id") !== command.topicId || rowString(parent, "thread_id") !== command.threadId) {
|
|
192
|
-
throw new Error("reply target must belong to the same thread");
|
|
193
|
-
}
|
|
194
|
-
replyArtifactId = rowString(parent, "artifact_id");
|
|
195
|
-
}
|
|
196
|
-
const firstSequence = this.maximum(storeId, "discourse_events") + 1;
|
|
197
|
-
const events = eventsFor(command, postId, timestamp, firstSequence);
|
|
198
|
-
const threadArtifactId = this.ensureThread(storeId, command);
|
|
199
|
-
const message = this.artifacts.create({
|
|
200
|
-
kind: "doc",
|
|
201
|
-
title: `${command.authorId} · ${command.threadId} · ${firstSequence}`,
|
|
202
|
-
status: "active",
|
|
203
|
-
subtype: DISCOURSE_MESSAGE_SUBTYPE,
|
|
204
|
-
body: bodyFor(command.content),
|
|
205
|
-
extra: {
|
|
206
|
-
storeId, postId, sequence: firstSequence, operationId: command.operationId,
|
|
207
|
-
forumId: command.forumId, topicId: command.topicId, threadId: command.threadId,
|
|
208
|
-
authorId: command.authorId, timestamp,
|
|
209
|
-
},
|
|
210
|
-
});
|
|
211
|
-
const question = questionColumns(command.content);
|
|
212
|
-
this.db.prepare(`INSERT INTO discourse_posts (
|
|
213
|
-
store_id, sequence, id, artifact_id, operation_id, command_json, forum_id, topic_id, thread_id,
|
|
214
|
-
author_id, content_json, timestamp, correlation_id, causation_id, reply_to_post_id, references_json,
|
|
215
|
-
question_type, response_id, target_id
|
|
216
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
217
|
-
storeId, firstSequence, postId, message.id, command.operationId, commandJson,
|
|
218
|
-
command.forumId, command.topicId, command.threadId, command.authorId, JSON.stringify(command.content), timestamp,
|
|
219
|
-
command.correlationId ?? null, command.causationId ?? null, command.replyToPostId ?? null,
|
|
220
|
-
JSON.stringify(command.references ?? []), question.questionType ?? null, question.responseId ?? null, question.targetId ?? null,
|
|
221
|
-
);
|
|
222
|
-
this.artifacts.link({ from: threadArtifactId, relation: "contains", to: message.id });
|
|
223
|
-
this.artifacts.link({ from: message.id, relation: "part_of", to: threadArtifactId });
|
|
224
|
-
if (replyArtifactId) this.artifacts.link({ from: message.id, relation: "reply_to", to: replyArtifactId });
|
|
225
|
-
for (const reference of command.references ?? []) this.artifacts.link({ from: message.id, relation: "discusses", to: reference.id });
|
|
226
|
-
for (const event of events) {
|
|
227
|
-
this.db.prepare("INSERT INTO discourse_events (store_id, sequence, event_json) VALUES (?, ?, ?)").run(storeId, event.sequence, JSON.stringify(event));
|
|
228
|
-
}
|
|
229
|
-
const latest = events.at(-1);
|
|
230
|
-
if (!latest) throw new Error("append produced no events");
|
|
231
|
-
this.db.prepare("DELETE FROM discourse_events WHERE store_id = ? AND sequence <= ?").run(storeId, latest.sequence - retention);
|
|
232
|
-
return { post: postFromRow(this.db.prepare("SELECT * FROM discourse_posts WHERE store_id = ? AND id = ?").get(storeId, postId) as Row), replayed: false, events };
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
private ensureThread(storeId: string, address: AppendPostCommand): string {
|
|
237
|
-
const existing = this.db.prepare("SELECT artifact_id FROM discourse_threads WHERE store_id = ? AND forum_id = ? AND topic_id = ? AND thread_id = ?").get(storeId, address.forumId, address.topicId, address.threadId) as Row | null;
|
|
238
|
-
if (existing) return rowString(existing, "artifact_id");
|
|
239
|
-
const thread = this.artifacts.create({
|
|
240
|
-
kind: "doc", title: address.threadId, status: "active", subtype: DISCOURSE_THREAD_SUBTYPE,
|
|
241
|
-
extra: { storeId, forumId: address.forumId, topicId: address.topicId, threadId: address.threadId },
|
|
242
|
-
});
|
|
243
|
-
this.db.prepare("INSERT INTO discourse_threads (store_id, forum_id, topic_id, thread_id, artifact_id) VALUES (?, ?, ?, ?, ?)").run(storeId, address.forumId, address.topicId, address.threadId, thread.id);
|
|
244
|
-
return thread.id;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
private readThread(storeId: string, input: Record<string, unknown>): Page<Post> {
|
|
248
|
-
const address = threadAddress(input);
|
|
249
|
-
const limit = queryLimit(input["limit"]);
|
|
250
|
-
const after = input["afterSequence"] === undefined ? 0 : nonNegativeInteger(input["afterSequence"], "afterSequence");
|
|
251
|
-
const rows = this.db.prepare("SELECT * FROM discourse_posts WHERE store_id = ? AND forum_id = ? AND topic_id = ? AND thread_id = ? AND sequence > ? ORDER BY sequence LIMIT ?").all(storeId, address.forumId, address.topicId, address.threadId, after, limit + 1) as Row[];
|
|
252
|
-
return page(rows.map(postFromRow), limit, (post) => post.sequence);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
private listTopics(storeId: string, forumId: string, limit: number): Page<TopicSummary> {
|
|
256
|
-
const rows = this.db.prepare("SELECT forum_id, topic_id, COUNT(DISTINCT thread_id) AS thread_count, COUNT(*) AS post_count, MAX(timestamp) AS last_activity FROM discourse_posts WHERE store_id = ? AND forum_id = ? GROUP BY forum_id, topic_id ORDER BY topic_id LIMIT ?").all(storeId, forumId, limit + 1) as Row[];
|
|
257
|
-
return page(rows.map((row) => ({ forumId: rowString(row, "forum_id"), topicId: rowString(row, "topic_id"), threadCount: rowNumber(row, "thread_count"), postCount: rowNumber(row, "post_count"), lastActivity: rowNumber(row, "last_activity") })), limit);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
private listThreads(storeId: string, forumId: string, topicId: string, limit: number): Page<ThreadSummary> {
|
|
261
|
-
const rows = this.db.prepare("SELECT forum_id, topic_id, thread_id, COUNT(*) AS post_count, MAX(timestamp) AS last_activity FROM discourse_posts WHERE store_id = ? AND forum_id = ? AND topic_id = ? GROUP BY forum_id, topic_id, thread_id ORDER BY thread_id LIMIT ?").all(storeId, forumId, topicId, limit + 1) as Row[];
|
|
262
|
-
return page(rows.map((row) => {
|
|
263
|
-
const threadId = rowString(row, "thread_id");
|
|
264
|
-
const participants = this.db.prepare("SELECT DISTINCT author_id FROM discourse_posts WHERE store_id = ? AND forum_id = ? AND topic_id = ? AND thread_id = ? ORDER BY author_id LIMIT ?").all(storeId, forumId, topicId, threadId, DISCOURSE_PARTICIPANT_MAX_COUNT) as Row[];
|
|
265
|
-
return { forumId, topicId, threadId, postCount: rowNumber(row, "post_count"), participantIds: participants.map((entry) => rowString(entry, "author_id")), lastActivity: rowNumber(row, "last_activity") };
|
|
266
|
-
}), limit);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
private openQuestions(storeId: string, forumId: string | undefined, targetId: string | undefined, limit: number): Page<OpenQuestion> {
|
|
270
|
-
const rows = this.db.prepare("SELECT p.* FROM discourse_posts p WHERE p.store_id = ? AND p.question_type = 'question' AND (? IS NULL OR p.forum_id = ?) AND (? IS NULL OR p.target_id IS NULL OR p.target_id = ?) AND NOT EXISTS (SELECT 1 FROM discourse_posts a WHERE a.store_id = p.store_id AND a.question_type = 'answer' AND a.response_id = p.response_id) ORDER BY p.sequence LIMIT ?").all(storeId, forumId ?? null, forumId ?? null, targetId ?? null, targetId ?? null, limit + 1) as Row[];
|
|
271
|
-
return page(rows.map((row) => ({ responseId: rowString(row, "response_id"), post: postFromRow(row) })), limit, (question) => question.post.sequence);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
private replay(storeId: string, afterSequence: number, limit: number): { events: DiscourseEvent[]; retainedFromSequence: number; latestSequence: number; expired: boolean; truncated: boolean } {
|
|
275
|
-
const bounds = this.db.prepare("SELECT COALESCE(MIN(sequence), 0) AS minimum, COALESCE(MAX(sequence), 0) AS maximum FROM discourse_events WHERE store_id = ?").get(storeId) as Row;
|
|
276
|
-
const retainedFromSequence = rowNumber(bounds, "minimum");
|
|
277
|
-
const latestSequence = rowNumber(bounds, "maximum");
|
|
278
|
-
const expired = retainedFromSequence > 0 && afterSequence > 0 && afterSequence < retainedFromSequence - 1;
|
|
279
|
-
const rows = expired ? [] : this.db.prepare("SELECT event_json FROM discourse_events WHERE store_id = ? AND sequence > ? ORDER BY sequence LIMIT ?").all(storeId, afterSequence, limit + 1) as Row[];
|
|
280
|
-
const events = rows.map((row) => parseJson<DiscourseEvent>(row, "event_json"));
|
|
281
|
-
return { events: events.slice(0, limit), retainedFromSequence, latestSequence, expired, truncated: events.length > limit };
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
private snapshot(storeId: string, input: Record<string, unknown>): { posts: Page<Post>; throughSequence: number } {
|
|
285
|
-
const limit = queryLimit(input["limit"]);
|
|
286
|
-
const after = input["afterSequence"] === undefined ? 0 : nonNegativeInteger(input["afterSequence"], "afterSequence");
|
|
287
|
-
const forumId = optionalString(input["forumId"], "forumId");
|
|
288
|
-
const rows = this.db.prepare("SELECT * FROM discourse_posts WHERE store_id = ? AND sequence > ? AND (? IS NULL OR forum_id = ?) ORDER BY sequence LIMIT ?").all(storeId, after, forumId ?? null, forumId ?? null, limit + 1) as Row[];
|
|
289
|
-
return { posts: page(rows.map(postFromRow), limit, (post) => post.sequence), throughSequence: this.maximum(storeId, "discourse_events") };
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
private acknowledge(storeId: string, consumerId: string, sequence: number): number {
|
|
293
|
-
const latest = this.maximum(storeId, "discourse_events");
|
|
294
|
-
if (sequence > latest) throw new Error(`cannot acknowledge future sequence ${sequence}`);
|
|
295
|
-
this.db.prepare("INSERT INTO discourse_cursors (store_id, consumer_id, sequence) VALUES (?, ?, ?) ON CONFLICT(store_id, consumer_id) DO UPDATE SET sequence = MAX(sequence, excluded.sequence)").run(storeId, consumerId, sequence);
|
|
296
|
-
return this.cursor("discourse_cursors", "consumer_id", storeId, consumerId);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
private projectionOutbox(storeId: string, projectionId: string, limit: number): ProjectionRecord[] {
|
|
300
|
-
const checkpoint = this.cursor("discourse_projection_cursors", "projection_id", storeId, projectionId);
|
|
301
|
-
return (this.db.prepare("SELECT * FROM discourse_posts WHERE store_id = ? AND sequence > ? ORDER BY sequence LIMIT ?").all(storeId, checkpoint, limit) as Row[]).map((row) => {
|
|
302
|
-
const post = postFromRow(row);
|
|
303
|
-
return { sequence: post.sequence, post };
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
private acknowledgeProjection(storeId: string, projectionId: string, sequence: number): void {
|
|
308
|
-
if (sequence > this.maximum(storeId, "discourse_posts")) throw new Error(`cannot acknowledge future projection sequence ${sequence}`);
|
|
309
|
-
this.db.prepare("INSERT INTO discourse_projection_cursors (store_id, projection_id, sequence) VALUES (?, ?, ?) ON CONFLICT(store_id, projection_id) DO UPDATE SET sequence = MAX(sequence, excluded.sequence)").run(storeId, projectionId, sequence);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
private projectionPending(storeId: string, projectionId: string): number {
|
|
313
|
-
const checkpoint = this.cursor("discourse_projection_cursors", "projection_id", storeId, projectionId);
|
|
314
|
-
return rowNumber(this.db.prepare("SELECT COUNT(*) AS value FROM discourse_posts WHERE store_id = ? AND sequence > ?").get(storeId, checkpoint) as Row, "value");
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
private cursor(table: "discourse_cursors" | "discourse_projection_cursors", column: "consumer_id" | "projection_id", storeId: string, id: string): number {
|
|
318
|
-
const row = this.db.prepare(`SELECT sequence FROM ${table} WHERE store_id = ? AND ${column} = ?`).get(storeId, id) as Row | null;
|
|
319
|
-
return row ? rowNumber(row, "sequence") : 0;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
private maximum(storeId: string, table: "discourse_events" | "discourse_posts"): number {
|
|
323
|
-
return rowNumber(this.db.prepare(`SELECT COALESCE(MAX(sequence), 0) AS value FROM ${table} WHERE store_id = ?`).get(storeId) as Row, "value");
|
|
324
|
-
}
|
|
325
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* conversation-journal-service.ts — host-neutral ConversationJournal application layer.
|
|
3
|
-
*
|
|
4
|
-
* Owns idempotency (checking operationId before insert) and bounds enforcement; the
|
|
5
|
-
* ConversationJournalStore port underneath is dumb storage. See
|
|
6
|
-
* src/domain/conversation-journal.ts for the domain shapes and the design rationale.
|
|
7
|
-
*/
|
|
8
|
-
import {
|
|
9
|
-
ancestorChain,
|
|
10
|
-
boundContent,
|
|
11
|
-
buildThreadTree,
|
|
12
|
-
CONVERSATION_JOURNAL_READ_MAX_POSTS,
|
|
13
|
-
validateAppendPostCommand,
|
|
14
|
-
type AppendPostCommand,
|
|
15
|
-
type AppendPostResult,
|
|
16
|
-
type JournalPost,
|
|
17
|
-
type JournalThread,
|
|
18
|
-
type ReadThreadQuery,
|
|
19
|
-
type ThreadPage,
|
|
20
|
-
type ThreadTreeNode,
|
|
21
|
-
} from "./domain/conversation-journal.ts";
|
|
22
|
-
import type { ConversationJournalStore } from "./ports/conversation-journal-store.ts";
|
|
23
|
-
|
|
24
|
-
export class ConversationJournalService {
|
|
25
|
-
constructor(private readonly store: ConversationJournalStore) {}
|
|
26
|
-
|
|
27
|
-
appendPost(command: AppendPostCommand): AppendPostResult {
|
|
28
|
-
validateAppendPostCommand(command);
|
|
29
|
-
|
|
30
|
-
const existing = this.store.findPostByOperationId(command.operationId);
|
|
31
|
-
if (existing) return { post: existing, replayed: true };
|
|
32
|
-
|
|
33
|
-
if (command.replyToPostId !== undefined && !this.store.getPost(command.replyToPostId)) {
|
|
34
|
-
throw new Error(`replyToPostId "${command.replyToPostId}" not found`);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
this.store.ensureThread(command.threadId);
|
|
38
|
-
const { content, truncated } = boundContent(command.content);
|
|
39
|
-
const post: JournalPost = {
|
|
40
|
-
id: crypto.randomUUID(),
|
|
41
|
-
threadId: command.threadId,
|
|
42
|
-
...(command.replyToPostId !== undefined ? { replyToPostId: command.replyToPostId } : {}),
|
|
43
|
-
authorId: command.authorId,
|
|
44
|
-
content,
|
|
45
|
-
truncated,
|
|
46
|
-
timestamp: new Date().toISOString(),
|
|
47
|
-
sourceSessionId: command.sourceSessionId,
|
|
48
|
-
operationId: command.operationId,
|
|
49
|
-
references: command.references ? [...command.references] : [],
|
|
50
|
-
};
|
|
51
|
-
this.store.insertPost(post);
|
|
52
|
-
return { post, replayed: false };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
getThread(threadId: string): JournalThread | undefined {
|
|
56
|
-
return this.store.getThread(threadId);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
getPost(id: string): JournalPost | undefined {
|
|
60
|
-
return this.store.getPost(id);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Bounded, explicit-completeness thread read. Never silently drops posts past the bound. */
|
|
64
|
-
readThread(query: ReadThreadQuery): ThreadPage {
|
|
65
|
-
const limit = query.limit;
|
|
66
|
-
if (!Number.isInteger(limit) || limit < 1 || limit > CONVERSATION_JOURNAL_READ_MAX_POSTS) {
|
|
67
|
-
throw new Error(`readThread limit must be between 1 and ${CONVERSATION_JOURNAL_READ_MAX_POSTS}`);
|
|
68
|
-
}
|
|
69
|
-
const all = [...this.store.postsForThread(query.threadId)].sort(
|
|
70
|
-
(left, right) => left.timestamp.localeCompare(right.timestamp) || left.id.localeCompare(right.id),
|
|
71
|
-
);
|
|
72
|
-
const truncated = all.length > limit;
|
|
73
|
-
return { posts: all.slice(0, limit), truncated };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Reconstructed reply tree for a thread, bounded and orphan/cycle-safe -- see buildThreadTree. */
|
|
77
|
-
readThreadTree(threadId: string): ThreadTreeNode[] {
|
|
78
|
-
return buildThreadTree(this.store.postsForThread(threadId));
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Root-first ancestor chain for one post -- the host-neutral equivalent of Pi's getBranch(). */
|
|
82
|
-
ancestorsOf(postId: string): JournalPost[] {
|
|
83
|
-
const posts = this.store.postsForThread(this.store.getPost(postId)?.threadId ?? "");
|
|
84
|
-
const byId = new Map(posts.map((post) => [post.id, post]));
|
|
85
|
-
return ancestorChain(postId, byId);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* domain/conversation-journal.ts — host-neutral ConversationJournal domain.
|
|
3
|
-
*
|
|
4
|
-
* See decision-discourse-as-a-layer-above-sessions-separate-conver-p832 and
|
|
5
|
-
* pis-tree-implementation-concrete-lessons-for-the-discourse-s-wnrs (Papyrus docs) for the
|
|
6
|
-
* design this implements and the concrete lessons behind each choice below.
|
|
7
|
-
*
|
|
8
|
-
* A Thread is the conversation's own stable identity, independent of whichever host
|
|
9
|
-
* process/session recorded any given Post into it. This module and its package source
|
|
10
|
-
* must never mention host runtime names (no "Pi") -- sourceSessionId is a generic
|
|
11
|
-
* provenance field on a Post, not a host-specific concept, and the host decides what
|
|
12
|
-
* value to put there.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export const CONVERSATION_JOURNAL_CONTENT_MAX_CHARACTERS = 20_000;
|
|
16
|
-
export const CONVERSATION_JOURNAL_SOURCE_ID_MAX_LENGTH = 256;
|
|
17
|
-
export const CONVERSATION_JOURNAL_MAX_REFERENCES_PER_POST = 50;
|
|
18
|
-
/** Bounds a single readThread call; retention/eviction beyond this is a host/persistence concern, not this domain's. */
|
|
19
|
-
export const CONVERSATION_JOURNAL_READ_MAX_POSTS = 500;
|
|
20
|
-
/** Bounds reply-chain traversal so a cycle (accidental or adversarial) cannot infinite-loop a tree build -- see the Pi /tree lessons doc for why this must not be assumed away. */
|
|
21
|
-
export const CONVERSATION_JOURNAL_MAX_TRAVERSAL_DEPTH = 10_000;
|
|
22
|
-
|
|
23
|
-
export type JournalAuthor = "human" | "agent";
|
|
24
|
-
|
|
25
|
-
/** A reference to an artifact owned outside this journal -- verified by the host, never asserted. */
|
|
26
|
-
export interface ArtifactReference {
|
|
27
|
-
readonly kind: string;
|
|
28
|
-
readonly id: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface JournalThread {
|
|
32
|
-
readonly id: string;
|
|
33
|
-
readonly createdAt: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface JournalPost {
|
|
37
|
-
readonly id: string;
|
|
38
|
-
readonly threadId: string;
|
|
39
|
-
/** Absent only for a thread's first post. */
|
|
40
|
-
readonly replyToPostId?: string;
|
|
41
|
-
readonly authorId: JournalAuthor;
|
|
42
|
-
readonly content: string;
|
|
43
|
-
/** True when content was cut to CONVERSATION_JOURNAL_CONTENT_MAX_CHARACTERS -- never silently. */
|
|
44
|
-
readonly truncated: boolean;
|
|
45
|
-
readonly timestamp: string;
|
|
46
|
-
/** Which host session/process recorded this post. Provenance, never this domain's top-level container -- see the layering decision doc. */
|
|
47
|
-
readonly sourceSessionId: string;
|
|
48
|
-
/**
|
|
49
|
-
* Idempotency key. Must be a composite of (sourceSessionId, a host-local entry id),
|
|
50
|
-
* constructed by the caller -- never a bare host entry id alone. A host's own entry
|
|
51
|
-
* ids are commonly unique only within one recording session, not globally; using one
|
|
52
|
-
* alone as a global idempotency key risks a false dedup collision between two
|
|
53
|
-
* unrelated sessions. See the Pi /tree lessons doc for the concrete case this
|
|
54
|
-
* generalizes from.
|
|
55
|
-
*/
|
|
56
|
-
readonly operationId: string;
|
|
57
|
-
readonly references: readonly ArtifactReference[];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export interface AppendPostCommand {
|
|
61
|
-
readonly threadId: string;
|
|
62
|
-
readonly replyToPostId?: string;
|
|
63
|
-
readonly authorId: JournalAuthor;
|
|
64
|
-
readonly content: string;
|
|
65
|
-
readonly sourceSessionId: string;
|
|
66
|
-
readonly operationId: string;
|
|
67
|
-
readonly references?: readonly ArtifactReference[];
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface AppendPostResult {
|
|
71
|
-
readonly post: JournalPost;
|
|
72
|
-
/** True when this exact operationId was already journaled and this call was a safe no-op replay. */
|
|
73
|
-
readonly replayed: boolean;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export interface ReadThreadQuery {
|
|
77
|
-
readonly threadId: string;
|
|
78
|
-
readonly limit: number;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export interface ThreadPage {
|
|
82
|
-
readonly posts: readonly JournalPost[];
|
|
83
|
-
/** True when more posts exist beyond `limit` -- never silently drop the tail without saying so. */
|
|
84
|
-
readonly truncated: boolean;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** One node of a reconstructed reply tree; see buildThreadTree. */
|
|
88
|
-
export interface ThreadTreeNode {
|
|
89
|
-
readonly post: JournalPost;
|
|
90
|
-
readonly children: ThreadTreeNode[];
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function requireBounded(value: string, label: string, maxLength: number): string {
|
|
94
|
-
if (value.length === 0) throw new Error(`${label} is required`);
|
|
95
|
-
if (value.length > maxLength) throw new Error(`${label} exceeds ${maxLength} characters`);
|
|
96
|
-
return value;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function validateAppendPostCommand(command: AppendPostCommand): void {
|
|
100
|
-
requireBounded(command.threadId, "threadId", CONVERSATION_JOURNAL_SOURCE_ID_MAX_LENGTH);
|
|
101
|
-
requireBounded(command.sourceSessionId, "sourceSessionId", CONVERSATION_JOURNAL_SOURCE_ID_MAX_LENGTH);
|
|
102
|
-
requireBounded(command.operationId, "operationId", CONVERSATION_JOURNAL_SOURCE_ID_MAX_LENGTH * 2);
|
|
103
|
-
if (command.content.length === 0) throw new Error("content is required");
|
|
104
|
-
if (command.authorId !== "human" && command.authorId !== "agent") throw new Error('authorId must be "human" or "agent"');
|
|
105
|
-
const references = command.references ?? [];
|
|
106
|
-
if (references.length > CONVERSATION_JOURNAL_MAX_REFERENCES_PER_POST) {
|
|
107
|
-
throw new Error(`a post is bounded to ${CONVERSATION_JOURNAL_MAX_REFERENCES_PER_POST} references; got ${references.length}`);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Applies the explicit truncation bound. Never silently drops the truncation fact -- callers must surface `truncated`. */
|
|
112
|
-
export function boundContent(content: string): { content: string; truncated: boolean } {
|
|
113
|
-
if (content.length <= CONVERSATION_JOURNAL_CONTENT_MAX_CHARACTERS) return { content, truncated: false };
|
|
114
|
-
return { content: content.slice(0, CONVERSATION_JOURNAL_CONTENT_MAX_CHARACTERS), truncated: true };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Reconstructs the reply tree from a flat list of posts (as returned by one bounded
|
|
119
|
-
* readThread call). A post whose replyToPostId does not resolve within this same list --
|
|
120
|
-
* because it is genuinely a thread root, or because an ancestor aged out under a
|
|
121
|
-
* retention policy -- degrades to being treated as a root of its own sub-tree, exactly
|
|
122
|
-
* like Pi's own getTree() treats an orphaned entry. This never throws on that account.
|
|
123
|
-
*
|
|
124
|
-
* Bounded and cycle-safe: a post already visited while walking up cannot be revisited,
|
|
125
|
-
* so a malformed or adversarial replyToPostId cycle cannot infinite-loop this function --
|
|
126
|
-
* see the Pi /tree lessons doc for why that guard must be explicit, not assumed.
|
|
127
|
-
*/
|
|
128
|
-
export function buildThreadTree(posts: readonly JournalPost[]): ThreadTreeNode[] {
|
|
129
|
-
const nodesById = new Map<string, ThreadTreeNode>();
|
|
130
|
-
for (const post of posts) nodesById.set(post.id, { post, children: [] });
|
|
131
|
-
|
|
132
|
-
const roots: ThreadTreeNode[] = [];
|
|
133
|
-
for (const post of posts) {
|
|
134
|
-
const node = nodesById.get(post.id)!;
|
|
135
|
-
const parent = post.replyToPostId ? nodesById.get(post.replyToPostId) : undefined;
|
|
136
|
-
if (parent) parent.children.push(node);
|
|
137
|
-
else roots.push(node);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
for (const node of nodesById.values()) {
|
|
141
|
-
node.children.sort((left, right) => left.post.timestamp.localeCompare(right.post.timestamp) || left.post.id.localeCompare(right.post.id));
|
|
142
|
-
}
|
|
143
|
-
roots.sort((left, right) => left.post.timestamp.localeCompare(right.post.timestamp) || left.post.id.localeCompare(right.post.id));
|
|
144
|
-
return roots;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Walks from a post back toward its thread root via replyToPostId, root-first order --
|
|
149
|
-
* the host-neutral equivalent of Pi's getBranch(). Bounded by
|
|
150
|
-
* CONVERSATION_JOURNAL_MAX_TRAVERSAL_DEPTH and tracks visited ids explicitly so a cycle
|
|
151
|
-
* cannot infinite-loop this walk, unlike Pi's own getBranch() (a documented gap in Pi,
|
|
152
|
-
* not something to assume away here).
|
|
153
|
-
*/
|
|
154
|
-
export function ancestorChain(postId: string, postsById: ReadonlyMap<string, JournalPost>): JournalPost[] {
|
|
155
|
-
const chain: JournalPost[] = [];
|
|
156
|
-
const visited = new Set<string>();
|
|
157
|
-
let currentId: string | undefined = postId;
|
|
158
|
-
while (currentId !== undefined) {
|
|
159
|
-
if (visited.has(currentId)) break; // cycle guard
|
|
160
|
-
if (chain.length >= CONVERSATION_JOURNAL_MAX_TRAVERSAL_DEPTH) break; // depth guard
|
|
161
|
-
visited.add(currentId);
|
|
162
|
-
const post = postsById.get(currentId);
|
|
163
|
-
if (!post) break; // orphan: stop here, do not error
|
|
164
|
-
chain.push(post);
|
|
165
|
-
currentId = post.replyToPostId;
|
|
166
|
-
}
|
|
167
|
-
return chain.reverse();
|
|
168
|
-
}
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
DISCOURSE_CONTENT_MAX_BYTES,
|
|
3
|
-
DISCOURSE_EVENT_RETENTION_DEFAULT,
|
|
4
|
-
DISCOURSE_EVENT_RETENTION_MAX,
|
|
5
|
-
DISCOURSE_QUERY_MAX_LIMIT,
|
|
6
|
-
} from "../constants.ts";
|
|
7
|
-
|
|
8
|
-
/** Papyrus-owned Doc subtypes reserved for the Discourse persistence adapter. */
|
|
9
|
-
export const DISCOURSE_THREAD_SUBTYPE = "context-thread";
|
|
10
|
-
export const DISCOURSE_MESSAGE_SUBTYPE = "context-message";
|
|
11
|
-
export const DISCOURSE_RELATIONS = new Set(["reply_to", "discusses"]);
|
|
12
|
-
|
|
13
|
-
export type JsonPrimitive = string | number | boolean | null;
|
|
14
|
-
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
|
15
|
-
export interface ArtifactReference { kind: string; id: string }
|
|
16
|
-
export interface ThreadAddress { forumId: string; topicId: string; threadId: string }
|
|
17
|
-
export interface AppendPostCommand extends ThreadAddress {
|
|
18
|
-
schemaVersion: "discourse.command.v1";
|
|
19
|
-
operationId: string;
|
|
20
|
-
authorId: string;
|
|
21
|
-
content: JsonValue;
|
|
22
|
-
correlationId?: string;
|
|
23
|
-
causationId?: string;
|
|
24
|
-
replyToPostId?: string;
|
|
25
|
-
references?: ArtifactReference[];
|
|
26
|
-
}
|
|
27
|
-
export interface Post extends ThreadAddress {
|
|
28
|
-
id: string;
|
|
29
|
-
authorId: string;
|
|
30
|
-
content: JsonValue;
|
|
31
|
-
timestamp: number;
|
|
32
|
-
sequence: number;
|
|
33
|
-
operationId: string;
|
|
34
|
-
correlationId?: string;
|
|
35
|
-
causationId?: string;
|
|
36
|
-
replyToPostId?: string;
|
|
37
|
-
references: ArtifactReference[];
|
|
38
|
-
}
|
|
39
|
-
export type DiscourseEventType = "post-added" | "thread-changed" | "question-opened" | "question-answered" | "subscription-resync-required";
|
|
40
|
-
export interface DiscourseEvent extends ThreadAddress {
|
|
41
|
-
schemaVersion: "discourse.event.v1";
|
|
42
|
-
type: DiscourseEventType;
|
|
43
|
-
sequence: number;
|
|
44
|
-
timestamp: number;
|
|
45
|
-
postId?: string;
|
|
46
|
-
operationId?: string;
|
|
47
|
-
correlationId?: string;
|
|
48
|
-
causationId?: string;
|
|
49
|
-
responseId?: string;
|
|
50
|
-
retainedFromSequence?: number;
|
|
51
|
-
}
|
|
52
|
-
export interface Page<T> {
|
|
53
|
-
items: T[];
|
|
54
|
-
truncated: boolean;
|
|
55
|
-
nextSequence?: number;
|
|
56
|
-
completeness: "complete" | "truncated";
|
|
57
|
-
}
|
|
58
|
-
export interface TopicSummary { forumId: string; topicId: string; threadCount: number; postCount: number; lastActivity: number }
|
|
59
|
-
export interface ThreadSummary extends ThreadAddress { postCount: number; participantIds: string[]; lastActivity: number }
|
|
60
|
-
export interface OpenQuestion { responseId: string; post: Post }
|
|
61
|
-
export interface ProjectionRecord { sequence: number; post: Post }
|
|
62
|
-
|
|
63
|
-
export function isDiscourseSubtype(subtype: string | undefined): boolean {
|
|
64
|
-
return subtype === DISCOURSE_THREAD_SUBTYPE || subtype === DISCOURSE_MESSAGE_SUBTYPE;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function record(value: unknown, name: string): Record<string, unknown> {
|
|
68
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${name} must be an object`);
|
|
69
|
-
return value as Record<string, unknown>;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function requiredString(value: unknown, name: string): string {
|
|
73
|
-
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} is required`);
|
|
74
|
-
return value;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function optionalString(value: unknown, name: string): string | undefined {
|
|
78
|
-
if (value === undefined) return undefined;
|
|
79
|
-
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
|
|
80
|
-
return value;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function nonNegativeInteger(value: unknown, name: string): number {
|
|
84
|
-
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${name} must be a non-negative safe integer`);
|
|
85
|
-
return value as number;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function queryLimit(value: unknown): number {
|
|
89
|
-
const limit = nonNegativeInteger(value, "limit");
|
|
90
|
-
if (limit < 1 || limit > DISCOURSE_QUERY_MAX_LIMIT) throw new Error(`limit must be between 1 and ${DISCOURSE_QUERY_MAX_LIMIT}`);
|
|
91
|
-
return limit;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function eventRetention(value: unknown): number {
|
|
95
|
-
if (value === undefined) return DISCOURSE_EVENT_RETENTION_DEFAULT;
|
|
96
|
-
const retention = nonNegativeInteger(value, "event_retention");
|
|
97
|
-
if (retention < 1 || retention > DISCOURSE_EVENT_RETENTION_MAX) {
|
|
98
|
-
throw new Error(`event_retention must be between 1 and ${DISCOURSE_EVENT_RETENTION_MAX}`);
|
|
99
|
-
}
|
|
100
|
-
return retention;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function jsonValue(value: unknown, name: string): JsonValue {
|
|
104
|
-
const encoded = JSON.stringify(value);
|
|
105
|
-
if (encoded === undefined) throw new Error(`${name} must be JSON-serializable`);
|
|
106
|
-
if (new TextEncoder().encode(encoded).byteLength > DISCOURSE_CONTENT_MAX_BYTES) {
|
|
107
|
-
throw new Error(`${name} cannot exceed ${DISCOURSE_CONTENT_MAX_BYTES} bytes`);
|
|
108
|
-
}
|
|
109
|
-
return JSON.parse(encoded) as JsonValue;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function appendCommand(value: unknown): AppendPostCommand {
|
|
113
|
-
const input = record(value, "command");
|
|
114
|
-
if (input["schemaVersion"] !== "discourse.command.v1") throw new Error("unsupported Discourse command schema");
|
|
115
|
-
const referencesValue = input["references"] ?? [];
|
|
116
|
-
if (!Array.isArray(referencesValue)) throw new Error("references must be an array");
|
|
117
|
-
const references = referencesValue.map((entry, index) => {
|
|
118
|
-
const reference = record(entry, `references[${index}]`);
|
|
119
|
-
return { kind: requiredString(reference["kind"], `references[${index}].kind`), id: requiredString(reference["id"], `references[${index}].id`) };
|
|
120
|
-
});
|
|
121
|
-
return {
|
|
122
|
-
schemaVersion: "discourse.command.v1",
|
|
123
|
-
operationId: requiredString(input["operationId"], "operationId"),
|
|
124
|
-
forumId: requiredString(input["forumId"], "forumId"),
|
|
125
|
-
topicId: requiredString(input["topicId"], "topicId"),
|
|
126
|
-
threadId: requiredString(input["threadId"], "threadId"),
|
|
127
|
-
authorId: requiredString(input["authorId"], "authorId"),
|
|
128
|
-
content: jsonValue(input["content"], "content"),
|
|
129
|
-
...(optionalString(input["correlationId"], "correlationId") ? { correlationId: input["correlationId"] as string } : {}),
|
|
130
|
-
...(optionalString(input["causationId"], "causationId") ? { causationId: input["causationId"] as string } : {}),
|
|
131
|
-
...(optionalString(input["replyToPostId"], "replyToPostId") ? { replyToPostId: input["replyToPostId"] as string } : {}),
|
|
132
|
-
references,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function threadAddress(value: Record<string, unknown>): ThreadAddress {
|
|
137
|
-
return {
|
|
138
|
-
forumId: requiredString(value["forumId"], "forumId"),
|
|
139
|
-
topicId: requiredString(value["topicId"], "topicId"),
|
|
140
|
-
threadId: requiredString(value["threadId"], "threadId"),
|
|
141
|
-
};
|
|
142
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import type { JournalPost, JournalThread } from "../domain/conversation-journal.ts";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Persistence port for ConversationJournal. Deliberately minimal and host-neutral: no
|
|
5
|
-
* mention of any host runtime, no query beyond what a bounded thread read needs. Idempotency
|
|
6
|
-
* (checking operationId before insert) is the service's job, not the store's -- this port is
|
|
7
|
-
* dumb storage, matching Discourse's own store/service split (see the layering decision doc).
|
|
8
|
-
*/
|
|
9
|
-
export interface ConversationJournalStore {
|
|
10
|
-
ensureThread(threadId: string): JournalThread;
|
|
11
|
-
getThread(threadId: string): JournalThread | undefined;
|
|
12
|
-
findPostByOperationId(operationId: string): JournalPost | undefined;
|
|
13
|
-
insertPost(post: JournalPost): void;
|
|
14
|
-
getPost(id: string): JournalPost | undefined;
|
|
15
|
-
/** All posts for one thread, unbounded at the store layer -- the service applies the read bound. */
|
|
16
|
-
postsForThread(threadId: string): readonly JournalPost[];
|
|
17
|
-
}
|