diff --git a/packages/plugins/plugin-triage/migrations/001_triage_core.sql b/packages/plugins/plugin-triage/migrations/001_triage_core.sql index a7d42f2cc67..a7b898d1640 100644 --- a/packages/plugins/plugin-triage/migrations/001_triage_core.sql +++ b/packages/plugins/plugin-triage/migrations/001_triage_core.sql @@ -67,6 +67,10 @@ CREATE TABLE plugin_triage_f3b4aa721e.triage_queue_chats ( CREATE INDEX triage_queue_chats_company_queue_idx ON plugin_triage_f3b4aa721e.triage_queue_chats (company_id, queue_id, updated_at DESC); +CREATE UNIQUE INDEX triage_queue_chats_active_queue_idx + ON plugin_triage_f3b4aa721e.triage_queue_chats (queue_id) + WHERE status = 'active'; + CREATE TABLE plugin_triage_f3b4aa721e.triage_items ( id uuid PRIMARY KEY, company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, diff --git a/packages/plugins/plugin-triage/src/manifest.ts b/packages/plugins/plugin-triage/src/manifest.ts index 62a326e2d69..8a1642e181f 100644 --- a/packages/plugins/plugin-triage/src/manifest.ts +++ b/packages/plugins/plugin-triage/src/manifest.ts @@ -69,6 +69,16 @@ const manifest: PaperclipPluginManifestV1 = { "database.namespace.read", "database.namespace.write", "companies.read", + "issues.read", + "issues.create", + "issues.update", + "issue.comments.read", + "issue.comments.create", + "agents.read", + "agents.resume", + "agent.sessions.create", + "agent.sessions.list", + "agent.sessions.send", "agents.managed", "projects.managed", "skills.managed", diff --git a/packages/plugins/plugin-triage/src/triage.ts b/packages/plugins/plugin-triage/src/triage.ts index 7eaab7f09be..672e388ec31 100644 --- a/packages/plugins/plugin-triage/src/triage.ts +++ b/packages/plugins/plugin-triage/src/triage.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import type { PluginContext } from "@paperclipai/plugin-sdk"; +import type { Issue, PluginContext } from "@paperclipai/plugin-sdk"; export type TriageQueueStatus = "active" | "archived"; export type TriageItemStatus = "active" | "archived"; @@ -53,12 +53,108 @@ export interface TriageItemEvent { createdAt: string; } +export interface TriageQueueChat { + id: string; + companyId: string; + queueId: string; + hiddenIssueId: string | null; + title: string | null; + status: "active" | "archived"; + metadata: Record; + createdAt: string; + updatedAt: string; +} + +export interface TriageGuidanceDoc { + id: string; + companyId: string; + queueId: string; + path: string; + title: string; + status: "active" | "archived"; + currentRevisionId: string | null; + content: string; + contentHash: string | null; + summary: string | null; + metadata: Record; + createdAt: string; + updatedAt: string; +} + +export interface TriageGuidanceRevision { + id: string; + companyId: string; + queueId: string; + docId: string; + content: string; + contentHash: string | null; + summary: string | null; + actorType: string | null; + actorId: string | null; + metadata: Record; + createdAt: string; +} + +export type TriageGuidanceProposalStatus = "proposed" | "revised" | "accepted" | "rejected"; + +export interface TriageGuidanceProposal { + id: string; + companyId: string; + queueId: string; + itemId: string | null; + targetDocId: string | null; + status: TriageGuidanceProposalStatus; + proposedContent: string; + rationale: string | null; + metadata: Record; + createdAt: string; + updatedAt: string; +} + export interface TriageActor { actorType?: string | null; actorId?: string | null; actorRunId?: string | null; } +export type TriageIssueActionMode = "create_if_missing" | "update_existing" | "create_or_update"; + +export interface TriageCreateOrUpdateIssueAction { + type: "create_or_update_issue"; + mode: TriageIssueActionMode; + template: Record; +} + +export interface TriageTransitionAction { + id: string; + companyId: string; + queueId: string; + actionKey: string; + fromStateKey: string; + toStateKey: string; + actionType: "create_or_update_issue"; + enabled: boolean; + action: TriageCreateOrUpdateIssueAction; + createdAt: string; + updatedAt: string; +} + +export interface TriageTransitionActionResult { + actionKey: string; + actionType: "create_or_update_issue"; + mode: TriageIssueActionMode; + result: "created" | "updated" | "skipped_existing"; + issueId: string | null; + commentCreated: boolean; +} + +export interface TriageItemTransitionResult { + item: TriageItem; + transitionEvent: TriageItemEvent; + actionEvents: TriageItemEvent[]; + actionResults: TriageTransitionActionResult[]; +} + export interface IngestItemInput { companyId: string; queueKey: string; @@ -96,6 +192,7 @@ export class TriageError extends Error { export interface TriageStore { listQueues(companyId: string): Promise; getQueue(companyId: string, queueKey: string): Promise; + getQueueById(companyId: string, queueId: string): Promise; createQueue(input: { companyId: string; queueKey: string; @@ -141,8 +238,26 @@ export interface TriageStore { properties?: Record | null; stateKey?: string | null; status?: TriageItemStatus; + linkedQueueChatId?: string | null; + linkedWorkIssueId?: string | null; }): Promise; archiveItem(companyId: string, itemId: string): Promise; + upsertTransitionAction(input: { + companyId: string; + queueId: string; + actionKey: string; + fromStateKey: string; + toStateKey: string; + action: TriageCreateOrUpdateIssueAction; + enabled?: boolean; + }): Promise; + listTransitionActions(input: { + companyId: string; + queueId: string; + fromStateKey?: string | null; + toStateKey?: string | null; + actionKey?: string | null; + }): Promise; recordItemEvent(input: { companyId: string; queueId: string; @@ -153,6 +268,54 @@ export interface TriageStore { actor?: TriageActor; metadata?: Record; }): Promise; + listItemEvents(companyId: string, itemId: string): Promise; + getActiveQueueChat(companyId: string, queueId: string): Promise; + createQueueChat(input: { + companyId: string; + queueId: string; + hiddenIssueId?: string | null; + title?: string | null; + metadata?: Record; + }): Promise<{ chat: TriageQueueChat; created: boolean }>; + updateQueueChat(input: { + companyId: string; + chatId: string; + hiddenIssueId?: string | null; + title?: string | null; + status?: TriageQueueChat["status"]; + metadata?: Record; + }): Promise; + listGuidanceDocs(companyId: string, queueId: string): Promise; + getGuidanceDocByPath(companyId: string, queueId: string, path: string): Promise; + upsertGuidanceRevision(input: { + companyId: string; + queueId: string; + path: string; + title: string; + content: string; + summary?: string | null; + actor?: TriageActor; + metadata?: Record; + }): Promise<{ doc: TriageGuidanceDoc; revision: TriageGuidanceRevision }>; + listGuidanceProposals(companyId: string, queueId: string): Promise; + getGuidanceProposal(companyId: string, proposalId: string): Promise; + createGuidanceProposal(input: { + companyId: string; + queueId: string; + itemId?: string | null; + targetDocId?: string | null; + proposedContent: string; + rationale?: string | null; + metadata?: Record; + }): Promise; + updateGuidanceProposal(input: { + companyId: string; + proposalId: string; + status?: TriageGuidanceProposalStatus; + proposedContent?: string | null; + rationale?: string | null; + metadata?: Record; + }): Promise; } function nowIso(): string { @@ -176,6 +339,17 @@ function objectField(value: unknown): Record { return {}; } +function jsonObjectField(value: unknown): Record { + if (typeof value === "string") { + try { + return objectField(JSON.parse(value)); + } catch { + return {}; + } + } + return objectField(value); +} + function normalizeQueueKey(value: unknown): string { const queueKey = stringField(value); if (!queueKey) { @@ -211,6 +385,81 @@ function sanitizeContentFormat(value: unknown): string { return contentFormat; } +function normalizeStateKey(value: unknown, fieldName: string): string { + const stateKey = stringField(value); + if (!stateKey) throw new TriageError(400, `${fieldName}_required`, `${fieldName} is required`); + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(stateKey)) { + throw new TriageError(422, `invalid_${fieldName}`, `${fieldName} is invalid`); + } + return stateKey; +} + +function normalizeActionKey(value: unknown): string { + const actionKey = stringField(value); + if (!actionKey) throw new TriageError(400, "action_key_required", "actionKey is required"); + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(actionKey)) { + throw new TriageError(422, "invalid_action_key", "actionKey is invalid"); + } + return actionKey; +} + +const ISSUE_TEMPLATE_FIELDS = new Set([ + "title", + "description", + "comment", + "projectId", + "assignee", + "assigneeAgentId", + "assigneeUserId", + "priority", + "status", +]); + +const ISSUE_PRIORITIES = new Set(["critical", "high", "medium", "low"]); +const ISSUE_STATUSES = new Set([ + "backlog", + "todo", + "in_progress", + "in_review", + "done", + "blocked", + "cancelled", +]); + +function parseTransitionIssueAction(value: unknown): TriageCreateOrUpdateIssueAction { + const raw = objectField(value); + if (raw.type !== "create_or_update_issue") { + throw new TriageError(422, "unsupported_transition_action", "Only create_or_update_issue actions are supported in v1"); + } + const mode = raw.mode === "create_if_missing" || raw.mode === "update_existing" || raw.mode === "create_or_update" + ? raw.mode + : null; + if (!mode) { + throw new TriageError(422, "invalid_transition_action_mode", "Transition action mode is invalid"); + } + const template = objectField(raw.template); + const templateKeys = Object.keys(template); + if (templateKeys.length === 0) { + throw new TriageError(422, "invalid_transition_action_template", "Transition action template cannot be empty"); + } + for (const key of templateKeys) { + if (!ISSUE_TEMPLATE_FIELDS.has(key)) { + throw new TriageError(422, "invalid_transition_action_template", `Unsupported issue template field: ${key}`); + } + if (typeof template[key] !== "string") { + throw new TriageError(422, "invalid_transition_action_template", `Issue template field must be a string: ${key}`); + } + } + if ((mode === "create_if_missing" || mode === "create_or_update") && typeof template.title !== "string") { + throw new TriageError(422, "invalid_transition_action_template", "Issue template title is required when an action can create an issue"); + } + return { + type: "create_or_update_issue", + mode, + template: Object.fromEntries(templateKeys.map((key) => [key, String(template[key])])), + }; +} + function table(namespace: string, name: string): string { return `${namespace}.${name}`; } @@ -276,15 +525,121 @@ function eventFromRow(row: Record): TriageItemEvent { }; } +function actionFromRow(row: Record): TriageTransitionAction { + const action = parseTransitionIssueAction(jsonObjectField(row.template)); + return { + id: String(row.id), + companyId: String(row.company_id), + queueId: String(row.queue_id), + actionKey: String(row.action_key), + fromStateKey: String(row.from_state_key), + toStateKey: String(row.to_state_key), + actionType: "create_or_update_issue", + enabled: row.enabled !== false, + action, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function chatFromRow(row: Record): TriageQueueChat { + return { + id: String(row.id), + companyId: String(row.company_id), + queueId: String(row.queue_id), + hiddenIssueId: row.hidden_issue_id === null || row.hidden_issue_id === undefined + ? null + : String(row.hidden_issue_id), + title: row.title === null || row.title === undefined ? null : String(row.title), + status: row.status === "archived" ? "archived" : "active", + metadata: objectField(row.metadata), + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function guidanceDocFromRow(row: Record): TriageGuidanceDoc { + return { + id: String(row.id), + companyId: String(row.company_id), + queueId: String(row.queue_id), + path: String(row.path), + title: String(row.title), + status: row.status === "archived" ? "archived" : "active", + currentRevisionId: row.current_revision_id === null || row.current_revision_id === undefined + ? null + : String(row.current_revision_id), + content: String(row.content ?? ""), + contentHash: row.content_hash === null || row.content_hash === undefined ? null : String(row.content_hash), + summary: row.summary === null || row.summary === undefined ? null : String(row.summary), + metadata: objectField(row.metadata), + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function guidanceRevisionFromRow(row: Record): TriageGuidanceRevision { + return { + id: String(row.id), + companyId: String(row.company_id), + queueId: String(row.queue_id), + docId: String(row.doc_id), + content: String(row.content ?? ""), + contentHash: row.content_hash === null || row.content_hash === undefined ? null : String(row.content_hash), + summary: row.summary === null || row.summary === undefined ? null : String(row.summary), + actorType: row.actor_type === null || row.actor_type === undefined ? null : String(row.actor_type), + actorId: row.actor_id === null || row.actor_id === undefined ? null : String(row.actor_id), + metadata: objectField(row.metadata), + createdAt: toIso(row.created_at), + }; +} + +function proposalStatus(value: unknown): TriageGuidanceProposalStatus { + return value === "accepted" || value === "rejected" || value === "revised" ? value : "proposed"; +} + +function guidanceProposalFromRow(row: Record): TriageGuidanceProposal { + return { + id: String(row.id), + companyId: String(row.company_id), + queueId: String(row.queue_id), + itemId: row.item_id === null || row.item_id === undefined ? null : String(row.item_id), + targetDocId: row.target_doc_id === null || row.target_doc_id === undefined ? null : String(row.target_doc_id), + status: proposalStatus(row.status), + proposedContent: String(row.proposed_content ?? ""), + rationale: row.rationale === null || row.rationale === undefined ? null : String(row.rationale), + metadata: objectField(row.metadata), + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function normalizeGuidancePath(value: unknown): string { + const path = stringField(value) ?? "guidance.md"; + if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,160}$/.test(path) || path.includes("..")) { + throw new TriageError(422, "invalid_guidance_path", "Guidance path is invalid"); + } + return path; +} + +function numberField(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value); + return null; +} + export function createPostgresTriageStore(ctx: Pick): TriageStore { const namespace = ctx.db.namespace; const queues = table(namespace, "triage_queues"); const states = table(namespace, "triage_queue_states"); const transitions = table(namespace, "triage_queue_transitions"); + const chats = table(namespace, "triage_queue_chats"); const docs = table(namespace, "triage_guidance_docs"); const revisions = table(namespace, "triage_guidance_doc_revisions"); + const proposals = table(namespace, "triage_guidance_proposals"); const items = table(namespace, "triage_items"); const events = table(namespace, "triage_item_events"); + const actions = table(namespace, "triage_transition_actions"); async function getQueueById(companyId: string, queueId: string): Promise { const rows = await ctx.db.query>( @@ -336,6 +691,10 @@ export function createPostgresTriageStore(ctx: Pick): Triag return rows[0] ? queueFromRow(rows[0]) : null; }, + async getQueueById(companyId, queueId) { + return getQueueById(companyId, queueId); + }, + async createQueue(input) { const id = randomUUID(); await ctx.db.execute( @@ -497,6 +856,8 @@ export function createPostgresTriageStore(ctx: Pick): Triag properties = COALESCE($8::jsonb, properties), state_key = COALESCE($9, state_key), status = COALESCE($10, status), + linked_queue_chat_id = COALESCE($11::uuid, linked_queue_chat_id), + linked_work_issue_id = COALESCE($12::uuid, linked_work_issue_id), revision = revision + 1, last_ingested_at = now(), updated_at = now() @@ -512,6 +873,8 @@ export function createPostgresTriageStore(ctx: Pick): Triag input.properties === undefined || input.properties === null ? null : JSON.stringify(input.properties), input.stateKey ?? null, input.status ?? null, + input.linkedQueueChatId ?? null, + input.linkedWorkIssueId ?? null, ], ); const item = await getItemById(input.companyId, input.itemId); @@ -524,6 +887,59 @@ export function createPostgresTriageStore(ctx: Pick): Triag return this.updateItem({ companyId, itemId, status: "archived" }); }, + async upsertTransitionAction(input) { + const id = randomUUID(); + const action = parseTransitionIssueAction(input.action); + await ctx.db.execute( + `INSERT INTO ${actions} + (id, company_id, queue_id, action_key, from_state_key, to_state_key, action_type, enabled, template) + VALUES ($1, $2, $3, $4, $5, $6, 'create_or_update_issue', $7, $8::jsonb) + ON CONFLICT (queue_id, action_key) DO UPDATE + SET from_state_key = EXCLUDED.from_state_key, + to_state_key = EXCLUDED.to_state_key, + action_type = EXCLUDED.action_type, + enabled = EXCLUDED.enabled, + template = EXCLUDED.template, + updated_at = now()`, + [ + id, + input.companyId, + input.queueId, + input.actionKey, + input.fromStateKey, + input.toStateKey, + input.enabled ?? true, + JSON.stringify(action), + ], + ); + const rows = await ctx.db.query>( + `SELECT * FROM ${actions} WHERE company_id = $1 AND queue_id = $2 AND action_key = $3 LIMIT 1`, + [input.companyId, input.queueId, input.actionKey], + ); + if (!rows[0]) throw new TriageError(500, "transition_action_save_failed", "Transition action was not saved"); + return actionFromRow(rows[0]); + }, + + async listTransitionActions(input) { + const rows = await ctx.db.query>( + `SELECT * FROM ${actions} + WHERE company_id = $1 + AND queue_id = $2 + AND ($3::text IS NULL OR from_state_key = $3) + AND ($4::text IS NULL OR to_state_key = $4) + AND ($5::text IS NULL OR action_key = $5) + ORDER BY created_at ASC`, + [ + input.companyId, + input.queueId, + input.fromStateKey ?? null, + input.toStateKey ?? null, + input.actionKey ?? null, + ], + ); + return rows.map(actionFromRow); + }, + async recordItemEvent(input) { const id = randomUUID(); await ctx.db.execute( @@ -565,6 +981,212 @@ export function createPostgresTriageStore(ctx: Pick): Triag createdAt: nowIso(), }; }, + + async listItemEvents(companyId, itemId) { + const rows = await ctx.db.query>( + `SELECT * FROM ${events} + WHERE company_id = $1 AND item_id = $2 + ORDER BY created_at DESC`, + [companyId, itemId], + ); + return rows.map(eventFromRow); + }, + + async getActiveQueueChat(companyId, queueId) { + const rows = await ctx.db.query>( + `SELECT * FROM ${chats} + WHERE company_id = $1 AND queue_id = $2 AND status = 'active' + ORDER BY updated_at DESC + LIMIT 1`, + [companyId, queueId], + ); + return rows[0] ? chatFromRow(rows[0]) : null; + }, + + async createQueueChat(input) { + const existing = await this.getActiveQueueChat(input.companyId, input.queueId); + if (existing) return { chat: existing, created: false }; + const id = randomUUID(); + await ctx.db.execute( + `INSERT INTO ${chats} + (id, company_id, queue_id, hidden_issue_id, title, metadata) + VALUES ($1, $2, $3, $4, $5, $6::jsonb) + ON CONFLICT DO NOTHING`, + [ + id, + input.companyId, + input.queueId, + input.hiddenIssueId ?? null, + input.title ?? null, + JSON.stringify(input.metadata ?? {}), + ], + ); + const chat = await this.getActiveQueueChat(input.companyId, input.queueId); + if (!chat) throw new TriageError(500, "queue_chat_create_failed", "Queue chat was not created"); + return { chat, created: chat.id === id }; + }, + + async updateQueueChat(input) { + await ctx.db.execute( + `UPDATE ${chats} + SET hidden_issue_id = COALESCE($3::uuid, hidden_issue_id), + title = COALESCE($4, title), + status = COALESCE($5, status), + metadata = COALESCE($6::jsonb, metadata), + updated_at = now() + WHERE company_id = $1 AND id = $2`, + [ + input.companyId, + input.chatId, + input.hiddenIssueId ?? null, + input.title ?? null, + input.status ?? null, + input.metadata === undefined ? null : JSON.stringify(input.metadata), + ], + ); + const rows = await ctx.db.query>( + `SELECT * FROM ${chats} WHERE company_id = $1 AND id = $2 LIMIT 1`, + [input.companyId, input.chatId], + ); + if (!rows[0]) throw new TriageError(404, "queue_chat_not_found", "Queue chat not found"); + return chatFromRow(rows[0]); + }, + + async listGuidanceDocs(companyId, queueId) { + const rows = await ctx.db.query>( + `SELECT d.*, r.content, r.content_hash, r.summary + FROM ${docs} d + LEFT JOIN ${revisions} r + ON r.company_id = d.company_id AND r.id = d.current_revision_id + WHERE d.company_id = $1 AND d.queue_id = $2 AND d.status = 'active' + ORDER BY d.path ASC`, + [companyId, queueId], + ); + return rows.map(guidanceDocFromRow); + }, + + async getGuidanceDocByPath(companyId, queueId, path) { + const rows = await ctx.db.query>( + `SELECT d.*, r.content, r.content_hash, r.summary + FROM ${docs} d + LEFT JOIN ${revisions} r + ON r.company_id = d.company_id AND r.id = d.current_revision_id + WHERE d.company_id = $1 AND d.queue_id = $2 AND d.path = $3 + LIMIT 1`, + [companyId, queueId, path], + ); + return rows[0] ? guidanceDocFromRow(rows[0]) : null; + }, + + async upsertGuidanceRevision(input) { + const existingDoc = await this.getGuidanceDocByPath(input.companyId, input.queueId, input.path); + const docId = existingDoc?.id ?? randomUUID(); + const revisionId = randomUUID(); + const contentHash = createHash("sha256").update(input.content).digest("hex"); + if (!existingDoc) { + await ctx.db.execute( + `INSERT INTO ${docs} + (id, company_id, queue_id, path, title, current_revision_id) + VALUES ($1, $2, $3, $4, $5, $6)`, + [docId, input.companyId, input.queueId, input.path, input.title, revisionId], + ); + } + await ctx.db.execute( + `INSERT INTO ${revisions} + (id, company_id, queue_id, doc_id, content, content_hash, summary, actor_type, actor_id, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)`, + [ + revisionId, + input.companyId, + input.queueId, + docId, + input.content, + contentHash, + input.summary ?? null, + input.actor?.actorType ?? null, + input.actor?.actorId ?? null, + JSON.stringify(input.metadata ?? {}), + ], + ); + await ctx.db.execute( + `UPDATE ${docs} + SET title = $4, current_revision_id = $5, updated_at = now() + WHERE company_id = $1 AND queue_id = $2 AND id = $3`, + [input.companyId, input.queueId, docId, input.title, revisionId], + ); + const doc = await this.getGuidanceDocByPath(input.companyId, input.queueId, input.path); + const revisionRows = await ctx.db.query>( + `SELECT * FROM ${revisions} WHERE company_id = $1 AND id = $2 LIMIT 1`, + [input.companyId, revisionId], + ); + if (!doc || !revisionRows[0]) { + throw new TriageError(500, "guidance_revision_failed", "Guidance revision was not saved"); + } + return { doc, revision: guidanceRevisionFromRow(revisionRows[0]) }; + }, + + async listGuidanceProposals(companyId, queueId) { + const rows = await ctx.db.query>( + `SELECT * FROM ${proposals} + WHERE company_id = $1 AND queue_id = $2 + ORDER BY updated_at DESC`, + [companyId, queueId], + ); + return rows.map(guidanceProposalFromRow); + }, + + async getGuidanceProposal(companyId, proposalId) { + const rows = await ctx.db.query>( + `SELECT * FROM ${proposals} WHERE company_id = $1 AND id = $2 LIMIT 1`, + [companyId, proposalId], + ); + return rows[0] ? guidanceProposalFromRow(rows[0]) : null; + }, + + async createGuidanceProposal(input) { + const id = randomUUID(); + await ctx.db.execute( + `INSERT INTO ${proposals} + (id, company_id, queue_id, item_id, target_doc_id, proposed_content, rationale, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`, + [ + id, + input.companyId, + input.queueId, + input.itemId ?? null, + input.targetDocId ?? null, + input.proposedContent, + input.rationale ?? null, + JSON.stringify(input.metadata ?? {}), + ], + ); + const proposal = await this.getGuidanceProposal(input.companyId, id); + if (!proposal) throw new TriageError(500, "guidance_proposal_create_failed", "Guidance proposal was not created"); + return proposal; + }, + + async updateGuidanceProposal(input) { + await ctx.db.execute( + `UPDATE ${proposals} + SET status = COALESCE($3, status), + proposed_content = COALESCE($4, proposed_content), + rationale = COALESCE($5, rationale), + metadata = COALESCE($6::jsonb, metadata), + updated_at = now() + WHERE company_id = $1 AND id = $2`, + [ + input.companyId, + input.proposalId, + input.status ?? null, + input.proposedContent ?? null, + input.rationale ?? null, + input.metadata === undefined ? null : JSON.stringify(input.metadata), + ], + ); + const proposal = await this.getGuidanceProposal(input.companyId, input.proposalId); + if (!proposal) throw new TriageError(404, "guidance_proposal_not_found", "Guidance proposal not found"); + return proposal; + }, }; } @@ -572,6 +1194,11 @@ export function createInMemoryTriageStore(): TriageStore { const queues = new Map(); const items = new Map(); const events = new Map(); + const actions = new Map(); + const chats = new Map(); + const docs = new Map(); + const revisions = new Map(); + const proposals = new Map(); const defaultedQueues = new Set(); function queueIndex(companyId: string, queueKey: string): string { @@ -586,6 +1213,26 @@ export function createInMemoryTriageStore(): TriageStore { return { ...item, properties: { ...item.properties } }; } + function cloneAction(action: TriageTransitionAction): TriageTransitionAction { + return { ...action, action: { ...action.action, template: { ...action.action.template } } }; + } + + function cloneChat(chat: TriageQueueChat): TriageQueueChat { + return { ...chat, metadata: { ...chat.metadata } }; + } + + function cloneDoc(doc: TriageGuidanceDoc): TriageGuidanceDoc { + return { ...doc, metadata: { ...doc.metadata } }; + } + + function cloneRevision(revision: TriageGuidanceRevision): TriageGuidanceRevision { + return { ...revision, metadata: { ...revision.metadata } }; + } + + function cloneProposal(proposal: TriageGuidanceProposal): TriageGuidanceProposal { + return { ...proposal, metadata: { ...proposal.metadata } }; + } + function recalculateCounts(queueId: string): void { const queue = [...queues.values()].find((candidate) => candidate.id === queueId); if (!queue) return; @@ -608,6 +1255,12 @@ export function createInMemoryTriageStore(): TriageStore { return queue ? cloneQueue(queue) : null; }, + async getQueueById(companyId, queueId) { + const queue = [...queues.values()].find((candidate) => + candidate.companyId === companyId && candidate.id === queueId); + return queue ? cloneQueue(queue) : null; + }, + async createQueue(input) { const index = queueIndex(input.companyId, input.queueKey); const existing = queues.get(index); @@ -631,6 +1284,41 @@ export function createInMemoryTriageStore(): TriageStore { }, async ensureQueueDefaults(queue) { + if (!docs.has(`${queue.id}:guidance.md`)) { + const timestamp = nowIso(); + const revisionId = randomUUID(); + const content = `# ${queue.title} Guidance\n\nCapture queue-specific policy, taste, examples, and handling rules here.\n`; + const doc: TriageGuidanceDoc = { + id: randomUUID(), + companyId: queue.companyId, + queueId: queue.id, + path: "guidance.md", + title: "Guidance", + status: "active", + currentRevisionId: revisionId, + content, + contentHash: createHash("sha256").update(content).digest("hex"), + summary: "Initial guidance document", + metadata: {}, + createdAt: timestamp, + updatedAt: timestamp, + }; + const revision: TriageGuidanceRevision = { + id: revisionId, + companyId: queue.companyId, + queueId: queue.id, + docId: doc.id, + content, + contentHash: doc.contentHash, + summary: doc.summary, + actorType: null, + actorId: null, + metadata: {}, + createdAt: timestamp, + }; + docs.set(`${queue.id}:guidance.md`, doc); + revisions.set(revisionId, revision); + } defaultedQueues.add(queue.id); }, @@ -725,6 +1413,12 @@ export function createInMemoryTriageStore(): TriageStore { if (input.properties !== undefined && input.properties !== null) item.properties = { ...input.properties }; if (input.stateKey !== undefined && input.stateKey !== null) item.stateKey = input.stateKey; if (input.status) item.status = input.status; + if (input.linkedQueueChatId !== undefined && input.linkedQueueChatId !== null) { + item.linkedQueueChatId = input.linkedQueueChatId; + } + if (input.linkedWorkIssueId !== undefined && input.linkedWorkIssueId !== null) { + item.linkedWorkIssueId = input.linkedWorkIssueId; + } item.revision += 1; item.lastIngestedAt = nowIso(); item.updatedAt = item.lastIngestedAt; @@ -736,6 +1430,37 @@ export function createInMemoryTriageStore(): TriageStore { return this.updateItem({ companyId, itemId, status: "archived" }); }, + async upsertTransitionAction(input) { + const timestamp = nowIso(); + const index = `${input.queueId}:${input.actionKey}`; + const existing = actions.get(index); + const action: TriageTransitionAction = { + id: existing?.id ?? randomUUID(), + companyId: input.companyId, + queueId: input.queueId, + actionKey: input.actionKey, + fromStateKey: input.fromStateKey, + toStateKey: input.toStateKey, + actionType: "create_or_update_issue", + enabled: input.enabled ?? existing?.enabled ?? true, + action: parseTransitionIssueAction(input.action), + createdAt: existing?.createdAt ?? timestamp, + updatedAt: timestamp, + }; + actions.set(index, action); + return cloneAction(action); + }, + + async listTransitionActions(input) { + return [...actions.values()] + .filter((action) => action.companyId === input.companyId && action.queueId === input.queueId) + .filter((action) => input.fromStateKey ? action.fromStateKey === input.fromStateKey : true) + .filter((action) => input.toStateKey ? action.toStateKey === input.toStateKey : true) + .filter((action) => input.actionKey ? action.actionKey === input.actionKey : true) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)) + .map(cloneAction); + }, + async recordItemEvent(input) { const event: TriageItemEvent = { id: randomUUID(), @@ -754,9 +1479,221 @@ export function createInMemoryTriageStore(): TriageStore { events.set(event.id, event); return { ...event, metadata: { ...event.metadata } }; }, + + async listItemEvents(companyId, itemId) { + return [...events.values()] + .filter((event) => event.companyId === companyId && event.itemId === itemId) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .map((event) => ({ ...event, metadata: { ...event.metadata } })); + }, + + async getActiveQueueChat(companyId, queueId) { + const chat = [...chats.values()] + .filter((candidate) => + candidate.companyId === companyId && candidate.queueId === queueId && candidate.status === "active") + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]; + return chat ? cloneChat(chat) : null; + }, + + async createQueueChat(input) { + const existing = await this.getActiveQueueChat(input.companyId, input.queueId); + if (existing) return { chat: existing, created: false }; + const timestamp = nowIso(); + const chat: TriageQueueChat = { + id: randomUUID(), + companyId: input.companyId, + queueId: input.queueId, + hiddenIssueId: input.hiddenIssueId ?? null, + title: input.title ?? null, + status: "active", + metadata: { ...(input.metadata ?? {}) }, + createdAt: timestamp, + updatedAt: timestamp, + }; + chats.set(chat.id, chat); + return { chat: cloneChat(chat), created: true }; + }, + + async updateQueueChat(input) { + const chat = chats.get(input.chatId); + if (!chat || chat.companyId !== input.companyId) { + throw new TriageError(404, "queue_chat_not_found", "Queue chat not found"); + } + if (input.hiddenIssueId !== undefined && input.hiddenIssueId !== null) chat.hiddenIssueId = input.hiddenIssueId; + if (input.title !== undefined) chat.title = input.title; + if (input.status) chat.status = input.status; + if (input.metadata !== undefined) chat.metadata = { ...input.metadata }; + chat.updatedAt = nowIso(); + return cloneChat(chat); + }, + + async listGuidanceDocs(companyId, queueId) { + return [...docs.values()] + .filter((doc) => doc.companyId === companyId && doc.queueId === queueId && doc.status === "active") + .sort((a, b) => a.path.localeCompare(b.path)) + .map(cloneDoc); + }, + + async getGuidanceDocByPath(companyId, queueId, path) { + const doc = docs.get(`${queueId}:${path}`); + return doc && doc.companyId === companyId ? cloneDoc(doc) : null; + }, + + async upsertGuidanceRevision(input) { + const timestamp = nowIso(); + const key = `${input.queueId}:${input.path}`; + const existing = docs.get(key); + const revision: TriageGuidanceRevision = { + id: randomUUID(), + companyId: input.companyId, + queueId: input.queueId, + docId: existing?.id ?? randomUUID(), + content: input.content, + contentHash: createHash("sha256").update(input.content).digest("hex"), + summary: input.summary ?? null, + actorType: input.actor?.actorType ?? null, + actorId: input.actor?.actorId ?? null, + metadata: { ...(input.metadata ?? {}) }, + createdAt: timestamp, + }; + const doc: TriageGuidanceDoc = { + id: revision.docId, + companyId: input.companyId, + queueId: input.queueId, + path: input.path, + title: input.title, + status: existing?.status ?? "active", + currentRevisionId: revision.id, + content: input.content, + contentHash: revision.contentHash, + summary: revision.summary, + metadata: existing ? { ...existing.metadata } : {}, + createdAt: existing?.createdAt ?? timestamp, + updatedAt: timestamp, + }; + revisions.set(revision.id, revision); + docs.set(key, doc); + return { doc: cloneDoc(doc), revision: cloneRevision(revision) }; + }, + + async listGuidanceProposals(companyId, queueId) { + return [...proposals.values()] + .filter((proposal) => proposal.companyId === companyId && proposal.queueId === queueId) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .map(cloneProposal); + }, + + async getGuidanceProposal(companyId, proposalId) { + const proposal = proposals.get(proposalId); + return proposal && proposal.companyId === companyId ? cloneProposal(proposal) : null; + }, + + async createGuidanceProposal(input) { + const timestamp = nowIso(); + const proposal: TriageGuidanceProposal = { + id: randomUUID(), + companyId: input.companyId, + queueId: input.queueId, + itemId: input.itemId ?? null, + targetDocId: input.targetDocId ?? null, + status: "proposed", + proposedContent: input.proposedContent, + rationale: input.rationale ?? null, + metadata: { ...(input.metadata ?? {}) }, + createdAt: timestamp, + updatedAt: timestamp, + }; + proposals.set(proposal.id, proposal); + return cloneProposal(proposal); + }, + + async updateGuidanceProposal(input) { + const proposal = proposals.get(input.proposalId); + if (!proposal || proposal.companyId !== input.companyId) { + throw new TriageError(404, "guidance_proposal_not_found", "Guidance proposal not found"); + } + if (input.status) proposal.status = input.status; + if (input.proposedContent !== undefined && input.proposedContent !== null) { + proposal.proposedContent = input.proposedContent; + } + if (input.rationale !== undefined) proposal.rationale = input.rationale; + if (input.metadata !== undefined) proposal.metadata = { ...input.metadata }; + proposal.updatedAt = nowIso(); + return cloneProposal(proposal); + }, }; } +export interface TriageAssistantContext { + queue: TriageQueue; + item: TriageItem; + guidanceDocs: TriageGuidanceDoc[]; + prompt: string; +} + +function ensureString(value: unknown, code: string, message: string): string { + const out = stringField(value); + if (!out) throw new TriageError(400, code, message); + return out; +} + +async function requireItemWithQueue(store: TriageStore, companyId: string, itemId: string) { + const item = await store.getItem(companyId, itemId); + if (!item) throw new TriageError(404, "item_not_found", "Item not found"); + const queue = await store.getQueueById(companyId, item.queueId); + if (!queue) throw new TriageError(404, "queue_not_found", "Queue not found"); + await store.ensureQueueDefaults(queue); + return { item, queue }; +} + +function proposalStatusFromParams(value: unknown): TriageGuidanceProposalStatus | undefined { + if (value === "proposed" || value === "revised" || value === "accepted" || value === "rejected") return value; + return undefined; +} + +function appendGuidanceSuggestion(currentContent: string, suggestion: string): string { + const heading = "## Proposed Learning"; + const separator = currentContent.trim().length > 0 ? "\n\n" : ""; + return `${currentContent.trimEnd()}${separator}${heading}\n\n${suggestion.trim()}\n`; +} + +function formatAssistantPrompt(input: { + queue: TriageQueue; + item: TriageItem; + guidanceDocs: TriageGuidanceDoc[]; + message: string; +}): string { + const guidance = input.guidanceDocs.length > 0 + ? input.guidanceDocs.map((doc) => [ + `--- ${doc.path} (${doc.title}) ---`, + doc.content.trim() || "(empty)", + ].join("\n")).join("\n\n") + : "(no guidance documents)"; + return [ + "You are the managed assistant for a Paperclip Triage queue.", + "Use only the provided company-scoped queue, item, and guidance context.", + "", + `Queue: ${input.queue.title} (${input.queue.queueKey})`, + input.queue.description ? `Queue description: ${input.queue.description}` : null, + `Current state: ${input.item.stateKey}`, + `Current item: ${input.item.title}`, + `Item revision: ${input.item.revision}`, + `Content format: ${input.item.contentFormat}`, + "", + "Item properties:", + JSON.stringify(input.item.properties, null, 2), + "", + "Item content:", + input.item.content || "(empty)", + "", + "Queue guidance:", + guidance, + "", + "User message:", + input.message, + ].filter((line): line is string => line !== null).join("\n"); +} + export function createTriageService(store: TriageStore) { return { async listQueues(params: Record) { @@ -815,6 +1752,198 @@ export function createTriageService(store: TriageStore) { return item; }, + async getAssistantContext(params: Record): Promise { + const companyId = requireCompanyId(params.companyId); + const itemId = ensureString(params.itemId, "item_id_required", "itemId is required"); + const message = typeof params.message === "string" ? params.message : ""; + const { item, queue } = await requireItemWithQueue(store, companyId, itemId); + if (params.queueKey !== undefined && normalizeQueueKey(params.queueKey) !== queue.queueKey) { + throw new TriageError(404, "item_not_in_queue", "Item does not belong to the requested queue"); + } + const guidanceDocs = await store.listGuidanceDocs(companyId, queue.id); + return { + queue, + item, + guidanceDocs, + prompt: formatAssistantPrompt({ queue, item, guidanceDocs, message }), + }; + }, + + async ensureQueueChat(params: Record) { + const companyId = requireCompanyId(params.companyId); + const itemId = stringField(params.itemId); + const queueKey = stringField(params.queueKey); + let queue: TriageQueue | null = null; + let item: TriageItem | null = null; + if (itemId) { + const resolved = await requireItemWithQueue(store, companyId, itemId); + queue = resolved.queue; + item = resolved.item; + } else if (queueKey) { + queue = await store.getQueue(companyId, normalizeQueueKey(queueKey)); + if (!queue) throw new TriageError(404, "queue_not_found", "Queue not found"); + await store.ensureQueueDefaults(queue); + } else { + throw new TriageError(400, "queue_or_item_required", "queueKey or itemId is required"); + } + const result = await store.createQueueChat({ + companyId, + queueId: queue.id, + hiddenIssueId: stringField(params.hiddenIssueId), + title: `Triage chat: ${queue.title}`, + }); + if (item && item.linkedQueueChatId !== result.chat.id) { + await store.updateItem({ + companyId, + itemId: item.id, + linkedQueueChatId: result.chat.id, + }); + } + return { ...result, queue, item }; + }, + + async updateQueueChat(params: Record) { + const companyId = requireCompanyId(params.companyId); + const chatId = ensureString(params.chatId, "queue_chat_id_required", "chatId is required"); + const existing = await store.updateQueueChat({ + companyId, + chatId, + hiddenIssueId: stringField(params.hiddenIssueId), + title: stringField(params.title), + status: params.status === "archived" || params.status === "active" ? params.status : undefined, + metadata: params.metadata === undefined ? undefined : objectField(params.metadata), + }); + return existing; + }, + + async listGuidanceDocs(params: Record) { + const companyId = requireCompanyId(params.companyId); + const queue = await this.getQueue(params); + return store.listGuidanceDocs(companyId, queue.id); + }, + + async listGuidanceProposals(params: Record) { + const companyId = requireCompanyId(params.companyId); + const queue = await this.getQueue(params); + return store.listGuidanceProposals(companyId, queue.id); + }, + + async listItemEvents(params: Record) { + const companyId = requireCompanyId(params.companyId); + const itemId = ensureString(params.itemId, "item_id_required", "itemId is required"); + const item = await store.getItem(companyId, itemId); + if (!item) throw new TriageError(404, "item_not_found", "Item not found"); + return store.listItemEvents(companyId, itemId); + }, + + async listTransitionActions(params: Record) { + const companyId = requireCompanyId(params.companyId); + const queue = await this.getQueue(params); + return store.listTransitionActions({ + companyId, + queueId: queue.id, + fromStateKey: stringField(params.fromStateKey), + toStateKey: stringField(params.toStateKey), + actionKey: stringField(params.actionKey), + }); + }, + + async upsertTransitionAction(params: Record) { + const companyId = requireCompanyId(params.companyId); + const queue = await this.getQueue(params); + return store.upsertTransitionAction({ + companyId, + queueId: queue.id, + actionKey: normalizeActionKey(params.actionKey), + fromStateKey: normalizeStateKey(params.fromStateKey, "fromStateKey"), + toStateKey: normalizeStateKey(params.toStateKey, "toStateKey"), + action: parseTransitionIssueAction(params.action), + enabled: params.enabled === undefined ? undefined : params.enabled === true, + }); + }, + + async transitionItem( + params: Record, + ctx: Pick, + actor: TriageActor = {}, + ): Promise { + const companyId = requireCompanyId(params.companyId); + const itemId = ensureString(params.itemId, "item_id_required", "itemId is required"); + const toStateKey = normalizeStateKey(params.toStateKey, "toStateKey"); + const { item, queue } = await requireItemWithQueue(store, companyId, itemId); + const actionKey = stringField(params.actionKey); + const fromStateKey = item.stateKey; + const actions = (await store.listTransitionActions({ + companyId, + queueId: queue.id, + fromStateKey, + toStateKey, + actionKey, + })).filter((action) => action.enabled); + + let linkedWorkIssueId = item.linkedWorkIssueId; + const actionResults: TriageTransitionActionResult[] = []; + const actionEvents: TriageItemEvent[] = []; + for (const action of actions) { + const result = await runIssueTransitionAction({ + ctx, + queue, + item, + action, + fromStateKey, + toStateKey, + linkedWorkIssueId, + actor, + }); + linkedWorkIssueId = result.issueId ?? linkedWorkIssueId; + actionResults.push(result); + actionEvents.push(await store.recordItemEvent({ + companyId, + queueId: queue.id, + itemId: item.id, + eventType: "transition.action.executed", + fromStateKey, + toStateKey, + actor, + metadata: { + actionKey: action.actionKey, + actionType: action.actionType, + mode: action.action.mode, + result: result.result, + issueId: result.issueId, + commentCreated: result.commentCreated, + }, + })); + } + + const next = await store.updateItem({ + companyId, + itemId: item.id, + stateKey: toStateKey, + linkedWorkIssueId, + }); + const transitionEvent = await store.recordItemEvent({ + companyId, + queueId: queue.id, + itemId: item.id, + eventType: "item.transitioned", + fromStateKey, + toStateKey, + actor, + metadata: { + actionCount: actions.length, + actionKeys: actions.map((action) => action.actionKey), + linkedWorkIssueId, + }, + }); + return { + item: next, + transitionEvent, + actionEvents, + actionResults, + }; + }, + async ingestItem(params: Record, actor: TriageActor = {}): Promise { const input = parseIngestInput(params); let queue = await store.getQueue(input.companyId, input.queueKey); @@ -918,6 +2047,238 @@ export function createTriageService(store: TriageStore) { }); }, + async updateItemContent(params: Record, actor: TriageActor = {}) { + const companyId = requireCompanyId(params.companyId); + const itemId = ensureString(params.itemId, "item_id_required", "itemId is required"); + const item = await store.getItem(companyId, itemId); + if (!item) throw new TriageError(404, "item_not_found", "Item not found"); + const expectedRevision = numberField(params.expectedRevision); + if (expectedRevision !== null && expectedRevision !== item.revision) { + throw new TriageError(409, "item_revision_conflict", "Item revision has changed", { + expectedRevision, + currentRevision: item.revision, + }); + } + const next = await store.updateItem({ + companyId, + itemId, + title: stringField(params.title), + contentFormat: params.contentFormat === undefined ? undefined : sanitizeContentFormat(params.contentFormat), + content: typeof params.content === "string" ? params.content : undefined, + properties: params.properties === undefined ? undefined : objectField(params.properties), + }); + const event = await store.recordItemEvent({ + companyId, + queueId: next.queueId, + itemId, + eventType: "item.content.updated", + fromStateKey: item.stateKey, + toStateKey: next.stateKey, + actor, + metadata: { + previousRevision: item.revision, + nextRevision: next.revision, + }, + }); + return { item: next, event }; + }, + + async createGuidanceProposal(params: Record, actor: TriageActor = {}) { + const companyId = requireCompanyId(params.companyId); + const itemId = stringField(params.itemId); + const queueKey = stringField(params.queueKey); + let queue: TriageQueue; + let item: TriageItem | null = null; + if (itemId) { + const resolved = await requireItemWithQueue(store, companyId, itemId); + queue = resolved.queue; + item = resolved.item; + } else if (queueKey) { + const found = await store.getQueue(companyId, normalizeQueueKey(queueKey)); + if (!found) throw new TriageError(404, "queue_not_found", "Queue not found"); + queue = found; + await store.ensureQueueDefaults(queue); + } else { + throw new TriageError(400, "queue_or_item_required", "queueKey or itemId is required"); + } + + const path = normalizeGuidancePath(params.path); + const targetDoc = await store.getGuidanceDocByPath(companyId, queue.id, path); + const suggestedChange = stringField(params.suggestedChange); + const explicitContent = typeof params.proposedContent === "string" ? params.proposedContent : null; + if (!explicitContent && !suggestedChange) { + throw new TriageError( + 400, + "guidance_proposal_content_required", + "proposedContent or suggestedChange is required", + ); + } + const proposedContent = explicitContent ?? appendGuidanceSuggestion(targetDoc?.content ?? "", suggestedChange!); + const proposal = await store.createGuidanceProposal({ + companyId, + queueId: queue.id, + itemId: item?.id ?? null, + targetDocId: targetDoc?.id ?? null, + proposedContent, + rationale: stringField(params.rationale), + metadata: { + path, + generatedBy: actor.actorType ?? "unknown", + suggestedChange, + itemRevision: item?.revision ?? null, + ...(params.metadata === undefined ? {} : objectField(params.metadata)), + }, + }); + await store.recordItemEvent({ + companyId, + queueId: queue.id, + itemId: item?.id ?? null, + eventType: "guidance.proposal.created", + actor, + metadata: { proposalId: proposal.id, path }, + }); + return { proposal, queue, item, targetDoc }; + }, + + async reviseGuidanceProposal(params: Record, actor: TriageActor = {}) { + const companyId = requireCompanyId(params.companyId); + const proposalId = ensureString(params.proposalId, "guidance_proposal_id_required", "proposalId is required"); + const existing = await store.getGuidanceProposal(companyId, proposalId); + if (!existing) throw new TriageError(404, "guidance_proposal_not_found", "Guidance proposal not found"); + if (existing.status === "accepted" || existing.status === "rejected") { + throw new TriageError(409, "guidance_proposal_closed", "Closed guidance proposals cannot be revised"); + } + const proposedContent = typeof params.proposedContent === "string" ? params.proposedContent : null; + if (!proposedContent) { + throw new TriageError(400, "guidance_proposal_content_required", "proposedContent is required"); + } + const proposal = await store.updateGuidanceProposal({ + companyId, + proposalId, + status: "revised", + proposedContent, + rationale: params.rationale === undefined ? existing.rationale : stringField(params.rationale), + metadata: { + ...existing.metadata, + revisedBy: actor.actorType ?? "unknown", + revisedAt: nowIso(), + }, + }); + await store.recordItemEvent({ + companyId, + queueId: proposal.queueId, + itemId: proposal.itemId, + eventType: "guidance.proposal.revised", + actor, + metadata: { proposalId }, + }); + return proposal; + }, + + async rejectGuidanceProposal(params: Record, actor: TriageActor = {}) { + const companyId = requireCompanyId(params.companyId); + const proposalId = ensureString(params.proposalId, "guidance_proposal_id_required", "proposalId is required"); + const existing = await store.getGuidanceProposal(companyId, proposalId); + if (!existing) throw new TriageError(404, "guidance_proposal_not_found", "Guidance proposal not found"); + if (existing.status === "accepted") { + throw new TriageError(409, "guidance_proposal_already_accepted", "Accepted guidance proposals cannot be rejected"); + } + const proposal = await store.updateGuidanceProposal({ + companyId, + proposalId, + status: "rejected", + metadata: { + ...existing.metadata, + rejectionReason: stringField(params.reason), + rejectedBy: actor.actorType ?? "unknown", + rejectedAt: nowIso(), + }, + }); + await store.recordItemEvent({ + companyId, + queueId: proposal.queueId, + itemId: proposal.itemId, + eventType: "guidance.proposal.rejected", + actor, + metadata: { proposalId }, + }); + return proposal; + }, + + async acceptGuidanceProposal(params: Record, actor: TriageActor = {}) { + const companyId = requireCompanyId(params.companyId); + const proposalId = ensureString(params.proposalId, "guidance_proposal_id_required", "proposalId is required"); + const existing = await store.getGuidanceProposal(companyId, proposalId); + if (!existing) throw new TriageError(404, "guidance_proposal_not_found", "Guidance proposal not found"); + if (existing.status === "accepted") { + throw new TriageError(409, "guidance_proposal_already_accepted", "Guidance proposal is already accepted"); + } + if (existing.status === "rejected") { + throw new TriageError(409, "guidance_proposal_rejected", "Rejected guidance proposals cannot be accepted"); + } + const path = normalizeGuidancePath(existing.metadata.path); + const result = await store.upsertGuidanceRevision({ + companyId, + queueId: existing.queueId, + path, + title: path === "guidance.md" ? "Guidance" : path, + content: existing.proposedContent, + summary: stringField(params.summary) ?? existing.rationale ?? "Accepted guidance proposal", + actor, + metadata: { proposalId }, + }); + const proposal = await store.updateGuidanceProposal({ + companyId, + proposalId, + status: "accepted", + metadata: { + ...existing.metadata, + acceptedRevisionId: result.revision.id, + acceptedBy: actor.actorType ?? "unknown", + acceptedAt: nowIso(), + }, + }); + await store.recordItemEvent({ + companyId, + queueId: proposal.queueId, + itemId: proposal.itemId, + eventType: "guidance.proposal.accepted", + actor, + metadata: { proposalId, revisionId: result.revision.id, path }, + }); + return { proposal, doc: result.doc, revision: result.revision }; + }, + + async manualEditGuidance(params: Record, actor: TriageActor = {}) { + const companyId = requireCompanyId(params.companyId); + const queue = await this.getQueue(params); + const path = normalizeGuidancePath(params.path); + const content = typeof params.content === "string" ? params.content : null; + if (content === null) throw new TriageError(400, "guidance_content_required", "content is required"); + const result = await store.upsertGuidanceRevision({ + companyId, + queueId: queue.id, + path, + title: stringField(params.title) ?? (path === "guidance.md" ? "Guidance" : path), + content, + summary: stringField(params.summary) ?? "Manual guidance edit", + actor, + metadata: { + editType: "manual", + ...(params.metadata === undefined ? {} : objectField(params.metadata)), + }, + }); + await store.recordItemEvent({ + companyId, + queueId: queue.id, + itemId: null, + eventType: "guidance.manual_edit.applied", + actor, + metadata: { docId: result.doc.id, revisionId: result.revision.id, path }, + }); + return result; + }, + async archiveItem(params: Record) { const itemId = stringField(params.itemId); if (!itemId) throw new TriageError(400, "item_id_required", "itemId is required"); @@ -941,6 +2302,188 @@ export function parseIngestInput(params: Record): IngestItemInp }; } +function templateHas(template: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(template, key); +} + +function templatePathValue(root: Record, path: string): unknown { + return path.split(".").reduce((current, part) => { + if (!current || typeof current !== "object" || Array.isArray(current)) return undefined; + return (current as Record)[part]; + }, root); +} + +function stringifyTemplateValue(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value, null, 2); +} + +function renderTemplate(template: string, context: Record): string { + return template.replace(/\{\{\s*([A-Za-z0-9_.:-]+)\s*\}\}/g, (_match, path: string) => + stringifyTemplateValue(templatePathValue(context, path))); +} + +function actionTemplateContext(input: { + queue: TriageQueue; + item: TriageItem; + actionKey: string; + fromStateKey: string; + toStateKey: string; +}): Record { + return { + queue: input.queue, + item: { + ...input.item, + propertiesJson: JSON.stringify(input.item.properties, null, 2), + }, + transition: { + actionKey: input.actionKey, + fromStateKey: input.fromStateKey, + toStateKey: input.toStateKey, + }, + }; +} + +function resolveTemplate(template: Record, context: Record): Record { + return Object.fromEntries(Object.entries(template).map(([key, value]) => [key, renderTemplate(value, context)])); +} + +function resolvedPriority(value: string | undefined): Issue["priority"] | undefined { + if (value === undefined) return undefined; + const priority = value.trim(); + if (!ISSUE_PRIORITIES.has(priority as Issue["priority"])) { + throw new TriageError(422, "invalid_issue_template_priority", "Resolved issue priority is invalid"); + } + return priority as Issue["priority"]; +} + +function resolvedStatus(value: string | undefined): Issue["status"] | undefined { + if (value === undefined) return undefined; + const status = value.trim(); + if (!ISSUE_STATUSES.has(status as Issue["status"])) { + throw new TriageError(422, "invalid_issue_template_status", "Resolved issue status is invalid"); + } + return status as Issue["status"]; +} + +function actorForIssueMutation(actor: TriageActor): { actorAgentId?: string | null; actorRunId?: string | null } { + return { + actorAgentId: actor.actorType === "agent" ? actor.actorId ?? null : null, + actorRunId: actor.actorRunId ?? null, + }; +} + +async function runIssueTransitionAction(input: { + ctx: Pick; + queue: TriageQueue; + item: TriageItem; + action: TriageTransitionAction; + fromStateKey: string; + toStateKey: string; + linkedWorkIssueId: string | null; + actor: TriageActor; +}): Promise { + const context = actionTemplateContext({ + queue: input.queue, + item: input.item, + actionKey: input.action.actionKey, + fromStateKey: input.fromStateKey, + toStateKey: input.toStateKey, + }); + const resolved = resolveTemplate(input.action.action.template, context); + const existingIssue = input.linkedWorkIssueId + ? await input.ctx.issues.get(input.linkedWorkIssueId, input.item.companyId) + : null; + + if (input.linkedWorkIssueId && !existingIssue) { + throw new TriageError(403, "linked_issue_cross_company_denied", "Linked work issue is not accessible in this company"); + } + + if (input.action.action.mode === "create_if_missing" && existingIssue) { + return { + actionKey: input.action.actionKey, + actionType: "create_or_update_issue", + mode: input.action.action.mode, + result: "skipped_existing", + issueId: existingIssue.id, + commentCreated: false, + }; + } + + if (input.action.action.mode === "update_existing" && !existingIssue) { + throw new TriageError(409, "linked_issue_required", "update_existing requires a linked work issue"); + } + + const mutationActor = actorForIssueMutation(input.actor); + const comment = templateHas(input.action.action.template, "comment") ? resolved.comment.trim() : ""; + let issue = existingIssue; + let result: TriageTransitionActionResult["result"] = "updated"; + + if (!issue) { + const title = resolved.title.trim(); + if (!title) { + throw new TriageError(422, "invalid_issue_template_title", "Resolved issue title cannot be empty"); + } + issue = await input.ctx.issues.create({ + companyId: input.item.companyId, + projectId: resolved.projectId?.trim() || undefined, + title, + description: templateHas(input.action.action.template, "description") ? resolved.description : undefined, + status: resolvedStatus(templateHas(input.action.action.template, "status") ? resolved.status : undefined), + priority: resolvedPriority(templateHas(input.action.action.template, "priority") ? resolved.priority : undefined), + assigneeAgentId: (resolved.assigneeAgentId ?? resolved.assignee)?.trim() || undefined, + assigneeUserId: resolved.assigneeUserId?.trim() || undefined, + originKind: `plugin:${input.ctx.manifest.id}:transition-action`, + originId: input.item.id, + originRunId: input.actor.actorRunId ?? null, + actor: mutationActor, + }); + result = "created"; + } else { + const patch: Partial> = {}; + if (templateHas(input.action.action.template, "title")) { + const title = resolved.title.trim(); + if (!title) throw new TriageError(422, "invalid_issue_template_title", "Resolved issue title cannot be empty"); + patch.title = title; + } + if (templateHas(input.action.action.template, "description")) patch.description = resolved.description; + if (templateHas(input.action.action.template, "status")) patch.status = resolvedStatus(resolved.status); + if (templateHas(input.action.action.template, "priority")) patch.priority = resolvedPriority(resolved.priority); + if (templateHas(input.action.action.template, "projectId")) patch.projectId = resolved.projectId.trim() || null; + if (templateHas(input.action.action.template, "assignee") || templateHas(input.action.action.template, "assigneeAgentId")) { + patch.assigneeAgentId = (resolved.assigneeAgentId ?? resolved.assignee).trim() || null; + } + if (templateHas(input.action.action.template, "assigneeUserId")) { + patch.assigneeUserId = resolved.assigneeUserId.trim() || null; + } + if (Object.keys(patch).length > 0) { + issue = await input.ctx.issues.update(issue.id, patch, input.item.companyId, mutationActor); + } + } + + let commentCreated = false; + if (comment) { + await input.ctx.issues.createComment(issue.id, comment, input.item.companyId, { + authorAgentId: mutationActor.actorAgentId ?? undefined, + }); + commentCreated = true; + } + + return { + actionKey: input.action.actionKey, + actionType: "create_or_update_issue", + mode: input.action.action.mode, + result, + issueId: issue.id, + commentCreated, + }; +} + export function formatTriageError(error: unknown): { status: number; body: { error: { code: string; message: string } } } { if (error instanceof TriageError) { return { diff --git a/packages/plugins/plugin-triage/src/worker.ts b/packages/plugins/plugin-triage/src/worker.ts index 62cf4312913..2898eaeef46 100644 --- a/packages/plugins/plugin-triage/src/worker.ts +++ b/packages/plugins/plugin-triage/src/worker.ts @@ -4,11 +4,13 @@ import { type PluginContext, type PluginApiRequestInput, type PaperclipPlugin, + type AgentSession, type PluginManagedAgentResolution, type PluginManagedProjectResolution, type PluginManagedSkillResolution, } from "@paperclipai/plugin-sdk"; import { + PLUGIN_ID, TRIAGE_ASSISTANT_AGENT_KEY, TRIAGE_MANAGED_SKILL_KEYS, TRIAGE_PROJECT_KEY, @@ -140,6 +142,66 @@ async function managedResourceHealth( }; } +function actionActor(ctx: PluginContext, params: Record) { + return { + actorType: "plugin-action", + actorId: ctx.manifest.id, + actorRunId: typeof params.actorRunId === "string" ? params.actorRunId : null, + }; +} + +async function ensureQueueChatIssue( + ctx: PluginContext, + service: ReturnType, + params: Record, +) { + const companyId = requireCompanyId(params); + const ensured = await service.ensureQueueChat(params); + let hiddenIssueId = ensured.chat.hiddenIssueId; + const existingIssue = hiddenIssueId ? await ctx.issues.get(hiddenIssueId, companyId) : null; + if (!existingIssue) { + const project = await ctx.projects.managed.reconcile(TRIAGE_PROJECT_KEY, companyId); + const issue = await ctx.issues.create({ + companyId, + projectId: project.projectId ?? undefined, + title: ensured.chat.title ?? `Triage chat: ${ensured.queue.title}`, + description: [ + `Hidden queue chat for Paperclip Triage queue \`${ensured.queue.queueKey}\`.`, + "", + "This issue stores assistant conversation audit context for the queue and is created by the triage plugin.", + ].join("\n"), + status: "in_review", + priority: "low", + surfaceVisibility: "plugin_operation", + originKind: `plugin:${PLUGIN_ID}:operation:queue-chat`, + originId: `queue-chat:${ensured.queue.id}`, + }); + hiddenIssueId = issue.id; + ensured.chat = await service.updateQueueChat({ + companyId, + chatId: ensured.chat.id, + hiddenIssueId, + metadata: { + ...ensured.chat.metadata, + hiddenIssueIdentifier: issue.identifier, + }, + }); + } + return { ...ensured, hiddenIssueId }; +} + +async function activeSessionForChat( + ctx: PluginContext, + companyId: string, + agentId: string, + chatMetadata: Record, +): Promise { + const activeSessionId = typeof chatMetadata.activeSessionId === "string" ? chatMetadata.activeSessionId : null; + if (!activeSessionId) return null; + const sessions = await ctx.agents.sessions.list(agentId, companyId); + return sessions.find((session) => session.sessionId === activeSessionId) ?? null; +} + export function createTriagePlugin(options: { createStore?: (ctx: PluginContext) => TriageStore; } = {}): PaperclipPlugin { @@ -235,6 +297,26 @@ export function createTriagePlugin(options: { return getService().getItem(params); }); + ctx.data.register("assistant-context", async (params: Record) => { + return getService().getAssistantContext(params); + }); + + ctx.data.register("queue-guidance", async (params: Record) => { + return getService().listGuidanceDocs(params); + }); + + ctx.data.register("guidance-proposals", async (params: Record) => { + return getService().listGuidanceProposals(params); + }); + + ctx.data.register("item-events", async (params: Record) => { + return getService().listItemEvents(params); + }); + + ctx.data.register("queue-transition-actions", async (params: Record) => { + return getService().listTransitionActions(params); + }); + ctx.actions.register("reconcile-managed-resources", async (params: Record) => { const companyId = requireCompanyId(params); const result = await managedResourceHealth(ctx, companyId, "reconcile"); @@ -277,10 +359,117 @@ export function createTriagePlugin(options: { return getService().updateItem(params); }); + ctx.actions.register("update-item-content", async (params: Record) => { + await requireKnownCompany(ctx, requireCompanyId(params)); + return getService().updateItemContent(params, actionActor(ctx, params)); + }); + ctx.actions.register("archive-item", async (params: Record) => { await requireKnownCompany(ctx, requireCompanyId(params)); return getService().archiveItem(params); }); + + ctx.actions.register("upsert-transition-action", async (params: Record) => { + await requireKnownCompany(ctx, requireCompanyId(params)); + return getService().upsertTransitionAction(params); + }); + + ctx.actions.register("transition-item", async (params: Record) => { + await requireKnownCompany(ctx, requireCompanyId(params)); + return getService().transitionItem(params, ctx, actionActor(ctx, params)); + }); + + ctx.actions.register("ensure-queue-chat", async (params: Record) => { + await requireKnownCompany(ctx, requireCompanyId(params)); + return ensureQueueChatIssue(ctx, getService(), params); + }); + + ctx.actions.register("send-assistant-message", async (params: Record) => { + const companyId = requireCompanyId(params); + await requireKnownCompany(ctx, companyId); + const message = stringField(params.message); + if (!message) { + throw new Error("message is required"); + } + + const context = await getService().getAssistantContext(params); + const chat = await ensureQueueChatIssue(ctx, getService(), params); + const agentResolution = await ctx.agents.managed.reconcile(TRIAGE_ASSISTANT_AGENT_KEY, companyId); + if (!agentResolution.agentId) { + throw new Error("Triage Assistant managed agent is missing"); + } + if (agentResolution.agent?.status === "paused") { + await ctx.agents.resume(agentResolution.agentId, companyId); + } + const existingSession = await activeSessionForChat( + ctx, + companyId, + agentResolution.agentId, + chat.chat.metadata, + ); + const session = existingSession ?? + await ctx.agents.sessions.create(agentResolution.agentId, companyId, { + taskKey: `plugin:${PLUGIN_ID}:session:triage-queue-chat:${chat.queue.id}`, + reason: "triage_queue_chat", + }); + const run = await ctx.agents.sessions.sendMessage(session.sessionId, companyId, { + prompt: context.prompt, + reason: `triage:${chat.queue.queueKey}`, + }); + const updatedChat = await getService().updateQueueChat({ + companyId, + chatId: chat.chat.id, + metadata: { + ...chat.chat.metadata, + activeSessionId: session.sessionId, + lastRunId: run.runId, + lastItemId: context.item.id, + }, + }); + if (chat.hiddenIssueId) { + await ctx.issues.createComment( + chat.hiddenIssueId, + [`User message for item \`${context.item.title}\`:`, "", message].join("\n"), + companyId, + { authorAgentId: agentResolution.agentId }, + ); + } + return { + queue: context.queue, + item: context.item, + guidanceDocs: context.guidanceDocs, + chat: updatedChat, + hiddenIssueId: chat.hiddenIssueId, + session, + runId: run.runId, + prompt: context.prompt, + }; + }); + + ctx.actions.register("generate-guidance-proposal", async (params: Record) => { + await requireKnownCompany(ctx, requireCompanyId(params)); + return getService().createGuidanceProposal(params, actionActor(ctx, params)); + }); + + ctx.actions.register("revise-guidance-proposal", async (params: Record) => { + await requireKnownCompany(ctx, requireCompanyId(params)); + return getService().reviseGuidanceProposal(params, actionActor(ctx, params)); + }); + + ctx.actions.register("reject-guidance-proposal", async (params: Record) => { + await requireKnownCompany(ctx, requireCompanyId(params)); + return getService().rejectGuidanceProposal(params, actionActor(ctx, params)); + }); + + ctx.actions.register("accept-guidance-proposal", async (params: Record) => { + await requireKnownCompany(ctx, requireCompanyId(params)); + return getService().acceptGuidanceProposal(params, actionActor(ctx, params)); + }); + + ctx.actions.register("manual-edit-guidance", async (params: Record) => { + await requireKnownCompany(ctx, requireCompanyId(params)); + return getService().manualEditGuidance(params, actionActor(ctx, params)); + }); }, async onApiRequest(input: PluginApiRequestInput) { diff --git a/packages/plugins/plugin-triage/tests/plugin.spec.ts b/packages/plugins/plugin-triage/tests/plugin.spec.ts index dc93d61b7a3..b524f3cda97 100644 --- a/packages/plugins/plugin-triage/tests/plugin.spec.ts +++ b/packages/plugins/plugin-triage/tests/plugin.spec.ts @@ -65,6 +65,11 @@ describe("Paperclip Triage scaffold", () => { "database.namespace.read", "database.namespace.write", "companies.read", + "issues.read", + "issues.create", + "issues.update", + "issue.comments.read", + "issue.comments.create", "agents.managed", "projects.managed", "skills.managed", @@ -155,7 +160,7 @@ describe("Paperclip Triage queue and item ingest", () => { const testPlugin = createTriagePlugin({ createStore: () => store }); const harness = createTestHarness({ manifest }); harness.seed({ companies: [company(COMPANY_ID), company(OTHER_COMPANY_ID)] }); - return { harness, plugin: testPlugin }; + return { harness, plugin: testPlugin, store }; } it("creates and updates queues through worker actions", async () => { @@ -304,4 +309,201 @@ describe("Paperclip Triage queue and item ingest", () => { }, }); }); + + it("reuses one hidden queue chat and assistant session per queue while isolating other queues", async () => { + const { harness, plugin: testPlugin } = createHarness(); + await testPlugin.definition.setup(harness.ctx); + + const first = await harness.performAction<{ + item: { id: string }; + }>("ingest-item", { + companyId: COMPANY_ID, + queueKey: "reviews", + title: "Launch review", + content: "Please review the launch copy.", + properties: { sourceKind: "manual" }, + }); + const other = await harness.performAction<{ + item: { id: string }; + }>("ingest-item", { + companyId: COMPANY_ID, + queueKey: "support", + title: "Support review", + content: "Please review the support reply.", + }); + + const firstSend = await harness.performAction<{ + chat: { id: string; hiddenIssueId: string; metadata: Record }; + hiddenIssueId: string; + session: { sessionId: string }; + runId: string; + prompt: string; + }>("send-assistant-message", { + companyId: COMPANY_ID, + itemId: first.item.id, + message: "What should I change?", + }); + + expect(firstSend.hiddenIssueId).toBeTruthy(); + expect(firstSend.chat.hiddenIssueId).toBe(firstSend.hiddenIssueId); + expect(firstSend.runId).toBeTruthy(); + expect(firstSend.prompt).toContain("Queue: Reviews (reviews)"); + expect(firstSend.prompt).toContain("Please review the launch copy."); + expect(firstSend.prompt).toContain("guidance.md"); + expect(firstSend.prompt).toContain("What should I change?"); + + const secondSend = await harness.performAction<{ + chat: { id: string; hiddenIssueId: string }; + hiddenIssueId: string; + session: { sessionId: string }; + }>("send-assistant-message", { + companyId: COMPANY_ID, + itemId: first.item.id, + message: "Use the same thread.", + }); + expect(secondSend.chat.id).toBe(firstSend.chat.id); + expect(secondSend.hiddenIssueId).toBe(firstSend.hiddenIssueId); + expect(secondSend.session.sessionId).toBe(firstSend.session.sessionId); + + const otherSend = await harness.performAction<{ + chat: { id: string }; + hiddenIssueId: string; + }>("send-assistant-message", { + companyId: COMPANY_ID, + itemId: other.item.id, + message: "Different queue.", + }); + expect(otherSend.chat.id).not.toBe(firstSend.chat.id); + expect(otherSend.hiddenIssueId).not.toBe(firstSend.hiddenIssueId); + }); + + it("updates item content only when the expected revision matches", async () => { + const { harness, plugin: testPlugin } = createHarness(); + await testPlugin.definition.setup(harness.ctx); + + const ingested = await harness.performAction<{ + item: { id: string; revision: number }; + }>("ingest-item", { + companyId: COMPANY_ID, + queueKey: "reviews", + title: "Original", + content: "Original content", + }); + + const updated = await harness.performAction<{ + item: { title: string; content: string; revision: number }; + event: { eventType: string; metadata: Record }; + }>("update-item-content", { + companyId: COMPANY_ID, + itemId: ingested.item.id, + expectedRevision: ingested.item.revision, + title: "Edited", + content: "Edited content", + }); + + expect(updated.item).toMatchObject({ title: "Edited", content: "Edited content", revision: 2 }); + expect(updated.event).toMatchObject({ + eventType: "item.content.updated", + metadata: { previousRevision: 1, nextRevision: 2 }, + }); + + await expect(harness.performAction("update-item-content", { + companyId: COMPANY_ID, + itemId: ingested.item.id, + expectedRevision: 1, + content: "Stale edit", + })).rejects.toMatchObject({ + status: 409, + code: "item_revision_conflict", + }); + }); + + it("supports guidance proposal generation, revision, acceptance, rejection, and manual edits", async () => { + const { harness, plugin: testPlugin } = createHarness(); + await testPlugin.definition.setup(harness.ctx); + + const ingested = await harness.performAction<{ + item: { id: string }; + }>("ingest-item", { + companyId: COMPANY_ID, + queueKey: "reviews", + title: "Review example", + content: "Prefer concise copy.", + }); + + const manual = await harness.performAction<{ + doc: { content: string; currentRevisionId: string }; + revision: { id: string; summary: string }; + }>("manual-edit-guidance", { + companyId: COMPANY_ID, + queueKey: "reviews", + content: "# Reviews Guidance\n\nStart from the user's target audience.", + summary: "Seed guidance", + }); + expect(manual.doc.content).toContain("target audience"); + expect(manual.doc.currentRevisionId).toBe(manual.revision.id); + + const generated = await harness.performAction<{ + proposal: { id: string; status: string; proposedContent: string }; + }>("generate-guidance-proposal", { + companyId: COMPANY_ID, + itemId: ingested.item.id, + suggestedChange: "For launch copy, flag vague benefits before approving.", + rationale: "The item exposed a repeatable approval rule.", + }); + expect(generated.proposal.status).toBe("proposed"); + expect(generated.proposal.proposedContent).toContain("target audience"); + expect(generated.proposal.proposedContent).toContain("flag vague benefits"); + + const revised = await harness.performAction<{ status: string; proposedContent: string }>( + "revise-guidance-proposal", + { + companyId: COMPANY_ID, + proposalId: generated.proposal.id, + proposedContent: "# Reviews Guidance\n\nFlag vague benefits and ask for concrete proof.", + rationale: "Sharper wording.", + }, + ); + expect(revised).toMatchObject({ status: "revised" }); + + const accepted = await harness.performAction<{ + proposal: { status: string; metadata: Record }; + doc: { content: string; currentRevisionId: string }; + revision: { id: string }; + }>("accept-guidance-proposal", { + companyId: COMPANY_ID, + proposalId: generated.proposal.id, + }); + expect(accepted.proposal.status).toBe("accepted"); + expect(accepted.proposal.metadata.acceptedRevisionId).toBe(accepted.revision.id); + expect(accepted.doc.content).toContain("concrete proof"); + expect(accepted.doc.currentRevisionId).toBe(accepted.revision.id); + + const rejectedCandidate = await harness.performAction<{ + proposal: { id: string }; + }>("generate-guidance-proposal", { + companyId: COMPANY_ID, + itemId: ingested.item.id, + proposedContent: "# Reviews Guidance\n\nToo broad.", + }); + const rejected = await harness.performAction<{ status: string; metadata: Record }>( + "reject-guidance-proposal", + { + companyId: COMPANY_ID, + proposalId: rejectedCandidate.proposal.id, + reason: "Too broad for this queue.", + }, + ); + expect(rejected).toMatchObject({ + status: "rejected", + metadata: expect.objectContaining({ rejectionReason: "Too broad for this queue." }), + }); + await expect(harness.performAction("accept-guidance-proposal", { + companyId: COMPANY_ID, + proposalId: rejectedCandidate.proposal.id, + })).rejects.toMatchObject({ + status: 409, + code: "guidance_proposal_rejected", + }); + }); });