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:
parent
1f1f545238
commit
a090c09ee5
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE "decision_training_examples"
|
||||
ADD COLUMN IF NOT EXISTS "retention_policy" text DEFAULT 'scrub_deleted_comments_v1' NOT NULL;
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ export interface AttentionItem {
|
|||
project: AttentionProjectRef | null;
|
||||
workspace: AttentionWorkspaceRef | null;
|
||||
detail: AttentionItemDetail | null;
|
||||
trainingExampleId: string | null;
|
||||
}
|
||||
|
||||
export interface AttentionFeed {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
|||
detail: null,
|
||||
dismissal: null,
|
||||
...overrides,
|
||||
trainingExampleId: overrides.trainingExampleId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
|||
detail: null,
|
||||
dismissal: null,
|
||||
...overrides,
|
||||
trainingExampleId: overrides.trainingExampleId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ function item(
|
|||
detail: null,
|
||||
dismissal: null,
|
||||
...overrides,
|
||||
trainingExampleId: overrides.trainingExampleId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue