From a090c09ee567d6c6775a5fb0fa0710b190d5d00e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:17:34 -0500 Subject: [PATCH] feat: add decision training snapshot foundation (#9702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies and their work > - Human approvals, issue interactions, and execution decisions already capture high-value decision moments > - Those moments are currently transient and cannot be reused as stable evaluation or training examples > - Reusable examples need a server-owned, immutable snapshot so later comments or runs cannot leak into the recorded state > - Human notes need to remain editable and auditable without changing the captured state > - This pull request adds the database model, snapshot capture service, API, export format, and attention-feed enrichment for decision training > - The benefit is a durable, inspectable foundation for evaluating whether agents can reproduce good human decisions from only the context available at decision time ## Linked Issues or Issue Description ### Subsystem affected Cross-cutting (`server/`, `packages/db`, and `packages/shared`). ### Problem or motivation Paperclip has no durable dataset for converting human decisions into evaluation-ready examples. Teams need to capture pending or resolved decisions with the exact issue context, comments, runs, and repository evidence available at a cutoff, while preventing future context from leaking into the example. ### Proposed solution Store immutable, schema-versioned snapshots anchored to durable interaction, approval, or execution-decision records; keep notes separately editable with history; expose human-only CRUD, list, and JSONL export APIs. ### Alternatives considered Client-generated snapshots were rejected because they duplicate cutoff logic and cannot reliably enforce no-leakage boundaries. Automatic outcome backfill was deferred so captured examples remain faithful to what was known at capture time. ### Roadmap alignment Supports the roadmap direction of turning completed work and decision patterns into reusable organizational knowledge. ### Additional context The implementation records explicit commit-resolution confidence (`exact`, `nearest_run`, `workspace`, or `none`) so downstream evaluation can distinguish evidence quality. ## What Changed - Added the `decision_training_examples` schema and idempotent migration with company, issue, and source/author indexes. - Added shared types for decision-training records, notes history, and versioned snapshots. - Added a single server-side snapshot capture path with inclusive comment cutoffs, pre-cutoff run capture, durable decision payloads, and explicit commit-resolution confidence. - Added create, list, detail, notes-only update, delete, and JSONL export routes with human-only write authorization and activity logging that skips no-op note submissions. - Added per-user `trainingExampleId` enrichment to attention items. - Added focused embedded-Postgres tests for cutoff boundaries, post-cutoff leakage, immutable snapshots, human-only writes, duplicate prevention, notes history, attention enrichment, and export shape. - Updated UI test and Storybook attention-item factories for the new required `trainingExampleId` contract. ## Verification - `pnpm exec vitest run server/src/__tests__/decision-training.test.ts` — 10 tests passed. - `pnpm --filter @paperclipai/db typecheck` — passed, including migration numbering and safety checks. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. ## Risks - The migration adds a new table and indexes only; it does not rewrite existing rows or install resolve-time hooks. - Snapshot JSON can grow with long comment threads and run histories; v1 intentionally favors complete, inspectable examples over aggressive truncation. - Commit SHA resolution is evidence-based and records `exact`, `nearest_run`, or `none` so downstream consumers can account for confidence. - The API is additive, but future UI work must continue to treat the snapshot as immutable and use notes-only updates. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex using `gpt-5.3-codex`, with repository tool use, terminal execution, and code-editing capabilities; context-window size is not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/DATABASE.md | 10 + .../0180_decision_training_examples.sql | 23 + ...181_decision_training_retention_policy.sql | 2 + packages/db/src/migrations/meta/_journal.json | 14 + .../src/schema/decision_training_examples.ts | 44 ++ packages/db/src/schema/index.ts | 1 + packages/shared/src/index.ts | 8 + packages/shared/src/types/attention.ts | 1 + .../shared/src/types/decision-training.ts | 57 ++ packages/shared/src/types/index.ts | 8 + .../src/__tests__/decision-training.test.ts | 555 ++++++++++++++++++ .../issue-comment-cancel-routes.test.ts | 14 + server/src/__tests__/openapi-routes.test.ts | 1 + server/src/app.ts | 2 + server/src/routes/decision-training.ts | 223 +++++++ server/src/routes/issues.ts | 8 + server/src/routes/openapi.ts | 62 ++ server/src/services/attention.ts | 40 +- server/src/services/decision-training.ts | 399 +++++++++++++ server/src/services/index.ts | 1 + ui/src/components/AttentionQueueRow.test.tsx | 1 + ui/src/lib/attention.test.ts | 1 + .../stories/what-needs-me.stories.tsx | 1 + 23 files changed, 1475 insertions(+), 1 deletion(-) create mode 100644 packages/db/src/migrations/0180_decision_training_examples.sql create mode 100644 packages/db/src/migrations/0181_decision_training_retention_policy.sql create mode 100644 packages/db/src/schema/decision_training_examples.ts create mode 100644 packages/shared/src/types/decision-training.ts create mode 100644 server/src/__tests__/decision-training.test.ts create mode 100644 server/src/routes/decision-training.ts create mode 100644 server/src/services/decision-training.ts diff --git a/doc/DATABASE.md b/doc/DATABASE.md index a979cefcaa..c7aa867635 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -167,6 +167,16 @@ These rows are company-scoped and user-scoped. A missing row means the user is j Both tables use a unique key on `(company_id, user_id, resource_id)` and keep `state` as `joined` or `left`. Join/leave mutations are idempotent board-user `/me` operations and write activity entries when the effective state changes. +## Decision training snapshot retention + +`decision_training_examples` stores a point-in-time copy of an issue, its comments, relevant runs, and the selected decision. Each row carries the `scrub_deleted_comments_v1` retention policy marker, and JSONL exports include that marker alongside the snapshot. + +- Deleting a captured source comment transactionally replaces that comment in every affected snapshot with a content-free redaction tombstone. The original body, presentation, and metadata are not retained in the training record. +- Deleting an issue deletes its decision-training examples through the `issue_id` foreign-key cascade. +- Deleting a training example deletes only that example and does not mutate the source issue. + +This policy makes training exports self-describing while keeping the decision record usable after a comment deletion without retaining content the author removed. + ## Plugin database namespaces The plugin runtime tracks plugin-owned database namespaces and migrations in `plugin_database_namespaces` and `plugin_migrations`. Hosted deployments that separate runtime and migration connections should set `DATABASE_MIGRATION_URL`; plugin namespace migration work uses the migration connection when present. diff --git a/packages/db/src/migrations/0180_decision_training_examples.sql b/packages/db/src/migrations/0180_decision_training_examples.sql new file mode 100644 index 0000000000..cf452f1edd --- /dev/null +++ b/packages/db/src/migrations/0180_decision_training_examples.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS "decision_training_examples" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE, + "source_kind" text NOT NULL, + "source_id" uuid NOT NULL, + "issue_id" uuid NOT NULL REFERENCES "issues"("id") ON DELETE CASCADE, + "cutoff_at" timestamp with time zone NOT NULL, + "notes" text DEFAULT '' NOT NULL, + "notes_history" jsonb DEFAULT '[]'::jsonb NOT NULL, + "decision_outcome" text, + "snapshot" jsonb NOT NULL, + "created_by_user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "decision_training_examples_source_kind_check" + CHECK ("source_kind" IN ('interaction', 'approval', 'execution_decision')) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "decision_training_examples_company_created_at_idx" + ON "decision_training_examples" USING btree ("company_id", "created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "decision_training_examples_issue_idx" + ON "decision_training_examples" USING btree ("issue_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "decision_training_examples_source_author_uq" + ON "decision_training_examples" USING btree ("source_kind", "source_id", "created_by_user_id"); diff --git a/packages/db/src/migrations/0181_decision_training_retention_policy.sql b/packages/db/src/migrations/0181_decision_training_retention_policy.sql new file mode 100644 index 0000000000..6875727017 --- /dev/null +++ b/packages/db/src/migrations/0181_decision_training_retention_policy.sql @@ -0,0 +1,2 @@ +ALTER TABLE "decision_training_examples" + ADD COLUMN IF NOT EXISTS "retention_policy" text DEFAULT 'scrub_deleted_comments_v1' NOT NULL; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 65f0790efe..428657b853 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1247,6 +1247,20 @@ "when": 1784241828832, "tag": "0179_summary_slot_failure_reason", "breakpoints": true + }, + { + "idx": 180, + "version": "7", + "when": 1784216720808, + "tag": "0180_decision_training_examples", + "breakpoints": true + }, + { + "idx": 181, + "version": "7", + "when": 1784231633059, + "tag": "0181_decision_training_retention_policy", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/decision_training_examples.ts b/packages/db/src/schema/decision_training_examples.ts new file mode 100644 index 0000000000..efbaa375fb --- /dev/null +++ b/packages/db/src/schema/decision_training_examples.ts @@ -0,0 +1,44 @@ +import type { + DecisionTrainingNotesHistoryEntry, + DecisionTrainingRetentionPolicy, + DecisionTrainingSnapshotV1, + DecisionTrainingSourceKind, +} from "@paperclipai/shared"; +import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { issues } from "./issues.js"; + +export const decisionTrainingExamples = pgTable( + "decision_training_examples", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + sourceKind: text("source_kind").$type().notNull(), + sourceId: uuid("source_id").notNull(), + issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + cutoffAt: timestamp("cutoff_at", { withTimezone: true }).notNull(), + notes: text("notes").notNull().default(""), + notesHistory: jsonb("notes_history").$type().notNull().default([]), + decisionOutcome: text("decision_outcome"), + retentionPolicy: text("retention_policy") + .$type() + .notNull() + .default("scrub_deleted_comments_v1"), + snapshot: jsonb("snapshot").$type().notNull(), + createdByUserId: text("created_by_user_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyCreatedAtIdx: index("decision_training_examples_company_created_at_idx").on( + table.companyId, + table.createdAt, + ), + issueIdx: index("decision_training_examples_issue_idx").on(table.issueId), + sourceAuthorUq: uniqueIndex("decision_training_examples_source_author_uq").on( + table.sourceKind, + table.sourceId, + table.createdByUserId, + ), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 010a791cf0..a5dec1a996 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -77,6 +77,7 @@ export { issueInboxArchives } from "./issue_inbox_archives.js"; export { userInboxAgentPolicies } from "./user_inbox_agent_policies.js"; export { inboxDismissals } from "./inbox_dismissals.js"; export { feedbackVotes } from "./feedback_votes.js"; +export { decisionTrainingExamples } from "./decision_training_examples.js"; export { feedbackExports } from "./feedback_exports.js"; export { issueReadStates } from "./issue_read_states.js"; export { assets } from "./assets.js"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c434ffecb7..785842f12b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -63,6 +63,14 @@ export type { AttentionSubjectKind, AttentionWorkspaceRef, } from "./types/attention.js"; +export type { + DecisionTrainingExample, + DecisionTrainingNotesHistoryEntry, + DecisionTrainingRetentionPolicy, + DecisionTrainingSnapshotV1, + DecisionTrainingSourceKind, +} from "./types/decision-training.js"; +export { DECISION_TRAINING_RETENTION_POLICY } from "./types/decision-training.js"; export type { PipelineAutomationRetryBlocker, diff --git a/packages/shared/src/types/attention.ts b/packages/shared/src/types/attention.ts index 6e01956439..a3964d66da 100644 --- a/packages/shared/src/types/attention.ts +++ b/packages/shared/src/types/attention.ts @@ -166,6 +166,7 @@ export interface AttentionItem { project: AttentionProjectRef | null; workspace: AttentionWorkspaceRef | null; detail: AttentionItemDetail | null; + trainingExampleId: string | null; } export interface AttentionFeed { diff --git a/packages/shared/src/types/decision-training.ts b/packages/shared/src/types/decision-training.ts new file mode 100644 index 0000000000..8507aac01b --- /dev/null +++ b/packages/shared/src/types/decision-training.ts @@ -0,0 +1,57 @@ +export type DecisionTrainingSourceKind = "interaction" | "approval" | "execution_decision"; + +export const DECISION_TRAINING_RETENTION_POLICY = "scrub_deleted_comments_v1" as const; +export type DecisionTrainingRetentionPolicy = typeof DECISION_TRAINING_RETENTION_POLICY; + +export interface DecisionTrainingNotesHistoryEntry { + author: string; + at: string; + body: string; +} + +export interface DecisionTrainingSnapshotV1 { + version: 1; + retention?: { + policy: DecisionTrainingRetentionPolicy; + commentDeletion: "redact"; + issueDeletion: "cascade"; + }; + capturedAt: string; + cutoff: { + at: string; + lastCommentId: string | null; + commentCount: number; + }; + issue: Record; + comments: Array>; + runs: Array>; + decision: { + kind: DecisionTrainingSourceKind; + payload: Record; + actor: Record | null; + outcome: string | null; + }; + code: { + repoUrl: string | null; + ref: string | null; + commitSha: string | null; + resolution: "exact" | "nearest_run" | "workspace" | "none"; + }; +} + +export interface DecisionTrainingExample { + id: string; + companyId: string; + sourceKind: DecisionTrainingSourceKind; + sourceId: string; + issueId: string; + cutoffAt: string; + notes: string; + notesHistory: DecisionTrainingNotesHistoryEntry[]; + decisionOutcome: string | null; + retentionPolicy: DecisionTrainingRetentionPolicy; + snapshot: DecisionTrainingSnapshotV1; + createdByUserId: string; + createdAt: string; + updatedAt: string; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 0df7258e97..983a452e83 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -26,6 +26,14 @@ export type { AttentionSubjectKind, AttentionWorkspaceRef, } from "./attention.js"; +export type { + DecisionTrainingExample, + DecisionTrainingNotesHistoryEntry, + DecisionTrainingRetentionPolicy, + DecisionTrainingSnapshotV1, + DecisionTrainingSourceKind, +} from "./decision-training.js"; +export { DECISION_TRAINING_RETENTION_POLICY } from "./decision-training.js"; export type { Environment, EnvironmentDeleteBlastRadius, diff --git a/server/src/__tests__/decision-training.test.ts b/server/src/__tests__/decision-training.test.ts new file mode 100644 index 0000000000..b7547154f9 --- /dev/null +++ b/server/src/__tests__/decision-training.test.ts @@ -0,0 +1,555 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + companies, + createDb, + decisionTrainingExamples, + executionWorkspaces, + heartbeatRuns, + issueComments, + issues, + issueThreadInteractions, + projectWorkspaces, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { decisionTrainingRoutes } from "../routes/decision-training.js"; +import { attentionService } from "../services/attention.js"; +import { captureDecisionSnapshot, decisionTrainingService } from "../services/decision-training.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres decision training tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("decision training", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-decision-training-"); + db = createDb(tempDb.connectionString); + }, 30_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(decisionTrainingExamples); + await db.delete(issueThreadInteractions); + await db.delete(issueComments); + await db.delete(executionWorkspaces); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedResolvedInteraction() { + const companyId = randomUUID(); + const projectId = randomUUID(); + const issueId = randomUUID(); + const interactionId = randomUUID(); + const cutoffAt = new Date("2026-07-16T12:00:00.000Z"); + const beforeId = randomUUID(); + const atCutoffId = randomUUID(); + const afterId = randomUUID(); + + await db.insert(companies).values({ id: companyId, name: "Decision Co", issuePrefix: `D${companyId.slice(0, 4)}` }); + await db.insert(projects).values({ id: projectId, companyId, name: "Decisions" }); + await db.insert(issues).values({ + id: issueId, + companyId, + projectId, + identifier: "DEC-1", + title: "Choose a rollout strategy", + status: "in_review", + }); + await db.insert(issueThreadInteractions).values({ + id: interactionId, + companyId, + issueId, + kind: "request_confirmation", + status: "accepted", + payload: { question: "Ship it?" } as never, + result: { accepted: true } as never, + resolvedByUserId: "board-user", + resolvedAt: cutoffAt, + }); + await db.insert(issueComments).values([ + { id: beforeId, companyId, issueId, body: "Before", createdAt: new Date("2026-07-16T11:59:59.000Z") }, + { id: atCutoffId, companyId, issueId, body: "At cutoff", createdAt: cutoffAt }, + { id: afterId, companyId, issueId, body: "Leaked later context", createdAt: new Date("2026-07-16T12:00:01.000Z") }, + ]); + return { companyId, projectId, issueId, interactionId, cutoffAt, beforeId, atCutoffId, afterId }; + } + + it("includes the cutoff boundary and excludes later comments", async () => { + const seeded = await seedResolvedInteraction(); + const captured = await captureDecisionSnapshot(db, { + companyId: seeded.companyId, + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + }, new Date("2026-07-16T13:00:00.000Z")); + + expect(captured.cutoffAt).toEqual(seeded.cutoffAt); + expect(captured.snapshot.cutoff).toEqual({ + at: seeded.cutoffAt.toISOString(), + lastCommentId: seeded.atCutoffId, + commentCount: 2, + }); + expect(captured.snapshot.comments.map((comment) => comment.id)).toEqual([seeded.beforeId, seeded.atCutoffId]); + expect(JSON.stringify(captured.snapshot)).not.toContain("Leaked later context"); + expect(captured.snapshot.retention).toEqual({ + policy: "scrub_deleted_comments_v1", + commentDeletion: "redact", + issueDeletion: "cascade", + }); + }); + + it("scrubs captured comment content after source deletion", async () => { + const seeded = await seedResolvedInteraction(); + const svc = decisionTrainingService(db); + const example = await svc.create({ + companyId: seeded.companyId, + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + notes: "Keep the decision, not deleted comment content.", + createdByUserId: "board-user", + }); + + await svc.scrubDeletedComments({ + companyId: seeded.companyId, + issueId: seeded.issueId, + commentIds: [seeded.beforeId], + deletedAt: new Date("2026-07-16T14:00:00.000Z"), + }); + + const updated = await svc.getById(example.id); + expect(updated?.retentionPolicy).toBe("scrub_deleted_comments_v1"); + expect(updated?.snapshot.comments).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: seeded.beforeId, + body: "", + presentation: null, + metadata: null, + retentionRedaction: { + reason: "source_comment_deleted", + policy: "scrub_deleted_comments_v1", + }, + }), + ])); + expect(JSON.stringify(updated?.snapshot)).not.toContain("Before"); + expect(updated?.snapshot.comments.find((comment) => comment.id === seeded.atCutoffId)?.body).toBe("At cutoff"); + }); + + it("deletes training examples when their issue is deleted", async () => { + const seeded = await seedResolvedInteraction(); + const svc = decisionTrainingService(db); + const example = await svc.create({ + companyId: seeded.companyId, + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + notes: "Cascade with the issue.", + createdByUserId: "board-user", + }); + + await db.delete(issueComments).where(eq(issueComments.issueId, seeded.issueId)); + await db.delete(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, seeded.issueId)); + await db.delete(issues).where(eq(issues.id, seeded.issueId)); + + expect(await svc.getById(example.id)).toBeUndefined(); + }); + + it("excludes runs updated after the decision cutoff", async () => { + const seeded = await seedResolvedInteraction(); + const agentId = randomUUID(); + const includedRunId = randomUUID(); + const excludedRunId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId: seeded.companyId, + name: "Decision agent", + role: "engineer", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(heartbeatRuns).values([ + { + id: includedRunId, + companyId: seeded.companyId, + agentId, + status: "succeeded", + startedAt: new Date("2026-07-16T11:00:00.000Z"), + finishedAt: new Date("2026-07-16T11:30:00.000Z"), + contextSnapshot: { issueId: seeded.issueId, evidence: "known before cutoff" }, + createdAt: new Date("2026-07-16T11:00:00.000Z"), + updatedAt: new Date("2026-07-16T11:30:00.000Z"), + }, + { + id: excludedRunId, + companyId: seeded.companyId, + agentId, + status: "running", + startedAt: new Date("2026-07-16T11:45:00.000Z"), + contextSnapshot: { issueId: seeded.issueId, evidence: "written after cutoff" }, + createdAt: new Date("2026-07-16T11:45:00.000Z"), + updatedAt: new Date("2026-07-16T12:30:00.000Z"), + }, + ]); + + const captured = await captureDecisionSnapshot(db, { + companyId: seeded.companyId, + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + }, new Date("2026-07-16T13:00:00.000Z")); + + expect(captured.snapshot.runs.map((run) => run.id)).toEqual([includedRunId]); + expect(JSON.stringify(captured.snapshot)).not.toContain("written after cutoff"); + }); + + it("labels workspace-only commit evidence accurately", async () => { + const seeded = await seedResolvedInteraction(); + await db.insert(projectWorkspaces).values({ + companyId: seeded.companyId, + projectId: seeded.projectId, + name: "Primary workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + metadata: { commitSha: "abcdef1234567890" }, + isPrimary: false, + createdAt: new Date("2026-07-16T11:00:00.000Z"), + updatedAt: new Date("2026-07-16T11:30:00.000Z"), + }); + await db.insert(projectWorkspaces).values({ + companyId: seeded.companyId, + projectId: seeded.projectId, + name: "Post-cutoff workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + metadata: { commitSha: "ffffffffffffffff" }, + isPrimary: true, + createdAt: new Date("2026-07-16T11:00:00.000Z"), + updatedAt: new Date("2026-07-16T12:30:00.000Z"), + }); + await db.insert(executionWorkspaces).values({ + companyId: seeded.companyId, + projectId: seeded.projectId, + sourceIssueId: seeded.issueId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Post-cutoff execution workspace", + providerType: "git_worktree", + metadata: { commitSha: "eeeeeeeeeeeeeeee" }, + openedAt: new Date("2026-07-16T11:00:00.000Z"), + lastUsedAt: new Date("2026-07-16T12:30:00.000Z"), + updatedAt: new Date("2026-07-16T12:30:00.000Z"), + }); + + const captured = await captureDecisionSnapshot(db, { + companyId: seeded.companyId, + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + }, new Date("2026-07-16T13:00:00.000Z")); + + expect(captured.snapshot.code).toMatchObject({ + commitSha: "abcdef1234567890", + resolution: "workspace", + }); + }); + + it("enforces one example per decision and author", async () => { + const seeded = await seedResolvedInteraction(); + const svc = decisionTrainingService(db); + const input = { + companyId: seeded.companyId, + sourceKind: "interaction" as const, + sourceId: seeded.interactionId, + issueId: seeded.issueId, + notes: "Ship behind a flag.", + createdByUserId: "board-user", + }; + + const created = await svc.create(input); + await expect(svc.create(input)).rejects.toMatchObject({ status: 409 }); + const updated = await svc.updateNotes(created.id, "board-user", "Use a 10% canary first."); + expect(updated?.notesHistory).toEqual([ + expect.objectContaining({ author: "board-user", body: "Ship behind a flag." }), + ]); + const unchanged = await svc.updateNotes(created.id, "board-user", "Use a 10% canary first."); + expect(unchanged?.notesHistory).toEqual(updated?.notesHistory); + expect(updated?.snapshot).toEqual(created.snapshot); + }); + + it("enriches attention items with the current user's training example", async () => { + const seeded = await seedResolvedInteraction(); + const example = await decisionTrainingService(db).create({ + companyId: seeded.companyId, + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + notes: "Captured guidance.", + createdByUserId: "board-user", + }); + await db + .update(issueThreadInteractions) + .set({ status: "pending", resolvedAt: null, updatedAt: new Date() }) + .where(eq(issueThreadInteractions.id, seeded.interactionId)); + + const feed = await attentionService(db).list(seeded.companyId, { userId: "board-user" }); + const item = feed.items.find((candidate) => candidate.subject.id === seeded.interactionId); + expect(item?.trainingExampleId).toBe(example.id); + }); + + it("does not log a notes update when the submitted notes are unchanged", async () => { + const seeded = await seedResolvedInteraction(); + const example = await decisionTrainingService(db).create({ + companyId: seeded.companyId, + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + notes: "Keep the current notes.", + createdByUserId: "board-user", + }); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { type: "board", userId: "board-user", source: "local_implicit" }; + next(); + }); + app.use("/api", decisionTrainingRoutes(db)); + app.use(errorHandler); + + await request(app) + .patch(`/api/decision-training/${example.id}`) + .send({ notes: "Keep the current notes." }) + .expect(200); + + const noOpLogs = await db + .select() + .from(activityLog) + .where(eq(activityLog.action, "decision_training.notes_updated")); + expect(noOpLogs).toHaveLength(0); + + await request(app) + .patch(`/api/decision-training/${example.id}`) + .send({ notes: "Record the real change." }) + .expect(200); + + const changedLogs = await db + .select() + .from(activityLog) + .where(eq(activityLog.action, "decision_training.notes_updated")); + expect(changedLogs).toHaveLength(1); + }); + + it("returns not found for malformed example ids", async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { type: "board", userId: "board-user", source: "local_implicit" }; + next(); + }); + app.use("/api", decisionTrainingRoutes(db)); + app.use(errorHandler); + + await request(app).get("/api/decision-training/not-a-uuid").expect(404); + await request(app) + .patch("/api/decision-training/not-a-uuid") + .send({ notes: "Changed" }) + .expect(404); + await request(app).delete("/api/decision-training/not-a-uuid").expect(404); + }); + + it("rejects updates and deletes from a different board user", async () => { + const seeded = await seedResolvedInteraction(); + const example = await decisionTrainingService(db).create({ + companyId: seeded.companyId, + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + notes: "Owner notes", + createdByUserId: "board-user", + }); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { type: "board", userId: "other-board-user", source: "local_implicit" }; + next(); + }); + app.use("/api", decisionTrainingRoutes(db)); + app.use(errorHandler); + + await request(app) + .patch(`/api/decision-training/${example.id}`) + .send({ notes: "Changed by someone else" }) + .expect(403); + await request(app).delete(`/api/decision-training/${example.id}`).expect(403); + + const unchanged = await decisionTrainingService(db).getById(example.id); + expect(unchanged?.notes).toBe("Owner notes"); + }); + + it("rejects agent writes and snapshot mutation", async () => { + const seeded = await seedResolvedInteraction(); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { + type: "agent", + agentId: randomUUID(), + companyId: seeded.companyId, + source: "agent_jwt", + }; + next(); + }); + app.use("/api", decisionTrainingRoutes(db)); + app.use(errorHandler); + + await request(app) + .post(`/api/companies/${seeded.companyId}/decision-training`) + .send({ sourceKind: "interaction", sourceId: seeded.interactionId, issueId: seeded.issueId, notes: "No" }) + .expect(403); + + await request(app) + .patch(`/api/decision-training/${randomUUID()}`) + .send({ notes: "Changed", snapshot: { version: 2 } }) + .expect(400); + }); + + it("rejects agent reads and exports", async () => { + const seeded = await seedResolvedInteraction(); + const example = await decisionTrainingService(db).create({ + companyId: seeded.companyId, + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + notes: "Sensitive guidance", + createdByUserId: "board-user", + }); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { + type: "agent", + agentId: randomUUID(), + companyId: seeded.companyId, + source: "agent_jwt", + }; + next(); + }); + app.use("/api", decisionTrainingRoutes(db)); + app.use(errorHandler); + + await request(app).get(`/api/companies/${seeded.companyId}/decision-training`).expect(403); + await request(app).get(`/api/decision-training/${example.id}`).expect(403); + await request(app).get(`/api/companies/${seeded.companyId}/decision-training/export.jsonl`).expect(403); + }); + + it("exports immutable state and labels as JSONL", async () => { + const seeded = await seedResolvedInteraction(); + const example = await decisionTrainingService(db).create({ + companyId: seeded.companyId, + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + notes: "Use a feature flag.", + createdByUserId: "board-user", + }); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { type: "board", userId: "board-user", source: "local_implicit" }; + next(); + }); + app.use("/api", decisionTrainingRoutes(db)); + app.use(errorHandler); + + const response = await request(app) + .get(`/api/companies/${seeded.companyId}/decision-training/export.jsonl`) + .expect(200); + const line = JSON.parse(response.text.trim()); + expect(line).toEqual({ + retentionPolicy: "scrub_deleted_comments_v1", + state: example.snapshot, + label: { outcome: "accepted", notes: "Use a feature flag." }, + }); + expect(response.text).not.toContain("Leaked later context"); + + const exportLogs = await db + .select() + .from(activityLog) + .where(eq(activityLog.action, "decision_training.exported")); + expect(exportLogs).toHaveLength(1); + expect(exportLogs[0]).toMatchObject({ + companyId: seeded.companyId, + actorType: "user", + actorId: "board-user", + entityType: "decision_training_export", + entityId: seeded.companyId, + details: { exampleCount: 1, exampleIds: [example.id] }, + }); + }); + + it("logs individual example reads", async () => { + const seeded = await seedResolvedInteraction(); + const example = await decisionTrainingService(db).create({ + companyId: seeded.companyId, + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + notes: "Read audit", + createdByUserId: "board-user", + }); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { type: "board", userId: "board-user", source: "local_implicit" }; + next(); + }); + app.use("/api", decisionTrainingRoutes(db)); + app.use(errorHandler); + + await request(app).get(`/api/decision-training/${example.id}`).expect(200); + + const readLogs = await db + .select() + .from(activityLog) + .where(eq(activityLog.action, "decision_training.read")); + expect(readLogs).toHaveLength(1); + expect(readLogs[0]).toMatchObject({ + companyId: seeded.companyId, + actorType: "user", + actorId: "board-user", + entityType: "decision_training_example", + entityId: example.id, + details: { + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + }, + }); + }); +}); diff --git a/server/src/__tests__/issue-comment-cancel-routes.test.ts b/server/src/__tests__/issue-comment-cancel-routes.test.ts index a85e648c3a..03ac6dc47a 100644 --- a/server/src/__tests__/issue-comment-cancel-routes.test.ts +++ b/server/src/__tests__/issue-comment-cancel-routes.test.ts @@ -43,6 +43,9 @@ const mockDocumentAnnotationService = vi.hoisted(() => ({ cleanupForIssueCommentDeletion: vi.fn(async () => ({ deletedCommentIds: [], resolvedThreadIds: [] })), remapOpenThreadsForDocument: vi.fn(async () => []), })); +const mockDecisionTrainingService = vi.hoisted(() => ({ + scrubDeletedComments: vi.fn(async () => ({ updatedCount: 0 })), +})); const mockIssueReferenceService = vi.hoisted(() => ({ deleteCommentSource: vi.fn(async () => undefined), deleteDocumentSource: vi.fn(async () => undefined), @@ -94,6 +97,10 @@ function registerModuleMocks() { logActivity: mockLogActivity, })); + vi.doMock("../services/decision-training.js", () => ({ + decisionTrainingService: () => mockDecisionTrainingService, + })); + vi.doMock("../services/feedback.js", () => ({ feedbackService: () => mockFeedbackService, })); @@ -206,6 +213,7 @@ describe.sequential("issue comment cancel routes", () => { vi.doUnmock("../telemetry.js"); vi.doUnmock("../services/access.js"); vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/decision-training.js"); vi.doUnmock("../services/external-objects.js"); vi.doUnmock("../services/feedback.js"); vi.doUnmock("../services/heartbeat.js"); @@ -391,6 +399,12 @@ describe.sequential("issue comment cancel routes", () => { ); expect(mockIssueReferenceService.deleteCommentSource).toHaveBeenCalledWith("annotation-comment-1", "tx"); expect(mockExternalObjectService.syncCommentSafely).toHaveBeenCalledWith("annotation-comment-1", "tx"); + expect(mockDecisionTrainingService.scrubDeletedComments).toHaveBeenCalledWith({ + companyId: "company-1", + issueId: "11111111-1111-4111-8111-111111111111", + commentIds: ["comment-1", "annotation-comment-1"], + deletedAt: new Date("2026-04-11T15:05:00.000Z"), + }, "tx"); const deletedActivity = mockLogActivity.mock.calls.find((call) => call[1]?.action === "issue.comment_deleted")?.[1]; expect(deletedActivity).toEqual(expect.objectContaining({ action: "issue.comment_deleted", diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 163e2e6d82..7e4904d94d 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -27,6 +27,7 @@ const apiPrefixes: Record = { "company-skill-policy.ts": "/api", "costs.ts": "/api", "dashboard.ts": "/api", + "decision-training.ts": "/api", "environments.ts": "/api", "execution-workspaces.ts": "/api", "file-resources.ts": "/api", diff --git a/server/src/app.ts b/server/src/app.ts index ae3a26e650..26bcefd866 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -40,6 +40,7 @@ import { costRoutes } from "./routes/costs.js"; import { activityRoutes } from "./routes/activity.js"; import { dashboardRoutes } from "./routes/dashboard.js"; import { attentionRoutes } from "./routes/attention.js"; +import { decisionTrainingRoutes } from "./routes/decision-training.js"; import { userProfileRoutes } from "./routes/user-profiles.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; @@ -267,6 +268,7 @@ export async function createApp( api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); api.use(attentionRoutes(db)); + api.use(decisionTrainingRoutes(db)); api.use(userProfileRoutes(db)); api.use(sidebarBadgeRoutes(db)); api.use(sidebarPreferenceRoutes(db)); diff --git a/server/src/routes/decision-training.ts b/server/src/routes/decision-training.ts new file mode 100644 index 0000000000..a55e8edc87 --- /dev/null +++ b/server/src/routes/decision-training.ts @@ -0,0 +1,223 @@ +import { Router, type Request, type Response } from "express"; +import { z } from "zod"; +import type { Db } from "@paperclipai/db"; +import { validate } from "../middleware/validate.js"; +import { decisionTrainingService, logActivity } from "../services/index.js"; +import { assertBoard, assertCompanyAccess, getActorInfo, hasCompanyAccess } from "./authz.js"; + +const sourceKindSchema = z.enum(["interaction", "approval", "execution_decision"]); +const exampleIdSchema = z.string().uuid(); +const createSchema = z.object({ + sourceKind: sourceKindSchema, + sourceId: z.string().uuid(), + issueId: z.string().uuid(), + notes: z.string().max(100_000).default(""), +}).strict(); +const updateSchema = z.object({ notes: z.string().max(100_000) }).strict(); + +function requireHumanUser(req: Request, res: Response) { + if (req.actor.type !== "board") { + res.status(403).json({ error: "Decision training writes require a human user" }); + return null; + } + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return null; + } + return req.actor.userId; +} + +function parseExampleId(req: Request, res: Response) { + const parsed = exampleIdSchema.safeParse(req.params.id); + if (!parsed.success) { + res.status(404).json({ error: "Decision training example not found" }); + return null; + } + return parsed.data; +} + +function requireExampleOwner(res: Response, userId: string, createdByUserId: string) { + if (userId !== createdByUserId) { + res.status(403).json({ error: "Only the example author can change decision training examples" }); + return false; + } + return true; +} + +export function decisionTrainingRoutes(db: Db) { + const router = Router(); + const svc = decisionTrainingService(db); + + router.post( + "/companies/:companyId/decision-training", + validate(createSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const userId = requireHumanUser(req, res); + if (!userId) return; + + const example = await svc.create({ + companyId, + sourceKind: req.body.sourceKind, + sourceId: req.body.sourceId, + issueId: req.body.issueId, + notes: req.body.notes, + createdByUserId: userId, + }); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "decision_training.created", + entityType: "decision_training_example", + entityId: example.id, + details: { sourceKind: example.sourceKind, sourceId: example.sourceId, issueId: example.issueId }, + }); + res.status(201).json(example); + }, + ); + + router.get("/companies/:companyId/decision-training", async (req, res) => { + const companyId = req.params.companyId as string; + assertBoard(req); + assertCompanyAccess(req, companyId); + const parsed = z.object({ + project: z.string().uuid().optional(), + kind: sourceKindSchema.optional(), + author: z.string().optional(), + q: z.string().trim().max(500).optional(), + }).safeParse(req.query); + if (!parsed.success) { + res.status(400).json({ error: "Invalid decision training query", details: parsed.error.flatten() }); + return; + } + res.json(await svc.list(companyId, { + projectId: parsed.data.project, + kind: parsed.data.kind, + author: parsed.data.author, + q: parsed.data.q, + })); + }); + + router.get("/companies/:companyId/decision-training/export.jsonl", async (req, res) => { + const companyId = req.params.companyId as string; + assertBoard(req); + assertCompanyAccess(req, companyId); + const rows = await svc.list(companyId); + const body = rows + .map(({ example }) => JSON.stringify({ + retentionPolicy: example.retentionPolicy, + state: example.snapshot, + label: { outcome: example.decisionOutcome, notes: example.notes }, + })) + .join("\n"); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "decision_training.exported", + entityType: "decision_training_export", + entityId: companyId, + details: { exampleCount: rows.length, exampleIds: rows.map(({ example }) => example.id) }, + }); + res.type("application/x-ndjson").send(body ? `${body}\n` : ""); + }); + + router.get("/decision-training/:id", async (req, res) => { + assertBoard(req); + const exampleId = parseExampleId(req, res); + if (!exampleId) return; + const example = await svc.getById(exampleId); + if (!example || !hasCompanyAccess(req, example.companyId)) { + res.status(404).json({ error: "Decision training example not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId: example.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "decision_training.read", + entityType: "decision_training_example", + entityId: example.id, + details: { sourceKind: example.sourceKind, sourceId: example.sourceId, issueId: example.issueId }, + }); + res.json(example); + }); + + router.patch("/decision-training/:id", validate(updateSchema), async (req, res) => { + const exampleId = parseExampleId(req, res); + if (!exampleId) return; + const existing = await svc.getById(exampleId); + if (!existing || !hasCompanyAccess(req, existing.companyId)) { + res.status(404).json({ error: "Decision training example not found" }); + return; + } + const userId = requireHumanUser(req, res); + if (!userId) return; + if (!requireExampleOwner(res, userId, existing.createdByUserId)) return; + const notesChanged = req.body.notes !== existing.notes; + const updated = await svc.updateNotes(existing.id, userId, req.body.notes); + if (!updated) { + res.status(404).json({ error: "Decision training example not found" }); + return; + } + if (notesChanged) { + const actor = getActorInfo(req); + await logActivity(db, { + companyId: existing.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "decision_training.notes_updated", + entityType: "decision_training_example", + entityId: updated.id, + details: { issueId: updated.issueId }, + }); + } + res.json(updated); + }); + + router.delete("/decision-training/:id", async (req, res) => { + const exampleId = parseExampleId(req, res); + if (!exampleId) return; + const existing = await svc.getById(exampleId); + if (!existing || !hasCompanyAccess(req, existing.companyId)) { + res.status(404).json({ error: "Decision training example not found" }); + return; + } + const userId = requireHumanUser(req, res); + if (!userId) return; + if (!requireExampleOwner(res, userId, existing.createdByUserId)) return; + const deleted = await svc.delete(existing.id); + if (deleted.length === 0) { + res.status(404).json({ error: "Decision training example not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId: existing.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "decision_training.deleted", + entityType: "decision_training_example", + entityId: existing.id, + details: { issueId: existing.issueId, deletedByUserId: userId }, + }); + res.status(204).send(); + }); + + return router; +} diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 932254e8cb..a426207567 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -150,6 +150,7 @@ import { } from "../services/issue-dependency-wakeups.js"; import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; import { executionWorkspaceService as executionWorkspaceServiceDirect } from "../services/execution-workspaces.js"; +import { decisionTrainingService } from "../services/decision-training.js"; import { feedbackService } from "../services/feedback.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { @@ -2601,6 +2602,7 @@ export function issueRoutes( const documentsSvc = documentService(db); const companySkillsSvc = companySkillService(db); const documentAnnotationsSvc = documentAnnotationService(db); + const decisionTrainingSvc = decisionTrainingService(db); const issueReferencesSvc = issueReferenceService(db); const issueThreadInteractionsSvc = issueThreadInteractionService(db); const taskWatchdogFactory: TaskWatchdogServiceFactory | undefined = Object.prototype.hasOwnProperty.call( @@ -9552,6 +9554,12 @@ export function issueRoutes( ]) ), ); + await decisionTrainingSvc.scrubDeletedComments({ + companyId: issue.companyId, + issueId: issue.id, + commentIds: [deletedComment.id, ...annotationCleanup.deletedCommentIds], + deletedAt: deletedComment.deletedAt ?? new Date(), + }, tx); }, }, ); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index d4e97e969e..220f791c2e 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -3039,6 +3039,68 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, }); +// ─── Decision training ────────────────────────────────────────────────────── + +const decisionTrainingSourceKindSchema = z.enum(["interaction", "approval", "execution_decision"]); + +registerCurrentRoute({ + method: "post", + path: "/api/companies/{companyId}/decision-training", + tags: ["decision-training"], + summary: "Capture a decision training example", + body: z.object({ + sourceKind: decisionTrainingSourceKindSchema, + sourceId: z.string().uuid(), + issueId: z.string().uuid(), + notes: z.string().max(100_000).default(""), + }).strict(), + responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, +}); + +registerCurrentRoute({ + method: "get", + path: "/api/companies/{companyId}/decision-training", + tags: ["decision-training"], + summary: "List decision training examples", + query: z.object({ + project: z.string().uuid().optional(), + kind: decisionTrainingSourceKindSchema.optional(), + author: z.string().optional(), + q: z.string().max(500).optional(), + }), +}); + +registerCurrentRoute({ + method: "get", + path: "/api/companies/{companyId}/decision-training/export.jsonl", + tags: ["decision-training"], + summary: "Export decision training examples as JSONL", +}); + +registerCurrentRoute({ + method: "get", + path: "/api/decision-training/{id}", + tags: ["decision-training"], + summary: "Get a decision training example", +}); + +registerCurrentRoute({ + method: "patch", + path: "/api/decision-training/{id}", + tags: ["decision-training"], + summary: "Update decision training notes", + body: z.object({ notes: z.string().max(100_000) }).strict(), + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registerCurrentRoute({ + method: "delete", + path: "/api/decision-training/{id}", + tags: ["decision-training"], + summary: "Delete a decision training example", + responses: { 204: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + registry.registerPath({ method: "get", path: "/api/sidebar-preferences/me", diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index 203a263cf8..fc58cf3e42 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -5,6 +5,7 @@ import { approvals, assets, companies, + decisionTrainingExamples, heartbeatRunEvents, heartbeatRuns, inboxDismissals, @@ -310,7 +311,7 @@ function decisionVerbs(...verbs: AttentionDecisionVerb[]): AttentionDecisionVerb return verbs; } -type CreateAttentionItemInput = Omit & { +type CreateAttentionItemInput = Omit & { project?: AttentionProjectRef | null; workspace?: AttentionWorkspaceRef | null; detail?: AttentionItemDetail | null; @@ -325,6 +326,7 @@ function createItem(input: CreateAttentionItemInput): AttentionItem { project: input.project ?? null, workspace: input.workspace ?? null, detail: input.detail ?? null, + trainingExampleId: null, rank: 0, }; } @@ -1238,6 +1240,42 @@ export function attentionService(db: Db) { const items = [...deduped.values()] .sort(compareAttentionItems) .map((item, index) => ({ ...item, rank: index + 1 })); + if (options.userId) { + const trainable: Array<{ sourceKind: "approval" | "interaction"; sourceId: string }> = []; + for (const item of items) { + if (item.sourceKind === "approval") { + trainable.push({ sourceKind: "approval", sourceId: item.subject.id }); + } + if (item.sourceKind === "issue_thread_interaction") { + trainable.push({ sourceKind: "interaction", sourceId: item.subject.id }); + } + } + if (trainable.length > 0) { + const examples = await db + .select({ + id: decisionTrainingExamples.id, + sourceKind: decisionTrainingExamples.sourceKind, + sourceId: decisionTrainingExamples.sourceId, + }) + .from(decisionTrainingExamples) + .where(and( + eq(decisionTrainingExamples.companyId, companyId), + eq(decisionTrainingExamples.createdByUserId, options.userId), + inArray(decisionTrainingExamples.sourceId, trainable.map((item) => item.sourceId)), + )); + const exampleBySource = new Map(examples.map((row) => [`${row.sourceKind}:${row.sourceId}`, row.id])); + for (const item of items) { + const sourceKind = item.sourceKind === "approval" + ? "approval" + : item.sourceKind === "issue_thread_interaction" + ? "interaction" + : null; + item.trainingExampleId = sourceKind + ? exampleBySource.get(`${sourceKind}:${item.subject.id}`) ?? null + : null; + } + } + } const countsBySourceKind = emptyCounts(); for (const item of items) countsBySourceKind[item.sourceKind] += 1; diff --git a/server/src/services/decision-training.ts b/server/src/services/decision-training.ts new file mode 100644 index 0000000000..2535bc39ac --- /dev/null +++ b/server/src/services/decision-training.ts @@ -0,0 +1,399 @@ +import { + and, + asc, + desc, + eq, + ilike, + isNotNull, + lte, + or, + sql, + type SQL, +} from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + approvals, + decisionTrainingExamples, + executionWorkspaces, + heartbeatRuns, + issueApprovals, + issueComments, + issueExecutionDecisions, + issues, + issueThreadInteractions, + projectWorkspaces, +} from "@paperclipai/db"; +import type { + DecisionTrainingNotesHistoryEntry, + DecisionTrainingSnapshotV1, + DecisionTrainingSourceKind, +} from "@paperclipai/shared"; +import { DECISION_TRAINING_RETENTION_POLICY } from "@paperclipai/shared"; +import { conflict, notFound } from "../errors.js"; + +type CaptureInput = { + companyId: string; + sourceKind: DecisionTrainingSourceKind; + sourceId: string; + issueId: string; +}; + +type SourceDecision = { + cutoffAt: Date; + outcome: string | null; + payload: Record; + actor: Record | null; + exactRunId: string | null; +}; + +type ListInput = { + projectId?: string; + kind?: DecisionTrainingSourceKind; + author?: string; + q?: string; +}; + +type ScrubDeletedCommentsInput = { + companyId: string; + issueId: string; + commentIds: string[]; + deletedAt: Date; +}; + +function jsonCopy(value: unknown): Record { + return JSON.parse(JSON.stringify(value)) as Record; +} + +function findCommitSha(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + if (Array.isArray(value)) { + for (const item of value) { + const found = findCommitSha(item); + if (found) return found; + } + return null; + } + const record = value as Record; + for (const key of ["commitSha", "commitSHA", "gitCommitSha", "headSha", "commit"]) { + const candidate = record[key]; + if (typeof candidate === "string" && /^[0-9a-f]{7,64}$/i.test(candidate)) return candidate; + } + for (const nested of Object.values(record)) { + const found = findCommitSha(nested); + if (found) return found; + } + return null; +} + +async function loadSourceDecision(db: Db, input: CaptureInput, capturedAt: Date): Promise { + if (input.sourceKind === "interaction") { + const row = await db.query.issueThreadInteractions.findFirst({ + where: and( + eq(issueThreadInteractions.id, input.sourceId), + eq(issueThreadInteractions.companyId, input.companyId), + eq(issueThreadInteractions.issueId, input.issueId), + ), + }); + if (!row) throw notFound("Decision interaction not found"); + const resolved = row.resolvedAt != null && row.status !== "pending"; + return { + cutoffAt: resolved ? row.resolvedAt! : capturedAt, + outcome: resolved ? row.status : null, + payload: jsonCopy({ + kind: row.kind, + title: row.title, + summary: row.summary, + payload: row.payload, + result: resolved ? row.result : null, + }), + actor: jsonCopy(resolved + ? { userId: row.resolvedByUserId, agentId: row.resolvedByAgentId } + : { userId: row.createdByUserId, agentId: row.createdByAgentId }), + exactRunId: row.sourceRunId, + }; + } + + if (input.sourceKind === "approval") { + const rows = await db + .select({ approval: approvals }) + .from(approvals) + .innerJoin(issueApprovals, and( + eq(issueApprovals.approvalId, approvals.id), + eq(issueApprovals.issueId, input.issueId), + eq(issueApprovals.companyId, input.companyId), + )) + .where(and(eq(approvals.id, input.sourceId), eq(approvals.companyId, input.companyId))) + .limit(1); + const row = rows[0]?.approval; + if (!row) throw notFound("Decision approval not found"); + const resolved = row.decidedAt != null && row.status !== "pending"; + return { + cutoffAt: resolved ? row.decidedAt! : capturedAt, + outcome: resolved ? row.status : null, + payload: jsonCopy({ type: row.type, payload: row.payload, decisionNote: resolved ? row.decisionNote : null }), + actor: jsonCopy(resolved + ? { userId: row.decidedByUserId } + : { userId: row.requestedByUserId, agentId: row.requestedByAgentId }), + exactRunId: null, + }; + } + + const row = await db.query.issueExecutionDecisions.findFirst({ + where: and( + eq(issueExecutionDecisions.id, input.sourceId), + eq(issueExecutionDecisions.companyId, input.companyId), + eq(issueExecutionDecisions.issueId, input.issueId), + ), + }); + if (!row) throw notFound("Execution decision not found"); + return { + cutoffAt: row.createdAt, + outcome: row.outcome, + payload: jsonCopy({ stageId: row.stageId, stageType: row.stageType, body: row.body }), + actor: jsonCopy({ userId: row.actorUserId, agentId: row.actorAgentId }), + exactRunId: row.createdByRunId, + }; +} + +export async function captureDecisionSnapshot( + db: Db, + input: CaptureInput, + capturedAt = new Date(), +): Promise<{ cutoffAt: Date; decisionOutcome: string | null; snapshot: DecisionTrainingSnapshotV1 }> { + const issue = await db.query.issues.findFirst({ + where: and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)), + }); + if (!issue) throw notFound("Issue not found"); + + const decision = await loadSourceDecision(db, input, capturedAt); + const comments = await db + .select() + .from(issueComments) + .where(and( + eq(issueComments.companyId, input.companyId), + eq(issueComments.issueId, input.issueId), + lte(issueComments.createdAt, decision.cutoffAt), + )) + .orderBy(asc(issueComments.createdAt), asc(issueComments.id)); + const runs = await db + .select() + .from(heartbeatRuns) + .where(and( + eq(heartbeatRuns.companyId, input.companyId), + isNotNull(heartbeatRuns.startedAt), + lte(heartbeatRuns.startedAt, decision.cutoffAt), + lte(heartbeatRuns.updatedAt, decision.cutoffAt), + or( + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${input.issueId}`, + sql`${heartbeatRuns.contextSnapshot} ->> 'taskId' = ${input.issueId}`, + ), + )) + .orderBy(asc(heartbeatRuns.startedAt), asc(heartbeatRuns.id)); + + const [projectWorkspace] = issue.projectId + ? await db + .select() + .from(projectWorkspaces) + .where(and( + eq(projectWorkspaces.companyId, input.companyId), + eq(projectWorkspaces.projectId, issue.projectId), + lte(projectWorkspaces.createdAt, decision.cutoffAt), + lte(projectWorkspaces.updatedAt, decision.cutoffAt), + )) + .orderBy(desc(projectWorkspaces.isPrimary), desc(projectWorkspaces.updatedAt)) + .limit(1) + : []; + const [executionWorkspace] = await db + .select() + .from(executionWorkspaces) + .where(and( + eq(executionWorkspaces.companyId, input.companyId), + eq(executionWorkspaces.sourceIssueId, input.issueId), + lte(executionWorkspaces.openedAt, decision.cutoffAt), + lte(executionWorkspaces.lastUsedAt, decision.cutoffAt), + lte(executionWorkspaces.updatedAt, decision.cutoffAt), + )) + .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.id)) + .limit(1); + + const exactRun = decision.exactRunId ? runs.find((run) => run.id === decision.exactRunId) ?? null : null; + const latestRunWithCommit = [...runs].reverse().find((run) => findCommitSha(run.contextSnapshot)) ?? null; + const exactCommit = exactRun ? findCommitSha(exactRun.contextSnapshot) : null; + const nearestCommit = latestRunWithCommit ? findCommitSha(latestRunWithCommit.contextSnapshot) : null; + const workspaceCommit = findCommitSha(executionWorkspace?.metadata) ?? findCommitSha(projectWorkspace?.metadata); + const commitSha = exactCommit ?? nearestCommit ?? workspaceCommit; + + return { + cutoffAt: decision.cutoffAt, + decisionOutcome: decision.outcome, + snapshot: { + version: 1, + retention: { + policy: DECISION_TRAINING_RETENTION_POLICY, + commentDeletion: "redact", + issueDeletion: "cascade", + }, + capturedAt: capturedAt.toISOString(), + cutoff: { + at: decision.cutoffAt.toISOString(), + lastCommentId: comments.at(-1)?.id ?? null, + commentCount: comments.length, + }, + issue: jsonCopy(issue), + comments: comments.map(jsonCopy), + runs: runs.map(jsonCopy), + decision: { + kind: input.sourceKind, + payload: decision.payload, + actor: decision.actor, + outcome: decision.outcome, + }, + code: { + repoUrl: executionWorkspace?.repoUrl ?? projectWorkspace?.repoUrl ?? null, + ref: executionWorkspace?.branchName + ?? executionWorkspace?.baseRef + ?? projectWorkspace?.repoRef + ?? projectWorkspace?.defaultRef + ?? null, + commitSha: commitSha ?? null, + resolution: exactCommit + ? "exact" + : nearestCommit + ? "nearest_run" + : workspaceCommit + ? "workspace" + : "none", + }, + }, + }; +} + +export function decisionTrainingService(db: Db) { + return { + create: async (input: CaptureInput & { notes: string; createdByUserId: string }) => { + const captured = await captureDecisionSnapshot(db, input); + const rows = await db + .insert(decisionTrainingExamples) + .values({ + companyId: input.companyId, + sourceKind: input.sourceKind, + sourceId: input.sourceId, + issueId: input.issueId, + cutoffAt: captured.cutoffAt, + notes: input.notes, + notesHistory: [], + decisionOutcome: captured.decisionOutcome, + retentionPolicy: DECISION_TRAINING_RETENTION_POLICY, + snapshot: captured.snapshot, + createdByUserId: input.createdByUserId, + }) + .onConflictDoNothing({ + target: [ + decisionTrainingExamples.sourceKind, + decisionTrainingExamples.sourceId, + decisionTrainingExamples.createdByUserId, + ], + }) + .returning(); + if (!rows[0]) throw conflict("This decision is already trained by this user"); + return rows[0]; + }, + list: async (companyId: string, input: ListInput = {}) => { + const filters: SQL[] = [eq(decisionTrainingExamples.companyId, companyId)]; + if (input.projectId) filters.push(eq(issues.projectId, input.projectId)); + if (input.kind) filters.push(eq(decisionTrainingExamples.sourceKind, input.kind)); + if (input.author) filters.push(eq(decisionTrainingExamples.createdByUserId, input.author)); + if (input.q) { + const query = `%${input.q}%`; + filters.push(or( + ilike(decisionTrainingExamples.notes, query), + ilike(issues.title, query), + ilike(issues.identifier, query), + )!); + } + return db + .select({ example: decisionTrainingExamples, issueTitle: issues.title, issueIdentifier: issues.identifier }) + .from(decisionTrainingExamples) + .innerJoin(issues, eq(decisionTrainingExamples.issueId, issues.id)) + .where(and(...filters)) + .orderBy(desc(decisionTrainingExamples.createdAt), desc(decisionTrainingExamples.id)); + }, + getById: async (id: string) => db.query.decisionTrainingExamples.findFirst({ + where: eq(decisionTrainingExamples.id, id), + }), + updateNotes: async (id: string, author: string, notes: string) => db.transaction(async (tx) => { + const row = await tx.query.decisionTrainingExamples.findFirst({ + where: eq(decisionTrainingExamples.id, id), + }); + if (!row) return null; + if (notes === row.notes) return row; + const history: DecisionTrainingNotesHistoryEntry[] = [ + ...(row.notesHistory ?? []), + { author, at: new Date().toISOString(), body: row.notes }, + ]; + const [updated] = await tx + .update(decisionTrainingExamples) + .set({ notes, notesHistory: history, updatedAt: new Date() }) + .where(eq(decisionTrainingExamples.id, id)) + .returning(); + return updated ?? null; + }), + scrubDeletedComments: async ( + input: ScrubDeletedCommentsInput, + dbOrTx: any = db, + ) => { + if (input.commentIds.length === 0) return { updatedCount: 0 }; + const commentIds = new Set(input.commentIds); + const rows = await dbOrTx + .select({ id: decisionTrainingExamples.id, snapshot: decisionTrainingExamples.snapshot }) + .from(decisionTrainingExamples) + .where(and( + eq(decisionTrainingExamples.companyId, input.companyId), + eq(decisionTrainingExamples.issueId, input.issueId), + )); + let updatedCount = 0; + for (const row of rows) { + let changed = false; + const comments = row.snapshot.comments.map((comment: Record) => { + if (typeof comment.id !== "string" || !commentIds.has(comment.id)) return comment; + changed = true; + return { + id: comment.id, + issueId: input.issueId, + body: "", + presentation: null, + metadata: null, + deletedAt: input.deletedAt.toISOString(), + retentionRedaction: { + reason: "source_comment_deleted", + policy: DECISION_TRAINING_RETENTION_POLICY, + }, + }; + }); + if (!changed) continue; + await dbOrTx + .update(decisionTrainingExamples) + .set({ + retentionPolicy: DECISION_TRAINING_RETENTION_POLICY, + snapshot: { + ...row.snapshot, + retention: { + policy: DECISION_TRAINING_RETENTION_POLICY, + commentDeletion: "redact", + issueDeletion: "cascade", + }, + comments, + }, + updatedAt: input.deletedAt, + }) + .where(eq(decisionTrainingExamples.id, row.id)); + updatedCount += 1; + } + return { updatedCount }; + }, + delete: async (id: string) => db + .delete(decisionTrainingExamples) + .where(eq(decisionTrainingExamples.id, id)) + .returning({ id: decisionTrainingExamples.id }), + }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index aa1d474c63..2fc0666740 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -62,6 +62,7 @@ export { goalService } from "./goals.js"; export { activityService, type ActivityFilters } from "./activity.js"; export { workTimelineService, normalizeTimelineWindow } from "./work-timeline.js"; export { attentionService } from "./attention.js"; +export { captureDecisionSnapshot, decisionTrainingService } from "./decision-training.js"; export type { WorkTimelineActor, WorkTimelineEdge, diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx index bc848aa631..d157bfbdce 100644 --- a/ui/src/components/AttentionQueueRow.test.tsx +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -113,6 +113,7 @@ function buildItem(overrides: Partial = {}): AttentionItem { detail: null, dismissal: null, ...overrides, + trainingExampleId: overrides.trainingExampleId ?? null, }; } diff --git a/ui/src/lib/attention.test.ts b/ui/src/lib/attention.test.ts index a91b662e25..4e648dfb20 100644 --- a/ui/src/lib/attention.test.ts +++ b/ui/src/lib/attention.test.ts @@ -48,6 +48,7 @@ function buildItem(overrides: Partial = {}): AttentionItem { detail: null, dismissal: null, ...overrides, + trainingExampleId: overrides.trainingExampleId ?? null, }; } diff --git a/ui/storybook/stories/what-needs-me.stories.tsx b/ui/storybook/stories/what-needs-me.stories.tsx index 81745ddbb0..7901da5dbc 100644 --- a/ui/storybook/stories/what-needs-me.stories.tsx +++ b/ui/storybook/stories/what-needs-me.stories.tsx @@ -78,6 +78,7 @@ function item( detail: null, dismissal: null, ...overrides, + trainingExampleId: overrides.trainingExampleId ?? null, }; }