@danypops/papyrus 0.11.4 → 0.13.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 +16 -2
- package/extension/src/active-task-continuation.ts +6 -0
- package/extension/src/artifact-browser.ts +13 -7
- package/extension/src/artifact-status-presentation.ts +53 -0
- package/extension/src/context-budget.ts +173 -0
- package/extension/src/context-view.ts +172 -0
- package/extension/src/docs.ts +6 -5
- package/extension/src/domain-tools.ts +108 -52
- package/extension/src/index.ts +124 -38
- package/extension/src/notes.ts +16 -4
- package/extension/src/rules.ts +7 -7
- package/extension/src/skill-catalog-footprint.ts +183 -0
- package/extension/src/skills.ts +2 -3
- package/extension/src/task-focus-events.ts +57 -0
- package/extension/src/task-widget.ts +13 -1
- package/extension/src/tasks.ts +51 -15
- package/extension/src/tool-rendering/artifact-card.ts +117 -0
- package/extension/src/tool-rendering/artifact-list.ts +179 -0
- package/extension/src/tool-rendering/index.ts +107 -0
- package/extension/src/tool-rendering/render-model.ts +406 -0
- package/package.json +4 -2
- package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
- package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
- package/src/adapters/sqlite-artifact-store.ts +20 -11
- package/src/adapters/sqlite-discourse-store.ts +325 -0
- package/src/adapters/sqlite-graph-projection-store.ts +41 -0
- package/src/adapters/sqlite-task-focus-store.ts +34 -15
- package/src/authority-registry.ts +115 -0
- package/src/cli.ts +904 -124
- package/src/constants.ts +77 -5
- package/src/conversation-journal-service.ts +87 -0
- package/src/db.ts +285 -33
- package/src/domain/artifact-event.ts +99 -0
- package/src/domain/conversation-journal.ts +168 -0
- package/src/domain/discourse-store.ts +142 -0
- package/src/domain/graph-projection.ts +74 -0
- package/src/domain/skill-definition.ts +57 -8
- package/src/domain/task-event.ts +4 -0
- package/src/domain-services.ts +201 -40
- package/src/graph-projection-service.ts +103 -0
- package/src/id-migration.ts +200 -0
- package/src/module-registry.ts +53 -0
- package/src/modules/docs.ts +77 -0
- package/src/modules/graph-projection.ts +82 -0
- package/src/modules/notes.ts +76 -0
- package/src/modules/rules.ts +81 -0
- package/src/modules/skills.ts +113 -0
- package/src/modules/tasks.ts +164 -0
- package/src/ops.ts +142 -15
- package/src/ports/artifact-scope-store.ts +20 -0
- package/src/ports/artifact-store.ts +10 -5
- package/src/ports/conversation-journal-store.ts +17 -0
- package/src/ports/graph-projection-store.ts +15 -0
- package/src/ports/task-focus-store.ts +62 -20
- package/src/service.ts +218 -223
- package/src/skill-execution.ts +169 -75
- package/src/task-service.ts +70 -38
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic, kind-agnostic mutation event log — the "who did what, when" answer
|
|
3
|
+
* shared by every artifact kind (doc, task, rule, skill), not reinvented per domain.
|
|
4
|
+
*
|
|
5
|
+
* Modeled after scribe's parchment.Event/EventFilter/GetEvents shape, but avoids its
|
|
6
|
+
* known gap: there, the Actor column is defined and filterable yet never populated by
|
|
7
|
+
* any caller. Here, actor/source default to explicit sentinels ("system"/"unknown")
|
|
8
|
+
* rather than being silently blank, and the event is appended by the same choke point
|
|
9
|
+
* that performs the mutation (src/ops.ts), so no domain call site can skip it.
|
|
10
|
+
*/
|
|
11
|
+
import { ARTIFACT_EVENT_ACTOR_MAX_LENGTH, ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT, ARTIFACT_EVENT_HISTORY_MAX_LIMIT } from "../constants.ts";
|
|
12
|
+
|
|
13
|
+
export const ARTIFACT_EVENT_TYPES = ["created", "updated", "status_changed", "extra_set", "linked", "unlinked"] as const;
|
|
14
|
+
export type ArtifactEventType = typeof ARTIFACT_EVENT_TYPES[number];
|
|
15
|
+
export type ArtifactEventDirection = "asc" | "desc";
|
|
16
|
+
|
|
17
|
+
/** Caller-supplied identity for a mutation. All fields are advisory (self-reported), not cryptographically verified. */
|
|
18
|
+
export interface ArtifactEventContext {
|
|
19
|
+
actor?: string;
|
|
20
|
+
source?: string;
|
|
21
|
+
sessionId?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ArtifactEvent {
|
|
25
|
+
id: number;
|
|
26
|
+
artifactId: string;
|
|
27
|
+
occurredAt: string;
|
|
28
|
+
type: ArtifactEventType;
|
|
29
|
+
actor: string;
|
|
30
|
+
source: string;
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
fromStatus?: string;
|
|
33
|
+
toStatus?: string;
|
|
34
|
+
relation?: string;
|
|
35
|
+
relatedId?: string;
|
|
36
|
+
schemaVersion: 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AppendArtifactEvent {
|
|
40
|
+
artifactId: string;
|
|
41
|
+
type: ArtifactEventType;
|
|
42
|
+
actor?: string;
|
|
43
|
+
source?: string;
|
|
44
|
+
sessionId?: string;
|
|
45
|
+
fromStatus?: string;
|
|
46
|
+
toStatus?: string;
|
|
47
|
+
relation?: string;
|
|
48
|
+
relatedId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ArtifactEventQuery {
|
|
52
|
+
artifactId?: string;
|
|
53
|
+
actor?: string;
|
|
54
|
+
sessionId?: string;
|
|
55
|
+
since?: string;
|
|
56
|
+
limit?: number;
|
|
57
|
+
cursor?: number;
|
|
58
|
+
direction?: ArtifactEventDirection;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ArtifactEventPage {
|
|
62
|
+
events: ArtifactEvent[];
|
|
63
|
+
nextCursor?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** No caller-supplied identity defaults to these explicit sentinels — never a silent blank. */
|
|
67
|
+
export const ARTIFACT_EVENT_DEFAULT_ACTOR = "system";
|
|
68
|
+
export const ARTIFACT_EVENT_DEFAULT_SOURCE = "unknown";
|
|
69
|
+
|
|
70
|
+
function boundedString(value: string, field: string, maximum: number): string {
|
|
71
|
+
if (value.length === 0 || value.length > maximum) throw new Error(`${field} must be between 1 and ${maximum} characters`);
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Fills defaults and enforces bounds. The one place every appended event is normalized. */
|
|
76
|
+
export function resolveArtifactEvent(input: AppendArtifactEvent): Required<Pick<AppendArtifactEvent, "actor" | "source">> & AppendArtifactEvent {
|
|
77
|
+
if (!input.artifactId) throw new Error("artifactId is required");
|
|
78
|
+
const actor = boundedString(input.actor ?? ARTIFACT_EVENT_DEFAULT_ACTOR, "actor", ARTIFACT_EVENT_ACTOR_MAX_LENGTH);
|
|
79
|
+
const source = boundedString(input.source ?? ARTIFACT_EVENT_DEFAULT_SOURCE, "source", ARTIFACT_EVENT_ACTOR_MAX_LENGTH);
|
|
80
|
+
if (input.sessionId !== undefined) boundedString(input.sessionId, "sessionId", ARTIFACT_EVENT_ACTOR_MAX_LENGTH);
|
|
81
|
+
return { ...input, actor, source };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function normalizeArtifactEventQuery(query: ArtifactEventQuery): Required<Pick<ArtifactEventQuery, "limit" | "direction">> & ArtifactEventQuery {
|
|
85
|
+
if (!query.artifactId && !query.actor && !query.sessionId) {
|
|
86
|
+
throw new Error("artifact event query requires artifactId, actor, or sessionId to stay bounded");
|
|
87
|
+
}
|
|
88
|
+
const limit = query.limit ?? ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT;
|
|
89
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > ARTIFACT_EVENT_HISTORY_MAX_LIMIT) {
|
|
90
|
+
throw new Error(`artifact event limit must be between 1 and ${ARTIFACT_EVENT_HISTORY_MAX_LIMIT}`);
|
|
91
|
+
}
|
|
92
|
+
if (query.cursor !== undefined && (!Number.isInteger(query.cursor) || query.cursor < 1)) {
|
|
93
|
+
throw new Error("artifact event cursor must be a positive integer");
|
|
94
|
+
}
|
|
95
|
+
if (query.direction !== undefined && query.direction !== "asc" && query.direction !== "desc") {
|
|
96
|
+
throw new Error("artifact event direction must be asc or desc");
|
|
97
|
+
}
|
|
98
|
+
return { ...query, limit, direction: query.direction ?? "desc" };
|
|
99
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* domain/graph-projection.ts — the generic graph projection protocol for external bounded
|
|
3
|
+
* contexts (step 6 of the incremental refactor in
|
|
4
|
+
* reducing-papyrus-consumer-change-amplification-with-modules--pvdo), sketched in
|
|
5
|
+
* papyrus-full-context-mesh-and-domain-storage-ownership-bound-qhzp.
|
|
6
|
+
*
|
|
7
|
+
* An external bounded context owns its own operational state and command authority. It
|
|
8
|
+
* never becomes a Papyrus-native module just to appear in the graph. Instead it publishes
|
|
9
|
+
* bounded, sequenced batches of the context-bearing identities and edges it wants durably
|
|
10
|
+
* materialized in the Context Mesh. Papyrus is an idempotent materialized read model for
|
|
11
|
+
* that producer's data, not its command database.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately out of scope for this walking skeleton, each a separate follow-up:
|
|
14
|
+
* - Producer identity is a request field (`producerId`), not yet derived from scoped
|
|
15
|
+
* authentication -- the decision doc's constraint that "producer identity comes from
|
|
16
|
+
* scoped authentication, not request payloads" is not yet met, since Papyrus's daemon
|
|
17
|
+
* auth model today is one shared bearer token, not per-caller identity. Tracked as a
|
|
18
|
+
* known gap, not silently assumed solved.
|
|
19
|
+
* - Edges reference other artifacts only via an externalId already projected by the SAME
|
|
20
|
+
* producer in this batch or a prior one. Linking a projected artifact to an existing
|
|
21
|
+
* Papyrus-native artifact (e.g. "this Discourse thread discusses this Task") is not yet
|
|
22
|
+
* supported here -- a real, needed capability, but a second batch shape / edge kind, not
|
|
23
|
+
* assumed away.
|
|
24
|
+
* - "Lag" is reported as Papyrus's own last-applied checkpoint; Papyrus has no way to know
|
|
25
|
+
* a producer's true current sequence, so relative lag must be computed by the caller
|
|
26
|
+
* (who does know its own latest sequence) by diffing against this checkpoint.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const GRAPH_PROJECTION_SCHEMA_VERSION = "papyrus.graph-projection/v1";
|
|
30
|
+
|
|
31
|
+
export interface ProjectedArtifact {
|
|
32
|
+
/** The producer's own stable identity for this entity -- never a Papyrus artifact id. */
|
|
33
|
+
readonly externalId: string;
|
|
34
|
+
readonly kind: string;
|
|
35
|
+
readonly subtype?: string;
|
|
36
|
+
readonly title: string;
|
|
37
|
+
readonly body?: string;
|
|
38
|
+
readonly labels?: readonly string[];
|
|
39
|
+
readonly extra?: Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProjectedEdge {
|
|
43
|
+
readonly from: string; // externalId, resolved against this producer's identity map
|
|
44
|
+
readonly relation: string;
|
|
45
|
+
readonly to: string; // externalId, resolved against this producer's identity map
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GraphProjectionBatch {
|
|
49
|
+
readonly schemaVersion: typeof GRAPH_PROJECTION_SCHEMA_VERSION;
|
|
50
|
+
readonly producerId: string;
|
|
51
|
+
readonly batchId: string;
|
|
52
|
+
/** Monotonic per-producer sequence, starting at 1. Enforced gapless -- see GraphProjection.apply. */
|
|
53
|
+
readonly sequence: number;
|
|
54
|
+
readonly artifacts: readonly ProjectedArtifact[];
|
|
55
|
+
readonly edges: readonly ProjectedEdge[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ProjectionCheckpoint {
|
|
59
|
+
readonly producerId: string;
|
|
60
|
+
readonly lastSequence: number;
|
|
61
|
+
readonly lastBatchId: string;
|
|
62
|
+
readonly appliedAt: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface GraphProjectionResult {
|
|
66
|
+
readonly producerId: string;
|
|
67
|
+
readonly batchId: string;
|
|
68
|
+
readonly sequence: number;
|
|
69
|
+
readonly artifactsUpserted: number;
|
|
70
|
+
readonly artifactsCreated: number;
|
|
71
|
+
readonly edgesUpserted: number;
|
|
72
|
+
/** True only when this exact batch was already applied and this call was a safe no-op replay. */
|
|
73
|
+
readonly alreadyApplied: boolean;
|
|
74
|
+
}
|
|
@@ -46,10 +46,29 @@ export interface SkillTaskBlueprint {
|
|
|
46
46
|
extra?: Record<string, unknown>;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* A pipeline step that nests another workflow Skill's run inside this one -- the Jenkins
|
|
51
|
+
* "trigger downstream job and wait" / Ansible "include_tasks" primitive. `skillId` is late-
|
|
52
|
+
* bound: existence and workflow-subtype are checked at execution time (skill-execution.ts),
|
|
53
|
+
* not here, since this validator has no store access. `dependsOn`/`parent` place this step in
|
|
54
|
+
* the SAME dependency graph as ordinary task blueprints -- a task can depend on a skill-call
|
|
55
|
+
* ref (meaning: depend on every task the nested run creates), and a skill-call's own `parent`
|
|
56
|
+
* contains the nested run's root tasks under an outer task.
|
|
57
|
+
*/
|
|
58
|
+
export interface SkillCallBlueprint {
|
|
59
|
+
ref: string;
|
|
60
|
+
title: string;
|
|
61
|
+
skillId: string;
|
|
62
|
+
arguments?: Record<string, unknown>;
|
|
63
|
+
dependsOn?: string[];
|
|
64
|
+
parent?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
49
67
|
export interface SkillBlueprints {
|
|
50
68
|
docs: SkillDocBlueprint[];
|
|
51
69
|
rules: SkillRuleBlueprint[];
|
|
52
70
|
tasks: SkillTaskBlueprint[];
|
|
71
|
+
skills: SkillCallBlueprint[];
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
export interface SkillBlueprintLink {
|
|
@@ -142,19 +161,34 @@ function placeholders(value: unknown, result: Set<string> = new Set()): Set<stri
|
|
|
142
161
|
return result;
|
|
143
162
|
}
|
|
144
163
|
|
|
145
|
-
|
|
146
|
-
|
|
164
|
+
/** Steps sharing one dependency graph: ordinary tasks and skill-call pipeline steps alike. */
|
|
165
|
+
interface DependentStep {
|
|
166
|
+
ref: string;
|
|
167
|
+
dependsOn?: string[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function assertAcyclic(steps: DependentStep[]): void {
|
|
171
|
+
const byRef = new Map(steps.map((step) => [step.ref, step]));
|
|
147
172
|
const visiting = new Set<string>();
|
|
148
173
|
const visited = new Set<string>();
|
|
149
174
|
const visit = (ref: string): void => {
|
|
150
|
-
if (visiting.has(ref)) throw new Error(`skill
|
|
175
|
+
if (visiting.has(ref)) throw new Error(`skill step dependency cycle includes "${ref}"`);
|
|
151
176
|
if (visited.has(ref)) return;
|
|
152
177
|
visiting.add(ref);
|
|
153
178
|
for (const dependency of byRef.get(ref)?.dependsOn ?? []) visit(dependency);
|
|
154
179
|
visiting.delete(ref);
|
|
155
180
|
visited.add(ref);
|
|
156
181
|
};
|
|
157
|
-
for (const
|
|
182
|
+
for (const step of steps) visit(step.ref);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function validateSkillCallBlueprint(value: unknown): SkillCallBlueprint {
|
|
186
|
+
const source = record(value, "skill call blueprint");
|
|
187
|
+
const ref = string(source["ref"], "skill call blueprint ref");
|
|
188
|
+
if (!NAME_PATTERN.test(ref)) throw new Error(`invalid skill blueprint ref "${ref}"`);
|
|
189
|
+
const title = string(source["title"], "skill call blueprint title");
|
|
190
|
+
const skillId = string(source["skillId"], "skill call blueprint skillId");
|
|
191
|
+
return { ...source, ref, title, skillId } as SkillCallBlueprint;
|
|
158
192
|
}
|
|
159
193
|
|
|
160
194
|
export function validateSkillDefinition(value: unknown): SkillDefinition {
|
|
@@ -165,23 +199,38 @@ export function validateSkillDefinition(value: unknown): SkillDefinition {
|
|
|
165
199
|
const docs = array(rawBlueprints["docs"] ?? [], "skill doc blueprints").map((entry) => validateBlueprint<SkillDocBlueprint>(entry, "doc"));
|
|
166
200
|
const rules = array(rawBlueprints["rules"] ?? [], "skill rule blueprints").map((entry) => validateBlueprint<SkillRuleBlueprint>(entry, "rule"));
|
|
167
201
|
const tasks = array(rawBlueprints["tasks"] ?? [], "skill task blueprints").map((entry) => validateBlueprint<SkillTaskBlueprint>(entry, "task"));
|
|
168
|
-
const
|
|
202
|
+
const skillCalls = array(rawBlueprints["skills"] ?? [], "skill call blueprints").map(validateSkillCallBlueprint);
|
|
203
|
+
const all = [...docs, ...rules, ...tasks, ...skillCalls];
|
|
169
204
|
if (all.length === 0 || all.length > SKILL_MAX_BLUEPRINTS) throw new Error(`skill blueprints must contain 1-${SKILL_MAX_BLUEPRINTS} artifacts`);
|
|
170
205
|
const refs = new Set<string>();
|
|
171
206
|
for (const blueprint of all) {
|
|
172
207
|
if (refs.has(blueprint.ref)) throw new Error(`duplicate skill blueprint ref "${blueprint.ref}"`);
|
|
173
208
|
refs.add(blueprint.ref);
|
|
174
209
|
}
|
|
210
|
+
// Tasks and skill-call pipeline steps share one dependency graph: a task may depend on a
|
|
211
|
+
// skill-call ref (meaning: depend on every task that nested run creates), and vice versa.
|
|
212
|
+
const stepRefs = new Set<string>([...tasks.map((task) => task.ref), ...skillCalls.map((call) => call.ref)]);
|
|
175
213
|
for (const task of tasks) {
|
|
176
214
|
if (task.dependsOn !== undefined && !Array.isArray(task.dependsOn)) throw new Error(`skill task "${task.ref}" dependsOn must be an array`);
|
|
177
215
|
for (const dependency of task.dependsOn ?? []) {
|
|
178
|
-
if (!
|
|
216
|
+
if (!stepRefs.has(dependency)) throw new Error(`unknown skill task dependency ref "${dependency}"`);
|
|
179
217
|
}
|
|
218
|
+
// parent stays task-only: containment under a skill-call step's exploded task SET has no
|
|
219
|
+
// single natural parent, so parent must name an actual task blueprint.
|
|
180
220
|
if (task.parent !== undefined && !tasks.some((candidate) => candidate.ref === task.parent)) {
|
|
181
221
|
throw new Error(`unknown skill task parent ref "${task.parent}"`);
|
|
182
222
|
}
|
|
183
223
|
}
|
|
184
|
-
|
|
224
|
+
for (const call of skillCalls) {
|
|
225
|
+
if (call.dependsOn !== undefined && !Array.isArray(call.dependsOn)) throw new Error(`skill call "${call.ref}" dependsOn must be an array`);
|
|
226
|
+
for (const dependency of call.dependsOn ?? []) {
|
|
227
|
+
if (!stepRefs.has(dependency)) throw new Error(`unknown skill call dependency ref "${dependency}"`);
|
|
228
|
+
}
|
|
229
|
+
if (call.parent !== undefined && !tasks.some((candidate) => candidate.ref === call.parent)) {
|
|
230
|
+
throw new Error(`unknown skill call parent ref "${call.parent}"`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
assertAcyclic([...tasks, ...skillCalls]);
|
|
185
234
|
for (const name of placeholders(all)) {
|
|
186
235
|
if (!Object.hasOwn(inputs, name)) throw new Error(`unknown skill input placeholder "${name}"`);
|
|
187
236
|
}
|
|
@@ -196,7 +245,7 @@ export function validateSkillDefinition(value: unknown): SkillDefinition {
|
|
|
196
245
|
return { from, relation, to };
|
|
197
246
|
});
|
|
198
247
|
if (links.length > SKILL_MAX_LINKS) throw new Error(`skill links exceed ${SKILL_MAX_LINKS}`);
|
|
199
|
-
return { version: 1, inputs, blueprints: { docs, rules, tasks }, links };
|
|
248
|
+
return { version: 1, inputs, blueprints: { docs, rules, tasks, skills: skillCalls }, links };
|
|
200
249
|
}
|
|
201
250
|
|
|
202
251
|
export function resolveSkillArguments(definition: SkillDefinition, value: unknown): Record<string, SkillArgumentValue> {
|
package/src/domain/task-event.ts
CHANGED
|
@@ -24,6 +24,10 @@ export const TASK_EVENT_TYPES = [
|
|
|
24
24
|
"retried",
|
|
25
25
|
"completed",
|
|
26
26
|
"canceled",
|
|
27
|
+
"dependency_added",
|
|
28
|
+
"dependency_removed",
|
|
29
|
+
"containment_added",
|
|
30
|
+
"containment_removed",
|
|
27
31
|
] as const;
|
|
28
32
|
|
|
29
33
|
export type TaskEventType = typeof TASK_EVENT_TYPES[number];
|