mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 20:50:08 +02:00
## 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>
556 lines
20 KiB
TypeScript
556 lines
20 KiB
TypeScript
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,
|
|
},
|
|
});
|
|
});
|
|
});
|