feat: add decision training snapshot foundation (#9702)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-17 12:17:34 -05:00 committed by GitHub
parent 1f1f545238
commit a090c09ee5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1475 additions and 1 deletions

View File

@ -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.

View File

@ -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");

View File

@ -0,0 +1,2 @@
ALTER TABLE "decision_training_examples"
ADD COLUMN IF NOT EXISTS "retention_policy" text DEFAULT 'scrub_deleted_comments_v1' NOT NULL;

View File

@ -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
}
]
}

View File

@ -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<DecisionTrainingSourceKind>().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<DecisionTrainingNotesHistoryEntry[]>().notNull().default([]),
decisionOutcome: text("decision_outcome"),
retentionPolicy: text("retention_policy")
.$type<DecisionTrainingRetentionPolicy>()
.notNull()
.default("scrub_deleted_comments_v1"),
snapshot: jsonb("snapshot").$type<DecisionTrainingSnapshotV1>().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,
),
}),
);

View File

@ -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";

View File

@ -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,

View File

@ -166,6 +166,7 @@ export interface AttentionItem {
project: AttentionProjectRef | null;
workspace: AttentionWorkspaceRef | null;
detail: AttentionItemDetail | null;
trainingExampleId: string | null;
}
export interface AttentionFeed {

View File

@ -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<string, unknown>;
comments: Array<Record<string, unknown>>;
runs: Array<Record<string, unknown>>;
decision: {
kind: DecisionTrainingSourceKind;
payload: Record<string, unknown>;
actor: Record<string, unknown> | 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;
}

View File

@ -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,

View File

@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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,
},
});
});
});

View File

@ -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",

View File

@ -27,6 +27,7 @@ const apiPrefixes: Record<string, string> = {
"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",

View File

@ -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));

View File

@ -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;
}

View File

@ -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);
},
},
);

View File

@ -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",

View File

@ -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<AttentionItem, "id" | "dismissalKey" | "rank" | "dismissal" | "project" | "workspace" | "detail"> & {
type CreateAttentionItemInput = Omit<AttentionItem, "id" | "dismissalKey" | "rank" | "dismissal" | "project" | "workspace" | "detail" | "trainingExampleId"> & {
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;

View File

@ -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<string, unknown>;
actor: Record<string, unknown> | 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<string, unknown> {
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}
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<string, unknown>;
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<SourceDecision> {
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<string, unknown>) => {
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 }),
};
}

View File

@ -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,

View File

@ -113,6 +113,7 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
detail: null,
dismissal: null,
...overrides,
trainingExampleId: overrides.trainingExampleId ?? null,
};
}

View File

@ -48,6 +48,7 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
detail: null,
dismissal: null,
...overrides,
trainingExampleId: overrides.trainingExampleId ?? null,
};
}

View File

@ -78,6 +78,7 @@ function item(
detail: null,
dismissal: null,
...overrides,
trainingExampleId: overrides.trainingExampleId ?? null,
};
}