Merge pull request #2529 from paperclipai/PAP-880-thumbs-capture-for-evals-feature-pr

Add feedback voting and thumbs capture flow
This commit is contained in:
Dotta 2026-04-02 10:44:50 -05:00 committed by GitHub
commit ca5659f734
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 19102 additions and 78 deletions

1
.gitignore vendored
View file

@ -31,6 +31,7 @@ server/src/**/*.js.map
server/src/**/*.d.ts
server/src/**/*.d.ts.map
tmp/
feedback-export-*
# Editor / tool temp files
*.tmp

View file

@ -15,6 +15,10 @@ function makeCompany(overrides: Partial<Company>): Company {
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
brandColor: null,
logoAssetId: null,
logoUrl: null,

View file

@ -163,6 +163,10 @@ describe("renderCompanyImportPreview", () => {
brandColor: null,
logoPath: null,
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
},
sidebar: {
agents: ["ceo"],
@ -371,6 +375,10 @@ describe("import selection catalog", () => {
brandColor: null,
logoPath: "images/company-logo.png",
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
},
sidebar: {
agents: ["ceo"],

View file

@ -0,0 +1,177 @@
import os from "node:os";
import path from "node:path";
import { mkdtemp, readFile } from "node:fs/promises";
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import type { FeedbackTrace } from "@paperclipai/shared";
import { readZipArchive } from "../commands/client/zip.js";
import {
buildFeedbackTraceQuery,
registerFeedbackCommands,
renderFeedbackReport,
summarizeFeedbackTraces,
writeFeedbackExportBundle,
} from "../commands/client/feedback.js";
function makeTrace(overrides: Partial<FeedbackTrace> = {}): FeedbackTrace {
return {
id: "trace-12345678",
companyId: "company-123",
feedbackVoteId: "vote-12345678",
issueId: "issue-123",
projectId: "project-123",
issueIdentifier: "PAP-123",
issueTitle: "Fix the feedback command",
authorUserId: "user-123",
targetType: "issue_comment",
targetId: "comment-123",
vote: "down",
status: "pending",
destination: "paperclip_labs_feedback_v1",
exportId: null,
consentVersion: "feedback-data-sharing-v1",
schemaVersion: "1",
bundleVersion: "1",
payloadVersion: "1",
payloadDigest: null,
payloadSnapshot: {
vote: {
value: "down",
reason: "Needed more detail",
},
},
targetSummary: {
label: "Comment",
excerpt: "The first answer was too vague.",
authorAgentId: "agent-123",
authorUserId: null,
createdAt: new Date("2026-03-31T12:00:00.000Z"),
documentKey: null,
documentTitle: null,
revisionNumber: null,
},
redactionSummary: null,
attemptCount: 0,
lastAttemptedAt: null,
exportedAt: null,
failureReason: null,
createdAt: new Date("2026-03-31T12:01:00.000Z"),
updatedAt: new Date("2026-03-31T12:02:00.000Z"),
...overrides,
};
}
describe("registerFeedbackCommands", () => {
it("registers the top-level feedback commands", () => {
const program = new Command();
expect(() => registerFeedbackCommands(program)).not.toThrow();
const feedback = program.commands.find((command) => command.name() === "feedback");
expect(feedback).toBeDefined();
expect(feedback?.commands.map((command) => command.name())).toEqual(["report", "export"]);
expect(feedback?.commands[0]?.options.filter((option) => option.long === "--company-id")).toHaveLength(1);
});
});
describe("buildFeedbackTraceQuery", () => {
it("encodes all supported filters", () => {
expect(
buildFeedbackTraceQuery({
targetType: "issue_comment",
vote: "down",
status: "pending",
projectId: "project-123",
issueId: "issue-123",
from: "2026-03-31T00:00:00.000Z",
to: "2026-03-31T23:59:59.999Z",
sharedOnly: true,
}),
).toBe(
"?targetType=issue_comment&vote=down&status=pending&projectId=project-123&issueId=issue-123&from=2026-03-31T00%3A00%3A00.000Z&to=2026-03-31T23%3A59%3A59.999Z&sharedOnly=true&includePayload=true",
);
});
});
describe("renderFeedbackReport", () => {
it("includes summary counts and the optional reason", () => {
const traces = [
makeTrace(),
makeTrace({
id: "trace-87654321",
feedbackVoteId: "vote-87654321",
vote: "up",
status: "local_only",
payloadSnapshot: {
vote: {
value: "up",
reason: null,
},
},
}),
];
const report = renderFeedbackReport({
apiBase: "http://127.0.0.1:3100",
companyId: "company-123",
traces,
summary: summarizeFeedbackTraces(traces),
includePayloads: false,
});
expect(report).toContain("Paperclip Feedback Report");
expect(report).toContain("thumbs up");
expect(report).toContain("thumbs down");
expect(report).toContain("Needed more detail");
});
});
describe("writeFeedbackExportBundle", () => {
it("writes votes, traces, a manifest, and a zip archive", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-feedback-export-"));
const outputDir = path.join(tempDir, "feedback-export");
const traces = [
makeTrace(),
makeTrace({
id: "trace-abcdef12",
feedbackVoteId: "vote-abcdef12",
issueIdentifier: "PAP-124",
issueId: "issue-124",
vote: "up",
status: "local_only",
payloadSnapshot: {
vote: {
value: "up",
reason: null,
},
},
}),
];
const exported = await writeFeedbackExportBundle({
apiBase: "http://127.0.0.1:3100",
companyId: "company-123",
traces,
outputDir,
});
expect(exported.manifest.summary.total).toBe(2);
expect(exported.manifest.summary.withReason).toBe(1);
const manifest = JSON.parse(await readFile(path.join(outputDir, "index.json"), "utf8")) as {
files: { votes: string[]; traces: string[]; zip: string };
};
expect(manifest.files.votes).toHaveLength(2);
expect(manifest.files.traces).toHaveLength(2);
const archive = await readFile(exported.zipPath);
const zip = await readZipArchive(archive);
expect(Object.keys(zip.files)).toEqual(
expect.arrayContaining([
"index.json",
`votes/${manifest.files.votes[0]}`,
`traces/${manifest.files.traces[0]}`,
]),
);
});
});

View file

@ -5,6 +5,7 @@ import * as p from "@clack/prompts";
import pc from "picocolors";
import type {
Company,
FeedbackTrace,
CompanyPortabilityFileEntry,
CompanyPortabilityExportResult,
CompanyPortabilityInclude,
@ -22,6 +23,11 @@ import {
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
import {
buildFeedbackTraceQuery,
normalizeFeedbackTraceExportFormat,
serializeFeedbackTraces,
} from "./feedback.js";
interface CompanyCommandOptions extends BaseClientOptions {}
type CompanyDeleteSelectorMode = "auto" | "id" | "prefix";
@ -44,6 +50,20 @@ interface CompanyExportOptions extends BaseClientOptions {
expandReferencedSkills?: boolean;
}
interface CompanyFeedbackOptions extends BaseClientOptions {
targetType?: string;
vote?: string;
status?: string;
projectId?: string;
issueId?: string;
from?: string;
to?: string;
sharedOnly?: boolean;
includePayload?: boolean;
out?: string;
format?: string;
}
interface CompanyImportOptions extends BaseClientOptions {
include?: string;
target?: CompanyImportTargetMode;
@ -1103,6 +1123,91 @@ export function registerCompanyCommands(program: Command): void {
}),
);
addCommonClientOptions(
company
.command("feedback:list")
.description("List feedback traces for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.option("--target-type <type>", "Filter by target type")
.option("--vote <vote>", "Filter by vote value")
.option("--status <status>", "Filter by trace status")
.option("--project-id <id>", "Filter by project ID")
.option("--issue-id <id>", "Filter by issue ID")
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
.option("--shared-only", "Only include traces eligible for sharing/export")
.option("--include-payload", "Include stored payload snapshots in the response")
.action(async (opts: CompanyFeedbackOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const traces = (await ctx.api.get<FeedbackTrace[]>(
`/api/companies/${ctx.companyId}/feedback-traces${buildFeedbackTraceQuery(opts)}`,
)) ?? [];
if (ctx.json) {
printOutput(traces, { json: true });
return;
}
printOutput(
traces.map((trace) => ({
id: trace.id,
issue: trace.issueIdentifier ?? trace.issueId,
vote: trace.vote,
status: trace.status,
targetType: trace.targetType,
target: trace.targetSummary.label,
})),
{ json: false },
);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
company
.command("feedback:export")
.description("Export feedback traces for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.option("--target-type <type>", "Filter by target type")
.option("--vote <vote>", "Filter by vote value")
.option("--status <status>", "Filter by trace status")
.option("--project-id <id>", "Filter by project ID")
.option("--issue-id <id>", "Filter by issue ID")
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
.option("--shared-only", "Only include traces eligible for sharing/export")
.option("--include-payload", "Include stored payload snapshots in the export")
.option("--out <path>", "Write export to a file path instead of stdout")
.option("--format <format>", "Export format: json or ndjson", "ndjson")
.action(async (opts: CompanyFeedbackOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const traces = (await ctx.api.get<FeedbackTrace[]>(
`/api/companies/${ctx.companyId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`,
)) ?? [];
const serialized = serializeFeedbackTraces(traces, opts.format);
if (opts.out?.trim()) {
await writeFile(opts.out, serialized, "utf8");
if (ctx.json) {
printOutput(
{ out: opts.out, count: traces.length, format: normalizeFeedbackTraceExportFormat(opts.format) },
{ json: true },
);
return;
}
console.log(`Wrote ${traces.length} feedback trace(s) to ${opts.out}`);
return;
}
process.stdout.write(`${serialized}${serialized.endsWith("\n") ? "" : "\n"}`);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
company
.command("export")

View file

@ -0,0 +1,645 @@
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import pc from "picocolors";
import { Command } from "commander";
import type { Company, FeedbackTrace, FeedbackTraceBundle } from "@paperclipai/shared";
import {
addCommonClientOptions,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
type ResolvedClientContext,
} from "./common.js";
interface FeedbackFilterOptions extends BaseClientOptions {
targetType?: string;
vote?: string;
status?: string;
projectId?: string;
issueId?: string;
from?: string;
to?: string;
sharedOnly?: boolean;
}
export interface FeedbackTraceQueryOptions {
targetType?: string;
vote?: string;
status?: string;
projectId?: string;
issueId?: string;
from?: string;
to?: string;
sharedOnly?: boolean;
}
interface FeedbackReportOptions extends FeedbackFilterOptions {
payloads?: boolean;
}
interface FeedbackExportOptions extends FeedbackFilterOptions {
out?: string;
}
interface FeedbackSummary {
total: number;
thumbsUp: number;
thumbsDown: number;
withReason: number;
statuses: Record<string, number>;
}
interface FeedbackExportManifest {
exportedAt: string;
serverUrl: string;
companyId: string;
summary: FeedbackSummary & {
uniqueIssues: number;
issues: string[];
};
files: {
votes: string[];
traces: string[];
fullTraces: string[];
zip: string;
};
}
interface FeedbackExportResult {
outputDir: string;
zipPath: string;
manifest: FeedbackExportManifest;
}
export function registerFeedbackCommands(program: Command): void {
const feedback = program.command("feedback").description("Inspect and export local feedback traces");
addCommonClientOptions(
feedback
.command("report")
.description("Render a terminal report for company feedback traces")
.option("-C, --company-id <id>", "Company ID (overrides context default)")
.option("--target-type <type>", "Filter by target type")
.option("--vote <vote>", "Filter by vote value")
.option("--status <status>", "Filter by trace status")
.option("--project-id <id>", "Filter by project ID")
.option("--issue-id <id>", "Filter by issue ID")
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
.option("--shared-only", "Only include traces eligible for sharing/export")
.option("--payloads", "Include raw payload dumps in the terminal report", false)
.action(async (opts: FeedbackReportOptions) => {
try {
const ctx = resolveCommandContext(opts);
const companyId = await resolveFeedbackCompanyId(ctx, opts.companyId);
const traces = await fetchCompanyFeedbackTraces(ctx, companyId, opts);
const summary = summarizeFeedbackTraces(traces);
if (ctx.json) {
printOutput(
{
apiBase: ctx.api.apiBase,
companyId,
summary,
traces,
},
{ json: true },
);
return;
}
console.log(renderFeedbackReport({
apiBase: ctx.api.apiBase,
companyId,
traces,
summary,
includePayloads: Boolean(opts.payloads),
}));
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
feedback
.command("export")
.description("Export feedback votes and raw trace bundles into a folder plus zip archive")
.option("-C, --company-id <id>", "Company ID (overrides context default)")
.option("--target-type <type>", "Filter by target type")
.option("--vote <vote>", "Filter by vote value")
.option("--status <status>", "Filter by trace status")
.option("--project-id <id>", "Filter by project ID")
.option("--issue-id <id>", "Filter by issue ID")
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
.option("--shared-only", "Only include traces eligible for sharing/export")
.option("--out <path>", "Output directory (default: ./feedback-export-<timestamp>)")
.action(async (opts: FeedbackExportOptions) => {
try {
const ctx = resolveCommandContext(opts);
const companyId = await resolveFeedbackCompanyId(ctx, opts.companyId);
const traces = await fetchCompanyFeedbackTraces(ctx, companyId, opts);
const outputDir = path.resolve(opts.out?.trim() || defaultFeedbackExportDirName());
const exported = await writeFeedbackExportBundle({
apiBase: ctx.api.apiBase,
companyId,
traces,
outputDir,
traceBundleFetcher: (trace) => fetchFeedbackTraceBundle(ctx, trace.id),
});
if (ctx.json) {
printOutput(
{
companyId,
outputDir: exported.outputDir,
zipPath: exported.zipPath,
summary: exported.manifest.summary,
},
{ json: true },
);
return;
}
console.log(renderFeedbackExportSummary(exported));
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
export async function resolveFeedbackCompanyId(
ctx: ResolvedClientContext,
explicitCompanyId?: string,
): Promise<string> {
const direct = explicitCompanyId?.trim() || ctx.companyId?.trim();
if (direct) return direct;
const companies = (await ctx.api.get<Company[]>("/api/companies")) ?? [];
const companyId = companies[0]?.id?.trim();
if (!companyId) {
throw new Error(
"Company ID is required. Pass --company-id, set PAPERCLIP_COMPANY_ID, or configure a CLI context default.",
);
}
return companyId;
}
export function buildFeedbackTraceQuery(opts: FeedbackTraceQueryOptions, includePayload = true): string {
const params = new URLSearchParams();
if (opts.targetType) params.set("targetType", opts.targetType);
if (opts.vote) params.set("vote", opts.vote);
if (opts.status) params.set("status", opts.status);
if (opts.projectId) params.set("projectId", opts.projectId);
if (opts.issueId) params.set("issueId", opts.issueId);
if (opts.from) params.set("from", opts.from);
if (opts.to) params.set("to", opts.to);
if (opts.sharedOnly) params.set("sharedOnly", "true");
if (includePayload) params.set("includePayload", "true");
const query = params.toString();
return query ? `?${query}` : "";
}
export function normalizeFeedbackTraceExportFormat(value: string | undefined): "json" | "ndjson" {
if (!value || value === "ndjson") return "ndjson";
if (value === "json") return "json";
throw new Error(`Unsupported export format: ${value}`);
}
export function serializeFeedbackTraces(traces: FeedbackTrace[], format: string | undefined): string {
if (normalizeFeedbackTraceExportFormat(format) === "json") {
return JSON.stringify(traces, null, 2);
}
return traces.map((trace) => JSON.stringify(trace)).join("\n");
}
export async function fetchCompanyFeedbackTraces(
ctx: ResolvedClientContext,
companyId: string,
opts: FeedbackFilterOptions,
): Promise<FeedbackTrace[]> {
return (
(await ctx.api.get<FeedbackTrace[]>(
`/api/companies/${companyId}/feedback-traces${buildFeedbackTraceQuery(opts, true)}`,
)) ?? []
);
}
export async function fetchFeedbackTraceBundle(
ctx: ResolvedClientContext,
traceId: string,
): Promise<FeedbackTraceBundle> {
const bundle = await ctx.api.get<FeedbackTraceBundle>(`/api/feedback-traces/${traceId}/bundle`);
if (!bundle) {
throw new Error(`Feedback trace bundle ${traceId} not found`);
}
return bundle;
}
export function summarizeFeedbackTraces(traces: FeedbackTrace[]): FeedbackSummary {
const statuses: Record<string, number> = {};
let thumbsUp = 0;
let thumbsDown = 0;
let withReason = 0;
for (const trace of traces) {
if (trace.vote === "up") thumbsUp += 1;
if (trace.vote === "down") thumbsDown += 1;
if (readFeedbackReason(trace)) withReason += 1;
statuses[trace.status] = (statuses[trace.status] ?? 0) + 1;
}
return {
total: traces.length,
thumbsUp,
thumbsDown,
withReason,
statuses,
};
}
export function renderFeedbackReport(input: {
apiBase: string;
companyId: string;
traces: FeedbackTrace[];
summary: FeedbackSummary;
includePayloads: boolean;
}): string {
const lines: string[] = [];
lines.push("");
lines.push(pc.bold(pc.magenta("Paperclip Feedback Report")));
lines.push(pc.dim(new Date().toISOString()));
lines.push(horizontalRule());
lines.push(`${pc.dim("Server:")} ${input.apiBase}`);
lines.push(`${pc.dim("Company:")} ${input.companyId}`);
lines.push("");
if (input.traces.length === 0) {
lines.push(pc.yellow("[!!] No feedback traces found."));
lines.push("");
return lines.join("\n");
}
lines.push(pc.bold(pc.cyan("Summary")));
lines.push(horizontalRule());
lines.push(` ${pc.green(pc.bold(String(input.summary.thumbsUp)))} thumbs up`);
lines.push(` ${pc.red(pc.bold(String(input.summary.thumbsDown)))} thumbs down`);
lines.push(` ${pc.yellow(pc.bold(String(input.summary.withReason)))} downvotes with a reason`);
lines.push(` ${pc.bold(String(input.summary.total))} total traces`);
lines.push("");
lines.push(pc.dim("Export status:"));
for (const status of ["pending", "sent", "local_only", "failed"]) {
lines.push(` ${padRight(status, 10)} ${input.summary.statuses[status] ?? 0}`);
}
lines.push("");
lines.push(pc.bold(pc.cyan("Trace Details")));
lines.push(horizontalRule());
for (const trace of input.traces) {
const voteColor = trace.vote === "up" ? pc.green : pc.red;
const voteIcon = trace.vote === "up" ? "^" : "v";
const issueRef = trace.issueIdentifier ?? trace.issueId;
const label = trace.targetSummary.label?.trim() || trace.targetType;
const excerpt = compactText(trace.targetSummary.excerpt);
const reason = readFeedbackReason(trace);
lines.push(
` ${voteColor(voteIcon)} ${pc.bold(issueRef)} ${pc.dim(compactText(trace.issueTitle, 64))}`,
);
lines.push(
` ${pc.dim("Trace:")} ${trace.id.slice(0, 8)} ${pc.dim("Status:")} ${trace.status} ${pc.dim("Date:")} ${formatTimestamp(trace.createdAt)}`,
);
lines.push(` ${pc.dim("Target:")} ${label}`);
if (excerpt) {
lines.push(` ${pc.dim("Excerpt:")} ${excerpt}`);
}
if (reason) {
lines.push(` ${pc.yellow(pc.bold("Reason:"))} ${pc.yellow(reason)}`);
}
lines.push("");
}
if (input.includePayloads) {
lines.push(pc.bold(pc.cyan("Raw Payloads")));
lines.push(horizontalRule());
for (const trace of input.traces) {
if (!trace.payloadSnapshot) continue;
const issueRef = trace.issueIdentifier ?? trace.issueId;
lines.push(` ${pc.bold(`${issueRef} (${trace.id.slice(0, 8)})`)}`);
const body = JSON.stringify(trace.payloadSnapshot, null, 2)?.split("\n") ?? [];
for (const line of body) {
lines.push(` ${pc.dim(line)}`);
}
lines.push("");
}
}
lines.push(horizontalRule());
lines.push(pc.dim(`Report complete. ${input.traces.length} trace(s) displayed.`));
lines.push("");
return lines.join("\n");
}
export async function writeFeedbackExportBundle(input: {
apiBase: string;
companyId: string;
traces: FeedbackTrace[];
outputDir: string;
traceBundleFetcher?: (trace: FeedbackTrace) => Promise<FeedbackTraceBundle>;
}): Promise<FeedbackExportResult> {
await ensureEmptyOutputDirectory(input.outputDir);
await mkdir(path.join(input.outputDir, "votes"), { recursive: true });
await mkdir(path.join(input.outputDir, "traces"), { recursive: true });
await mkdir(path.join(input.outputDir, "full-traces"), { recursive: true });
const summary = summarizeFeedbackTraces(input.traces);
const voteFiles: string[] = [];
const traceFiles: string[] = [];
const fullTraceDirs: string[] = [];
const fullTraceFiles: string[] = [];
const issueSet = new Set<string>();
for (const trace of input.traces) {
const issueRef = sanitizeFileSegment(trace.issueIdentifier ?? trace.issueId);
const voteRecord = buildFeedbackVoteRecord(trace);
const voteFileName = `${issueRef}-${trace.feedbackVoteId.slice(0, 8)}.json`;
const traceFileName = `${issueRef}-${trace.id.slice(0, 8)}.json`;
voteFiles.push(voteFileName);
traceFiles.push(traceFileName);
issueSet.add(trace.issueIdentifier ?? trace.issueId);
await writeFile(
path.join(input.outputDir, "votes", voteFileName),
`${JSON.stringify(voteRecord, null, 2)}\n`,
"utf8",
);
await writeFile(
path.join(input.outputDir, "traces", traceFileName),
`${JSON.stringify(trace, null, 2)}\n`,
"utf8",
);
if (input.traceBundleFetcher) {
const bundle = await input.traceBundleFetcher(trace);
const bundleDirName = `${issueRef}-${trace.id.slice(0, 8)}`;
const bundleDir = path.join(input.outputDir, "full-traces", bundleDirName);
await mkdir(bundleDir, { recursive: true });
fullTraceDirs.push(bundleDirName);
await writeFile(
path.join(bundleDir, "bundle.json"),
`${JSON.stringify(bundle, null, 2)}\n`,
"utf8",
);
fullTraceFiles.push(path.posix.join("full-traces", bundleDirName, "bundle.json"));
for (const file of bundle.files) {
const targetPath = path.join(bundleDir, file.path);
await mkdir(path.dirname(targetPath), { recursive: true });
await writeFile(targetPath, file.contents, "utf8");
fullTraceFiles.push(path.posix.join("full-traces", bundleDirName, file.path.replace(/\\/g, "/")));
}
}
}
const zipPath = `${input.outputDir}.zip`;
const manifest: FeedbackExportManifest = {
exportedAt: new Date().toISOString(),
serverUrl: input.apiBase,
companyId: input.companyId,
summary: {
...summary,
uniqueIssues: issueSet.size,
issues: Array.from(issueSet).sort((left, right) => left.localeCompare(right)),
},
files: {
votes: voteFiles.slice().sort((left, right) => left.localeCompare(right)),
traces: traceFiles.slice().sort((left, right) => left.localeCompare(right)),
fullTraces: fullTraceDirs.slice().sort((left, right) => left.localeCompare(right)),
zip: path.basename(zipPath),
},
};
await writeFile(
path.join(input.outputDir, "index.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
const archiveFiles = await collectJsonFilesForArchive(input.outputDir, [
"index.json",
...manifest.files.votes.map((file) => path.posix.join("votes", file)),
...manifest.files.traces.map((file) => path.posix.join("traces", file)),
...fullTraceFiles,
]);
await writeFile(zipPath, createStoredZipArchive(archiveFiles, path.basename(input.outputDir)));
return {
outputDir: input.outputDir,
zipPath,
manifest,
};
}
export function renderFeedbackExportSummary(exported: FeedbackExportResult): string {
const lines: string[] = [];
lines.push("");
lines.push(pc.bold(pc.magenta("Paperclip Feedback Export")));
lines.push(pc.dim(exported.manifest.exportedAt));
lines.push(horizontalRule());
lines.push(`${pc.dim("Company:")} ${exported.manifest.companyId}`);
lines.push(`${pc.dim("Output:")} ${exported.outputDir}`);
lines.push(`${pc.dim("Archive:")} ${exported.zipPath}`);
lines.push("");
lines.push(pc.bold("Export Summary"));
lines.push(horizontalRule());
lines.push(` ${pc.green(pc.bold(String(exported.manifest.summary.thumbsUp)))} thumbs up`);
lines.push(` ${pc.red(pc.bold(String(exported.manifest.summary.thumbsDown)))} thumbs down`);
lines.push(` ${pc.yellow(pc.bold(String(exported.manifest.summary.withReason)))} with reason`);
lines.push(` ${pc.bold(String(exported.manifest.summary.uniqueIssues))} unique issues`);
lines.push("");
lines.push(pc.dim("Files:"));
lines.push(` ${path.join(exported.outputDir, "index.json")}`);
lines.push(` ${path.join(exported.outputDir, "votes")} (${exported.manifest.files.votes.length} files)`);
lines.push(` ${path.join(exported.outputDir, "traces")} (${exported.manifest.files.traces.length} files)`);
lines.push(` ${path.join(exported.outputDir, "full-traces")} (${exported.manifest.files.fullTraces.length} bundles)`);
lines.push(` ${exported.zipPath}`);
lines.push("");
return lines.join("\n");
}
function readFeedbackReason(trace: FeedbackTrace): string | null {
const payload = asRecord(trace.payloadSnapshot);
const vote = asRecord(payload?.vote);
const reason = vote?.reason;
return typeof reason === "string" && reason.trim() ? reason.trim() : null;
}
function buildFeedbackVoteRecord(trace: FeedbackTrace) {
return {
voteId: trace.feedbackVoteId,
traceId: trace.id,
issueId: trace.issueId,
issueIdentifier: trace.issueIdentifier,
issueTitle: trace.issueTitle,
vote: trace.vote,
targetType: trace.targetType,
targetId: trace.targetId,
targetSummary: trace.targetSummary,
status: trace.status,
consentVersion: trace.consentVersion,
createdAt: trace.createdAt,
updatedAt: trace.updatedAt,
reason: readFeedbackReason(trace),
};
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function compactText(value: string | null | undefined, maxLength = 88): string | null {
if (!value) return null;
const compact = value.replace(/\s+/g, " ").trim();
if (!compact) return null;
if (compact.length <= maxLength) return compact;
return `${compact.slice(0, maxLength - 3)}...`;
}
function formatTimestamp(value: unknown): string {
if (value instanceof Date) return value.toISOString().slice(0, 19).replace("T", " ");
if (typeof value === "string") return value.slice(0, 19).replace("T", " ");
return "-";
}
function horizontalRule(): string {
return pc.dim("-".repeat(72));
}
function padRight(value: string, width: number): string {
return `${value}${" ".repeat(Math.max(0, width - value.length))}`;
}
function defaultFeedbackExportDirName(): string {
const iso = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
return `feedback-export-${iso}`;
}
async function ensureEmptyOutputDirectory(outputDir: string): Promise<void> {
try {
const info = await stat(outputDir);
if (!info.isDirectory()) {
throw new Error(`Output path already exists and is not a directory: ${outputDir}`);
}
const entries = await readdir(outputDir);
if (entries.length > 0) {
throw new Error(`Output directory already exists and is not empty: ${outputDir}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (/ENOENT/.test(message)) {
await mkdir(outputDir, { recursive: true });
return;
}
throw error;
}
}
async function collectJsonFilesForArchive(
outputDir: string,
relativePaths: string[],
): Promise<Record<string, string>> {
const files: Record<string, string> = {};
for (const relativePath of relativePaths) {
const normalized = relativePath.replace(/\\/g, "/");
files[normalized] = await readFile(path.join(outputDir, normalized), "utf8");
}
return files;
}
function sanitizeFileSegment(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "feedback";
}
function writeUint16(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
}
function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
target[offset + 2] = (value >>> 16) & 0xff;
target[offset + 3] = (value >>> 24) & 0xff;
}
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function createStoredZipArchive(files: Record<string, string>, rootPath: string): Uint8Array {
const encoder = new TextEncoder();
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
let entryCount = 0;
for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
const body = encoder.encode(content);
const checksum = crc32(body);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, 0);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, body.length);
writeUint32(localHeader, 22, body.length);
writeUint16(localHeader, 26, fileName.length);
localHeader.set(fileName, 30);
const centralHeader = new Uint8Array(46 + fileName.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0x0800);
writeUint16(centralHeader, 10, 0);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, body.length);
writeUint32(centralHeader, 24, body.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, body);
centralChunks.push(centralHeader);
localOffset += localHeader.length + body.length;
entryCount += 1;
}
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
);
let offset = 0;
for (const chunk of localChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
const centralDirectoryOffset = offset;
for (const chunk of centralChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
writeUint32(archive, offset, 0x06054b50);
writeUint16(archive, offset + 8, entryCount);
writeUint16(archive, offset + 10, entryCount);
writeUint32(archive, offset + 12, centralDirectoryLength);
writeUint32(archive, offset + 16, centralDirectoryOffset);
return archive;
}

View file

@ -1,8 +1,10 @@
import { Command } from "commander";
import { writeFile } from "node:fs/promises";
import {
addIssueCommentSchema,
checkoutIssueSchema,
createIssueSchema,
type FeedbackTrace,
updateIssueSchema,
type Issue,
type IssueComment,
@ -15,6 +17,11 @@ import {
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
import {
buildFeedbackTraceQuery,
normalizeFeedbackTraceExportFormat,
serializeFeedbackTraces,
} from "./feedback.js";
interface IssueBaseOptions extends BaseClientOptions {
status?: string;
@ -61,6 +68,18 @@ interface IssueCheckoutOptions extends BaseClientOptions {
expectedStatuses?: string;
}
interface IssueFeedbackOptions extends BaseClientOptions {
targetType?: string;
vote?: string;
status?: string;
from?: string;
to?: string;
sharedOnly?: boolean;
includePayload?: boolean;
out?: string;
format?: string;
}
export function registerIssueCommands(program: Command): void {
const issue = program.command("issue").description("Issue operations");
@ -237,6 +256,85 @@ export function registerIssueCommands(program: Command): void {
}),
);
addCommonClientOptions(
issue
.command("feedback:list")
.description("List feedback traces for an issue")
.argument("<issueId>", "Issue ID")
.option("--target-type <type>", "Filter by target type")
.option("--vote <vote>", "Filter by vote value")
.option("--status <status>", "Filter by trace status")
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
.option("--shared-only", "Only include traces eligible for sharing/export")
.option("--include-payload", "Include stored payload snapshots in the response")
.action(async (issueId: string, opts: IssueFeedbackOptions) => {
try {
const ctx = resolveCommandContext(opts);
const traces = (await ctx.api.get<FeedbackTrace[]>(
`/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts)}`,
)) ?? [];
if (ctx.json) {
printOutput(traces, { json: true });
return;
}
printOutput(
traces.map((trace) => ({
id: trace.id,
issue: trace.issueIdentifier ?? trace.issueId,
vote: trace.vote,
status: trace.status,
targetType: trace.targetType,
target: trace.targetSummary.label,
})),
{ json: false },
);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
issue
.command("feedback:export")
.description("Export feedback traces for an issue")
.argument("<issueId>", "Issue ID")
.option("--target-type <type>", "Filter by target type")
.option("--vote <vote>", "Filter by vote value")
.option("--status <status>", "Filter by trace status")
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
.option("--shared-only", "Only include traces eligible for sharing/export")
.option("--include-payload", "Include stored payload snapshots in the export")
.option("--out <path>", "Write export to a file path instead of stdout")
.option("--format <format>", "Export format: json or ndjson", "ndjson")
.action(async (issueId: string, opts: IssueFeedbackOptions) => {
try {
const ctx = resolveCommandContext(opts);
const traces = (await ctx.api.get<FeedbackTrace[]>(
`/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`,
)) ?? [];
const serialized = serializeFeedbackTraces(traces, opts.format);
if (opts.out?.trim()) {
await writeFile(opts.out, serialized, "utf8");
if (ctx.json) {
printOutput(
{ out: opts.out, count: traces.length, format: normalizeFeedbackTraceExportFormat(opts.format) },
{ json: true },
);
return;
}
console.log(`Wrote ${traces.length} feedback trace(s) to ${opts.out}`);
return;
}
process.stdout.write(`${serialized}${serialized.endsWith("\n") ? "" : "\n"}`);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
issue
.command("checkout")

View file

@ -15,6 +15,7 @@ import { registerAgentCommands } from "./commands/client/agent.js";
import { registerApprovalCommands } from "./commands/client/approval.js";
import { registerActivityCommands } from "./commands/client/activity.js";
import { registerDashboardCommands } from "./commands/client/dashboard.js";
import { registerFeedbackCommands } from "./commands/client/feedback.js";
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
import { loadPaperclipEnvFile } from "./config/env.js";
import { registerWorktreeCommands } from "./commands/worktree.js";
@ -137,6 +138,7 @@ registerAgentCommands(program);
registerApprovalCommands(program);
registerActivityCommands(program);
registerDashboardCommands(program);
registerFeedbackCommands(program);
registerWorktreeCommands(program);
registerPluginCommands(program);

189
docs/feedback-voting.md Normal file
View file

@ -0,0 +1,189 @@
# Feedback Voting — Local Data Guide
When you rate an agent's response with **Helpful** (thumbs up) or **Needs work** (thumbs down), Paperclip saves your vote locally alongside your running instance. This guide covers what gets stored, how to access it, and how to export it.
## How voting works
1. Click **Helpful** or **Needs work** on any agent comment or document revision.
2. If you click **Needs work**, an optional text prompt appears: _"What could have been better?"_ You can type a reason or dismiss it.
3. A consent dialog asks whether to keep the vote local or share it. Your choice is remembered for future votes.
### What gets stored
Each vote creates two local records:
| Record | What it contains |
|--------|-----------------|
| **Vote** | Your vote (up/down), optional reason text, sharing preference, consent version, timestamp |
| **Trace bundle** | Full context snapshot: the voted-on comment/revision text, issue title, agent info, your vote, and reason — everything needed to understand the feedback in isolation |
All data lives in your local Paperclip database. Nothing leaves your machine unless you explicitly choose to share.
When a vote is marked for sharing, Paperclip also queues the trace bundle for background export through the Telemetry Backend. The app server never uploads raw feedback trace bundles directly to object storage.
## Viewing your votes
### Quick report (terminal)
```bash
pnpm paperclipai feedback report
```
Shows a color-coded summary: vote counts, per-trace details with reasons, and export statuses.
```bash
# Installed CLI
paperclipai feedback report
# Point to a different server or company
pnpm paperclipai feedback report --api-base http://127.0.0.1:3000 --company-id <company-id>
# Include raw payload dumps in the report
pnpm paperclipai feedback report --payloads
```
### API endpoints
All endpoints require board-user access (automatic in local dev).
**List votes for an issue:**
```bash
curl http://127.0.0.1:3102/api/issues/<issueId>/feedback-votes
```
**List trace bundles for an issue (with full payloads):**
```bash
curl 'http://127.0.0.1:3102/api/issues/<issueId>/feedback-traces?includePayload=true'
```
**List all traces company-wide:**
```bash
curl 'http://127.0.0.1:3102/api/companies/<companyId>/feedback-traces?includePayload=true'
```
**Get a single trace envelope record:**
```bash
curl http://127.0.0.1:3102/api/feedback-traces/<traceId>
```
**Get the full export bundle for a trace:**
```bash
curl http://127.0.0.1:3102/api/feedback-traces/<traceId>/bundle
```
#### Filtering
The trace endpoints accept query parameters:
| Parameter | Values | Description |
|-----------|--------|-------------|
| `vote` | `up`, `down` | Filter by vote direction |
| `status` | `local_only`, `pending`, `sent`, `failed` | Filter by export status |
| `targetType` | `issue_comment`, `issue_document_revision` | Filter by what was voted on |
| `sharedOnly` | `true` | Only show votes the user chose to share |
| `includePayload` | `true` | Include the full context snapshot |
| `from` / `to` | ISO date | Date range filter |
## Exporting your data
### Export to files + zip
```bash
pnpm paperclipai feedback export
```
Creates a timestamped directory with:
```
feedback-export-20260331T120000Z/
index.json # manifest with summary stats
votes/
PAP-123-a1b2c3d4.json # vote metadata (one per vote)
traces/
PAP-123-e5f6g7h8.json # Paperclip feedback envelope (one per trace)
full-traces/
PAP-123-e5f6g7h8/
bundle.json # full export manifest for the trace
...raw adapter files # codex / claude / opencode session artifacts when available
feedback-export-20260331T120000Z.zip
```
Exports are full by default. `traces/` keeps the Paperclip envelope, while `full-traces/` contains the richer per-trace bundle plus any recoverable adapter-native files.
```bash
# Custom server and output directory
pnpm paperclipai feedback export --api-base http://127.0.0.1:3000 --company-id <company-id> --out ./my-export
```
### Reading an exported trace
Open any file in `traces/` to see:
```json
{
"id": "trace-uuid",
"vote": "down",
"issueIdentifier": "PAP-123",
"issueTitle": "Fix login timeout",
"targetType": "issue_comment",
"targetSummary": {
"label": "Comment",
"excerpt": "The first 80 chars of the comment that was voted on..."
},
"payloadSnapshot": {
"vote": {
"value": "down",
"reason": "Did not address the root cause"
},
"target": {
"body": "Full text of the agent comment..."
},
"issue": {
"identifier": "PAP-123",
"title": "Fix login timeout"
}
}
}
```
Open `full-traces/<issue>-<trace>/bundle.json` to see the expanded export metadata, including capture notes, adapter type, integrity metadata, and the inventory of raw files written alongside it.
Built-in local adapters now export their native session artifacts more directly:
- `codex_local`: `adapter/codex/session.jsonl`
- `claude_local`: `adapter/claude/session.jsonl`, plus any `adapter/claude/session/...` sidecar files and `adapter/claude/debug.txt` when present
- `opencode_local`: `adapter/opencode/session.json`, `adapter/opencode/messages/*.json`, and `adapter/opencode/parts/<messageId>/*.json`, with optional `project.json`, `todo.json`, and `session-diff.json`
## Sharing preferences
The first time you vote, a consent dialog asks:
- **Keep local** — vote is stored locally only (`sharedWithLabs: false`)
- **Share this vote** — vote is marked for sharing (`sharedWithLabs: true`)
Your preference is saved per-company. You can change it any time via the feedback settings. Votes marked "keep local" are never queued for export.
## Data lifecycle
| Status | Meaning |
|--------|---------|
| `local_only` | Vote stored locally, not marked for sharing |
| `pending` | Marked for sharing, waiting to be sent |
| `sent` | Successfully transmitted |
| `failed` | Transmission attempted but failed (will retry) |
Your local database always retains the full vote and trace data regardless of sharing status.
## Remote sync
Votes you choose to share are queued as `pending` traces and flushed by the server's background worker to the Telemetry Backend. The Telemetry Backend validates the request, then persists the bundle into its configured object storage.
- App server responsibility: build the bundle, POST it to Telemetry Backend, update trace status
- Telemetry Backend responsibility: authenticate the request, validate payload shape, compress/store the bundle, return the final object key
- Retry behavior: failed uploads move to `failed` with an error message in `failureReason`, and the worker retries them on later ticks
Exported objects use a deterministic key pattern so they are easy to inspect:
```text
feedback-traces/<companyId>/YYYY/MM/DD/<exportId-or-traceId>.json
```

View file

@ -0,0 +1,70 @@
CREATE TABLE "feedback_exports" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"feedback_vote_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"project_id" uuid,
"author_user_id" text NOT NULL,
"target_type" text NOT NULL,
"target_id" text NOT NULL,
"vote" text NOT NULL,
"status" text DEFAULT 'local_only' NOT NULL,
"destination" text,
"export_id" text,
"consent_version" text,
"schema_version" text DEFAULT 'paperclip-feedback-envelope-v2' NOT NULL,
"bundle_version" text DEFAULT 'paperclip-feedback-bundle-v2' NOT NULL,
"payload_version" text DEFAULT 'paperclip-feedback-v1' NOT NULL,
"payload_digest" text,
"payload_snapshot" jsonb,
"target_summary" jsonb NOT NULL,
"redaction_summary" jsonb,
"attempt_count" integer DEFAULT 0 NOT NULL,
"last_attempted_at" timestamp with time zone,
"exported_at" timestamp with time zone,
"failure_reason" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "feedback_votes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"target_type" text NOT NULL,
"target_id" text NOT NULL,
"author_user_id" text NOT NULL,
"vote" text NOT NULL,
"reason" text,
"shared_with_labs" boolean DEFAULT false NOT NULL,
"shared_at" timestamp with time zone,
"consent_version" text,
"redaction_summary" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "companies" ADD COLUMN "feedback_data_sharing_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "companies" ADD COLUMN "feedback_data_sharing_consent_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "companies" ADD COLUMN "feedback_data_sharing_consent_by_user_id" text;--> statement-breakpoint
ALTER TABLE "companies" ADD COLUMN "feedback_data_sharing_terms_version" text;--> statement-breakpoint
ALTER TABLE "document_revisions" ADD COLUMN "created_by_run_id" uuid;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN "created_by_run_id" uuid;--> statement-breakpoint
ALTER TABLE "feedback_exports" ADD CONSTRAINT "feedback_exports_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feedback_exports" ADD CONSTRAINT "feedback_exports_feedback_vote_id_feedback_votes_id_fk" FOREIGN KEY ("feedback_vote_id") REFERENCES "public"."feedback_votes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feedback_exports" ADD CONSTRAINT "feedback_exports_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feedback_exports" ADD CONSTRAINT "feedback_exports_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feedback_votes" ADD CONSTRAINT "feedback_votes_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feedback_votes" ADD CONSTRAINT "feedback_votes_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "feedback_exports_feedback_vote_idx" ON "feedback_exports" USING btree ("feedback_vote_id");--> statement-breakpoint
CREATE INDEX "feedback_exports_company_created_idx" ON "feedback_exports" USING btree ("company_id","created_at");--> statement-breakpoint
CREATE INDEX "feedback_exports_company_status_idx" ON "feedback_exports" USING btree ("company_id","status","created_at");--> statement-breakpoint
CREATE INDEX "feedback_exports_company_issue_idx" ON "feedback_exports" USING btree ("company_id","issue_id","created_at");--> statement-breakpoint
CREATE INDEX "feedback_exports_company_project_idx" ON "feedback_exports" USING btree ("company_id","project_id","created_at");--> statement-breakpoint
CREATE INDEX "feedback_exports_company_author_idx" ON "feedback_exports" USING btree ("company_id","author_user_id","created_at");--> statement-breakpoint
CREATE INDEX "feedback_votes_company_issue_idx" ON "feedback_votes" USING btree ("company_id","issue_id");--> statement-breakpoint
CREATE INDEX "feedback_votes_issue_target_idx" ON "feedback_votes" USING btree ("issue_id","target_type","target_id");--> statement-breakpoint
CREATE INDEX "feedback_votes_author_idx" ON "feedback_votes" USING btree ("author_user_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "feedback_votes_company_target_author_idx" ON "feedback_votes" USING btree ("company_id","target_type","target_id","author_user_id");--> statement-breakpoint
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;

File diff suppressed because it is too large Load diff

View file

@ -330,6 +330,13 @@
"when": 1774960197878,
"tag": "0046_smooth_sentinels",
"breakpoints": true
},
{
"idx": 47,
"version": "7",
"when": 1775137972687,
"tag": "0047_overjoyed_groot",
"breakpoints": true
}
]
}

View file

@ -16,6 +16,12 @@ export const companies = pgTable(
requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents")
.notNull()
.default(true),
feedbackDataSharingEnabled: boolean("feedback_data_sharing_enabled")
.notNull()
.default(false),
feedbackDataSharingConsentAt: timestamp("feedback_data_sharing_consent_at", { withTimezone: true }),
feedbackDataSharingConsentByUserId: text("feedback_data_sharing_consent_by_user_id"),
feedbackDataSharingTermsVersion: text("feedback_data_sharing_terms_version"),
brandColor: text("brand_color"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),

View file

@ -2,6 +2,7 @@ import { pgTable, uuid, text, integer, timestamp, index, uniqueIndex } from "dri
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { documents } from "./documents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
export const documentRevisions = pgTable(
"document_revisions",
@ -16,6 +17,7 @@ export const documentRevisions = pgTable(
changeSummary: text("change_summary"),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({

View file

@ -0,0 +1,45 @@
import { index, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { feedbackVotes } from "./feedback_votes.js";
import { issues } from "./issues.js";
import { projects } from "./projects.js";
export const feedbackExports = pgTable(
"feedback_exports",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
feedbackVoteId: uuid("feedback_vote_id").notNull().references(() => feedbackVotes.id, { onDelete: "cascade" }),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
authorUserId: text("author_user_id").notNull(),
targetType: text("target_type").notNull(),
targetId: text("target_id").notNull(),
vote: text("vote").notNull(),
status: text("status").notNull().default("local_only"),
destination: text("destination"),
exportId: text("export_id"),
consentVersion: text("consent_version"),
schemaVersion: text("schema_version").notNull().default("paperclip-feedback-envelope-v2"),
bundleVersion: text("bundle_version").notNull().default("paperclip-feedback-bundle-v2"),
payloadVersion: text("payload_version").notNull().default("paperclip-feedback-v1"),
payloadDigest: text("payload_digest"),
payloadSnapshot: jsonb("payload_snapshot"),
targetSummary: jsonb("target_summary").notNull(),
redactionSummary: jsonb("redaction_summary"),
attemptCount: integer("attempt_count").notNull().default(0),
lastAttemptedAt: timestamp("last_attempted_at", { withTimezone: true }),
exportedAt: timestamp("exported_at", { withTimezone: true }),
failureReason: text("failure_reason"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
voteUniqueIdx: uniqueIndex("feedback_exports_feedback_vote_idx").on(table.feedbackVoteId),
companyCreatedIdx: index("feedback_exports_company_created_idx").on(table.companyId, table.createdAt),
companyStatusIdx: index("feedback_exports_company_status_idx").on(table.companyId, table.status, table.createdAt),
companyIssueIdx: index("feedback_exports_company_issue_idx").on(table.companyId, table.issueId, table.createdAt),
companyProjectIdx: index("feedback_exports_company_project_idx").on(table.companyId, table.projectId, table.createdAt),
companyAuthorIdx: index("feedback_exports_company_author_idx").on(table.companyId, table.authorUserId, table.createdAt),
}),
);

View file

@ -0,0 +1,34 @@
import { boolean, index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
export const feedbackVotes = pgTable(
"feedback_votes",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull().references(() => issues.id),
targetType: text("target_type").notNull(),
targetId: text("target_id").notNull(),
authorUserId: text("author_user_id").notNull(),
vote: text("vote").notNull(),
reason: text("reason"),
sharedWithLabs: boolean("shared_with_labs").notNull().default(false),
sharedAt: timestamp("shared_at", { withTimezone: true }),
consentVersion: text("consent_version"),
redactionSummary: jsonb("redaction_summary"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIssueIdx: index("feedback_votes_company_issue_idx").on(table.companyId, table.issueId),
issueTargetIdx: index("feedback_votes_issue_target_idx").on(table.issueId, table.targetType, table.targetId),
authorIdx: index("feedback_votes_author_idx").on(table.authorUserId, table.createdAt),
companyTargetAuthorUniqueIdx: uniqueIndex("feedback_votes_company_target_author_idx").on(
table.companyId,
table.targetType,
table.targetId,
table.authorUserId,
),
}),
);

View file

@ -32,6 +32,8 @@ export { issueLabels } from "./issue_labels.js";
export { issueApprovals } from "./issue_approvals.js";
export { issueComments } from "./issue_comments.js";
export { issueInboxArchives } from "./issue_inbox_archives.js";
export { feedbackVotes } from "./feedback_votes.js";
export { feedbackExports } from "./feedback_exports.js";
export { issueReadStates } from "./issue_read_states.js";
export { assets } from "./assets.js";
export { issueAttachments } from "./issue_attachments.js";

View file

@ -2,6 +2,7 @@ import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { agents } from "./agents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
export const issueComments = pgTable(
"issue_comments",
@ -11,6 +12,7 @@ export const issueComments = pgTable(
issueId: uuid("issue_id").notNull().references(() => issues.id),
authorAgentId: uuid("author_agent_id").references(() => agents.id),
authorUserId: text("author_user_id"),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
body: text("body").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),

View file

@ -138,6 +138,16 @@ export {
export type {
Company,
FeedbackVote,
FeedbackDataSharingPreference,
FeedbackTargetType,
FeedbackVoteValue,
FeedbackTrace,
FeedbackTraceStatus,
FeedbackTraceTargetSummary,
FeedbackTraceBundleCaptureStatus,
FeedbackTraceBundleFile,
FeedbackTraceBundle,
CompanySkillSourceType,
CompanySkillTrustLevel,
CompanySkillCompatibility,
@ -325,6 +335,15 @@ export type {
ProviderQuotaResult,
} from "./types/index.js";
export {
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
FEEDBACK_TARGET_TYPES,
FEEDBACK_DATA_SHARING_PREFERENCES,
FEEDBACK_TRACE_STATUSES,
FEEDBACK_VOTE_VALUES,
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
} from "./types/feedback.js";
export {
instanceGeneralSettingsSchema,
patchInstanceGeneralSettingsSchema,
@ -338,9 +357,14 @@ export {
createCompanySchema,
updateCompanySchema,
updateCompanyBrandingSchema,
feedbackTargetTypeSchema,
feedbackTraceStatusSchema,
feedbackVoteValueSchema,
upsertIssueFeedbackVoteSchema,
type CreateCompany,
type UpdateCompany,
type UpdateCompanyBranding,
type UpsertIssueFeedbackVote,
agentSkillStateSchema,
agentSkillSyncModeSchema,
agentSkillEntrySchema,

View file

@ -31,6 +31,10 @@ export interface CompanyPortabilityCompanyManifestEntry {
brandColor: string | null;
logoPath: string | null;
requireBoardApprovalForNewAgents: boolean;
feedbackDataSharingEnabled: boolean;
feedbackDataSharingConsentAt: string | null;
feedbackDataSharingConsentByUserId: string | null;
feedbackDataSharingTermsVersion: string | null;
}
export interface CompanyPortabilitySidebarOrder {

View file

@ -12,6 +12,10 @@ export interface Company {
budgetMonthlyCents: number;
spentMonthlyCents: number;
requireBoardApprovalForNewAgents: boolean;
feedbackDataSharingEnabled: boolean;
feedbackDataSharingConsentAt: Date | null;
feedbackDataSharingConsentByUserId: string | null;
feedbackDataSharingTermsVersion: string | null;
brandColor: string | null;
logoAssetId: string | null;
logoUrl: string | null;

View file

@ -0,0 +1,120 @@
export const FEEDBACK_TARGET_TYPES = ["issue_comment", "issue_document_revision"] as const;
export type FeedbackTargetType = (typeof FEEDBACK_TARGET_TYPES)[number];
export const FEEDBACK_VOTE_VALUES = ["up", "down"] as const;
export type FeedbackVoteValue = (typeof FEEDBACK_VOTE_VALUES)[number];
export const FEEDBACK_DATA_SHARING_PREFERENCES = ["allowed", "not_allowed", "prompt"] as const;
export type FeedbackDataSharingPreference = (typeof FEEDBACK_DATA_SHARING_PREFERENCES)[number];
export const DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE: FeedbackDataSharingPreference = "prompt";
export const FEEDBACK_TRACE_STATUSES = ["local_only", "pending", "sent", "failed"] as const;
export type FeedbackTraceStatus = (typeof FEEDBACK_TRACE_STATUSES)[number];
export const DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION = "feedback-data-sharing-v1";
export interface FeedbackVote {
id: string;
companyId: string;
issueId: string;
targetType: FeedbackTargetType;
targetId: string;
authorUserId: string;
vote: FeedbackVoteValue;
reason: string | null;
sharedWithLabs: boolean;
sharedAt: Date | null;
consentVersion: string | null;
redactionSummary: Record<string, unknown> | null;
createdAt: Date;
updatedAt: Date;
}
export interface FeedbackTraceTargetSummary {
label: string;
excerpt: string | null;
authorAgentId: string | null;
authorUserId: string | null;
createdAt: Date | null;
documentKey: string | null;
documentTitle: string | null;
revisionNumber: number | null;
}
export interface FeedbackTrace {
id: string;
companyId: string;
feedbackVoteId: string;
issueId: string;
projectId: string | null;
issueIdentifier: string | null;
issueTitle: string;
authorUserId: string;
targetType: FeedbackTargetType;
targetId: string;
vote: FeedbackVoteValue;
status: FeedbackTraceStatus;
destination: string | null;
exportId: string | null;
consentVersion: string | null;
schemaVersion: string;
bundleVersion: string;
payloadVersion: string;
payloadDigest: string | null;
payloadSnapshot: Record<string, unknown> | null;
targetSummary: FeedbackTraceTargetSummary;
redactionSummary: Record<string, unknown> | null;
attemptCount: number;
lastAttemptedAt: Date | null;
exportedAt: Date | null;
failureReason: string | null;
createdAt: Date;
updatedAt: Date;
}
export type FeedbackTraceBundleCaptureStatus = "full" | "partial" | "unavailable";
export interface FeedbackTraceBundleFile {
path: string;
contentType: string;
encoding: "utf8";
byteLength: number;
sha256: string;
source:
| "paperclip_run"
| "paperclip_run_events"
| "paperclip_run_log"
| "codex_session"
| "claude_stream_json"
| "claude_project_session"
| "claude_project_artifact"
| "claude_debug_log"
| "claude_task_metadata"
| "opencode_session"
| "opencode_session_diff"
| "opencode_message"
| "opencode_message_part"
| "opencode_project"
| "opencode_todo";
contents: string;
}
export interface FeedbackTraceBundle {
traceId: string;
exportId: string | null;
companyId: string;
issueId: string;
issueIdentifier: string | null;
adapterType: string | null;
captureStatus: FeedbackTraceBundleCaptureStatus;
notes: string[];
envelope: Record<string, unknown>;
surface: Record<string, unknown> | null;
paperclipRun: Record<string, unknown> | null;
rawAdapterTrace: Record<string, unknown> | null;
normalizedAdapterTrace: Record<string, unknown> | null;
privacy: Record<string, unknown> | null;
integrity: Record<string, unknown>;
files: FeedbackTraceBundleFile[];
}

View file

@ -1,4 +1,16 @@
export type { Company } from "./company.js";
export type {
FeedbackVote,
FeedbackDataSharingPreference,
FeedbackTargetType,
FeedbackVoteValue,
FeedbackTrace,
FeedbackTraceStatus,
FeedbackTraceTargetSummary,
FeedbackTraceBundleCaptureStatus,
FeedbackTraceBundleFile,
FeedbackTraceBundle,
} from "./feedback.js";
export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings } from "./instance.js";
export type {
CompanySkillSourceType,

View file

@ -1,5 +1,8 @@
import type { FeedbackDataSharingPreference } from "./feedback.js";
export interface InstanceGeneralSettings {
censorUsernameInLogs: boolean;
feedbackDataSharingPreference: FeedbackDataSharingPreference;
}
export interface InstanceExperimentalSettings {

View file

@ -36,6 +36,10 @@ export const portabilityCompanyManifestEntrySchema = z.object({
brandColor: z.string().nullable(),
logoPath: z.string().nullable(),
requireBoardApprovalForNewAgents: z.boolean(),
feedbackDataSharingEnabled: z.boolean().default(false),
feedbackDataSharingConsentAt: z.string().datetime().nullable().default(null),
feedbackDataSharingConsentByUserId: z.string().nullable().default(null),
feedbackDataSharingTermsVersion: z.string().nullable().default(null),
});
export const portabilitySidebarOrderSchema = z.object({

View file

@ -3,6 +3,7 @@ import { COMPANY_STATUSES } from "../constants.js";
const logoAssetIdSchema = z.string().uuid().nullable().optional();
const brandColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional();
const feedbackDataSharingTermsVersionSchema = z.string().min(1).nullable().optional();
export const createCompanySchema = z.object({
name: z.string().min(1),
@ -18,6 +19,10 @@ export const updateCompanySchema = createCompanySchema
status: z.enum(COMPANY_STATUSES).optional(),
spentMonthlyCents: z.number().int().nonnegative().optional(),
requireBoardApprovalForNewAgents: z.boolean().optional(),
feedbackDataSharingEnabled: z.boolean().optional(),
feedbackDataSharingConsentAt: z.coerce.date().nullable().optional(),
feedbackDataSharingConsentByUserId: z.string().min(1).nullable().optional(),
feedbackDataSharingTermsVersion: feedbackDataSharingTermsVersionSchema,
brandColor: brandColorSchema,
logoAssetId: logoAssetIdSchema,
});

View file

@ -0,0 +1,22 @@
import { z } from "zod";
import {
FEEDBACK_DATA_SHARING_PREFERENCES,
FEEDBACK_TARGET_TYPES,
FEEDBACK_TRACE_STATUSES,
FEEDBACK_VOTE_VALUES,
} from "../types/feedback.js";
export const feedbackTargetTypeSchema = z.enum(FEEDBACK_TARGET_TYPES);
export const feedbackTraceStatusSchema = z.enum(FEEDBACK_TRACE_STATUSES);
export const feedbackVoteValueSchema = z.enum(FEEDBACK_VOTE_VALUES);
export const feedbackDataSharingPreferenceSchema = z.enum(FEEDBACK_DATA_SHARING_PREFERENCES);
export const upsertIssueFeedbackVoteSchema = z.object({
targetType: feedbackTargetTypeSchema,
targetId: z.string().uuid(),
vote: feedbackVoteValueSchema,
reason: z.string().trim().max(1000).optional(),
allowSharing: z.boolean().optional(),
});
export type UpsertIssueFeedbackVote = z.infer<typeof upsertIssueFeedbackVoteSchema>;

View file

@ -24,6 +24,14 @@ export {
type UpdateCompany,
type UpdateCompanyBranding,
} from "./company.js";
export {
feedbackDataSharingPreferenceSchema,
feedbackTargetTypeSchema,
feedbackTraceStatusSchema,
feedbackVoteValueSchema,
upsertIssueFeedbackVoteSchema,
type UpsertIssueFeedbackVote,
} from "./feedback.js";
export {
companySkillSourceTypeSchema,
companySkillTrustLevelSchema,

View file

@ -1,7 +1,12 @@
import { z } from "zod";
import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE } from "../types/feedback.js";
import { feedbackDataSharingPreferenceSchema } from "./feedback.js";
export const instanceGeneralSettingsSchema = z.object({
censorUsernameInLogs: z.boolean().default(false),
feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default(
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
),
}).strict();
export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial();

View file

@ -131,7 +131,7 @@ function makeAgent(adapterType: string) {
describe("agent skill routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
agent: makeAgent("claude_local"),

View file

@ -29,6 +29,12 @@ vi.mock("../services/index.js", () => ({
agentService: () => ({
getById: vi.fn(),
}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(),
listFeedbackTraces: vi.fn(),
getFeedbackTraceById: vi.fn(),
saveIssueVote: vi.fn(),
}),
logActivity: vi.fn(),
}));

View file

@ -34,6 +34,12 @@ const mockCompanyPortabilityService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(),
listFeedbackTraces: vi.fn(),
getFeedbackTraceById: vi.fn(),
saveIssueVote: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
@ -41,6 +47,7 @@ vi.mock("../services/index.js", () => ({
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
feedbackService: () => mockFeedbackService,
logActivity: mockLogActivity,
}));
@ -78,9 +85,7 @@ function createApp(actor: Record<string, unknown>) {
describe("PATCH /api/companies/:companyId/branding", () => {
beforeEach(() => {
mockCompanyService.update.mockReset();
mockAgentService.getById.mockReset();
mockLogActivity.mockReset();
vi.resetAllMocks();
});
it("rejects non-CEO agent callers", async () => {

View file

@ -32,6 +32,12 @@ const mockCompanyPortabilityService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(),
listFeedbackTraces: vi.fn(),
getFeedbackTraceById: vi.fn(),
saveIssueVote: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
@ -39,6 +45,7 @@ vi.mock("../services/index.js", () => ({
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
feedbackService: () => mockFeedbackService,
logActivity: mockLogActivity,
}));

File diff suppressed because it is too large Load diff

View file

@ -35,6 +35,7 @@ describe("instance settings routes", () => {
vi.clearAllMocks();
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
});
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
@ -44,6 +45,7 @@ describe("instance settings routes", () => {
id: "instance-settings-1",
general: {
censorUsernameInLogs: true,
feedbackDataSharingPreference: "allowed",
},
});
mockInstanceSettingsService.updateExperimental.mockResolvedValue({
@ -110,15 +112,22 @@ describe("instance settings routes", () => {
const getRes = await request(app).get("/api/instance/settings/general");
expect(getRes.status).toBe(200);
expect(getRes.body).toEqual({ censorUsernameInLogs: false });
expect(getRes.body).toEqual({
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
});
const patchRes = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
.send({
censorUsernameInLogs: true,
feedbackDataSharingPreference: "allowed",
});
expect(patchRes.status).toBe(200);
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
censorUsernameInLogs: true,
feedbackDataSharingPreference: "allowed",
});
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
@ -148,7 +157,7 @@ describe("instance settings routes", () => {
const res = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
.send({ feedbackDataSharingPreference: "not_allowed" });
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();

View file

@ -35,8 +35,22 @@ vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,

View file

@ -32,11 +32,16 @@ vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
documentService: () => mockDocumentsService,
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({
getExperimental: vi.fn(async () => ({})),
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,

View file

@ -0,0 +1,128 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
const mockFeedbackService = vi.hoisted(() => ({
getFeedbackTraceById: vi.fn(),
getFeedbackTraceBundle: vi.fn(),
listIssueVotesForUser: vi.fn(),
listFeedbackTraces: vi.fn(),
saveIssueVote: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => ({
getById: vi.fn(),
update: vi.fn(),
addComment: vi.fn(),
findMentionedAgents: vi.fn(),
}),
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
describe("issue feedback trace routes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rejects non-board callers before fetching a feedback trace", async () => {
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app).get("/api/feedback-traces/trace-1");
expect(res.status).toBe(403);
expect(mockFeedbackService.getFeedbackTraceById).not.toHaveBeenCalled();
});
it("returns 404 when a board user lacks access to the trace company", async () => {
mockFeedbackService.getFeedbackTraceById.mockResolvedValue({
id: "trace-1",
companyId: "company-2",
});
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app).get("/api/feedback-traces/trace-1");
expect(res.status).toBe(404);
});
it("returns 404 for bundle fetches when a board user lacks access to the trace company", async () => {
mockFeedbackService.getFeedbackTraceBundle.mockResolvedValue({
id: "trace-1",
companyId: "company-2",
issueId: "issue-1",
files: [],
});
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app).get("/api/feedback-traces/trace-1/bundle");
expect(res.status).toBe(404);
});
});

View file

@ -36,11 +36,25 @@ vi.mock("../services/index.js", () => ({
executionWorkspaceService: () => ({
getById: vi.fn(),
}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => mockGoalService,
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),

View file

@ -228,6 +228,42 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
});
it("accepts issue identifiers through getById", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
issueNumber: 1064,
identifier: "PAP-1064",
title: "Feedback votes error",
status: "todo",
priority: "medium",
createdByUserId: "user-1",
});
const issue = await svc.getById("PAP-1064");
expect(issue).toEqual(
expect.objectContaining({
id: issueId,
identifier: "PAP-1064",
}),
);
});
it("returns null instead of throwing for malformed non-uuid issue refs", async () => {
await expect(svc.getById("not-a-uuid")).resolves.toBeNull();
});
it("filters issues by execution workspace id", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
@ -357,18 +393,8 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
},
]);
await svc.archiveInbox(
companyId,
archivedIssueId,
userId,
new Date("2026-03-26T12:30:00.000Z"),
);
await svc.archiveInbox(
companyId,
resurfacedIssueId,
userId,
new Date("2026-03-26T13:00:00.000Z"),
);
await svc.archiveInbox(companyId, archivedIssueId, userId, new Date("2026-03-26T12:30:00.000Z"));
await svc.archiveInbox(companyId, resurfacedIssueId, userId, new Date("2026-03-26T13:00:00.000Z"));
await db.insert(issueComments).values({
companyId,

View file

@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
createAppMock,
createDbMock,
detectPortMock,
feedbackExportServiceMock,
feedbackServiceFactoryMock,
fakeServer,
} = vi.hoisted(() => {
const createAppMock = vi.fn(async () => ((_: unknown, __: unknown) => {}) as never);
const createDbMock = vi.fn(() => ({}) as never);
const detectPortMock = vi.fn(async (port: number) => port);
const feedbackExportServiceMock = {
flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 0, sent: 0, failed: 0 })),
};
const feedbackServiceFactoryMock = vi.fn(() => feedbackExportServiceMock);
const fakeServer = {
once: vi.fn().mockReturnThis(),
off: vi.fn().mockReturnThis(),
listen: vi.fn((_port: number, _host: string, callback?: () => void) => {
callback?.();
return fakeServer;
}),
close: vi.fn(),
};
return {
createAppMock,
createDbMock,
detectPortMock,
feedbackExportServiceMock,
feedbackServiceFactoryMock,
fakeServer,
};
});
vi.mock("node:http", () => ({
createServer: vi.fn(() => fakeServer),
}));
vi.mock("detect-port", () => ({
default: detectPortMock,
}));
vi.mock("@paperclipai/db", () => ({
createDb: createDbMock,
ensurePostgresDatabase: vi.fn(),
getPostgresDataDirectory: vi.fn(),
inspectMigrations: vi.fn(async () => ({ status: "upToDate" })),
applyPendingMigrations: vi.fn(),
reconcilePendingMigrationHistory: vi.fn(async () => ({ repairedMigrations: [] })),
formatDatabaseBackupResult: vi.fn(() => "ok"),
runDatabaseBackup: vi.fn(),
authUsers: {},
companies: {},
companyMemberships: {},
instanceUserRoles: {},
}));
vi.mock("../app.js", () => ({
createApp: createAppMock,
}));
vi.mock("../config.js", () => ({
loadConfig: vi.fn(() => ({
deploymentMode: "authenticated",
deploymentExposure: "private",
host: "127.0.0.1",
port: 3210,
allowedHostnames: [],
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
authDisableSignUp: false,
databaseMode: "postgres",
databaseUrl: "postgres://paperclip:paperclip@127.0.0.1:5432/paperclip",
embeddedPostgresDataDir: "/tmp/paperclip-test-db",
embeddedPostgresPort: 54329,
databaseBackupEnabled: false,
databaseBackupIntervalMinutes: 60,
databaseBackupRetentionDays: 30,
databaseBackupDir: "/tmp/paperclip-test-backups",
serveUi: false,
uiDevMiddleware: false,
secretsProvider: "local_encrypted",
secretsStrictMode: false,
secretsMasterKeyFilePath: "/tmp/paperclip-master.key",
storageProvider: "local_disk",
storageLocalDiskBaseDir: "/tmp/paperclip-storage",
storageS3Bucket: "paperclip-test",
storageS3Region: "us-east-1",
storageS3Endpoint: undefined,
storageS3Prefix: "",
storageS3ForcePathStyle: false,
feedbackExportBackendUrl: "https://telemetry.example.com",
feedbackExportBackendToken: "telemetry-token",
heartbeatSchedulerEnabled: false,
heartbeatSchedulerIntervalMs: 30000,
companyDeletionEnabled: false,
})),
}));
vi.mock("../middleware/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("../realtime/live-events-ws.js", () => ({
setupLiveEventsWebSocketServer: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
feedbackService: feedbackServiceFactoryMock,
heartbeatService: vi.fn(() => ({
reapOrphanedRuns: vi.fn(async () => undefined),
resumeQueuedRuns: vi.fn(async () => undefined),
tickTimers: vi.fn(async () => ({ enqueued: 0 })),
})),
reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })),
routineService: vi.fn(() => ({
tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })),
})),
}));
vi.mock("../storage/index.js", () => ({
createStorageServiceFromConfig: vi.fn(() => ({ id: "storage-service" })),
}));
vi.mock("../services/feedback-share-client.js", () => ({
createFeedbackTraceShareClientFromConfig: vi.fn(() => ({ id: "feedback-share-client" })),
}));
vi.mock("../startup-banner.js", () => ({
printStartupBanner: vi.fn(),
}));
vi.mock("../board-claim.js", () => ({
getBoardClaimWarningUrl: vi.fn(() => null),
initializeBoardClaimChallenge: vi.fn(async () => undefined),
}));
vi.mock("../auth/better-auth.js", () => ({
createBetterAuthHandler: vi.fn(() => undefined),
createBetterAuthInstance: vi.fn(() => ({})),
deriveAuthTrustedOrigins: vi.fn(() => []),
resolveBetterAuthSession: vi.fn(async () => null),
resolveBetterAuthSessionFromHeaders: vi.fn(async () => null),
}));
import { startServer } from "../index.ts";
describe("startServer feedback export wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.BETTER_AUTH_SECRET = "test-secret";
});
it("passes the feedback export service into createApp so pending traces flush in runtime", async () => {
const started = await startServer();
expect(started.server).toBe(fakeServer);
expect(feedbackServiceFactoryMock).toHaveBeenCalledTimes(1);
expect(createAppMock).toHaveBeenCalledTimes(1);
expect(createAppMock.mock.calls[0]?.[1]).toMatchObject({
feedbackExportService: feedbackExportServiceMock,
storageService: { id: "storage-service" },
serverPort: 3210,
});
});
});

View file

@ -51,7 +51,7 @@ async function runGit(cwd: string, args: string[]) {
await execFileAsync("git", args, { cwd });
}
async function createTempRepo() {
async function createTempRepo(defaultBranch = "main") {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-"));
await runGit(repoRoot, ["init"]);
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
@ -59,7 +59,7 @@ async function createTempRepo() {
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
await runGit(repoRoot, ["checkout", "-B", "main"]);
await runGit(repoRoot, ["checkout", "-B", defaultBranch]);
return repoRoot;
}
@ -658,13 +658,7 @@ describe("realizeExecutionWorkspace", () => {
it("auto-detects the default branch when baseRef is not configured", async () => {
// Create a repo with "master" as default branch (not "main")
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-master-"));
await runGit(repoRoot, ["init", "-b", "master"]);
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
const repoRoot = await createTempRepo("master");
// Set up a bare remote and push master so refs/remotes/origin/master
// exists locally. Note: refs/remotes/origin/HEAD is NOT set by a manual
@ -716,13 +710,7 @@ describe("realizeExecutionWorkspace", () => {
it("auto-detects the default branch via symbolic-ref when origin/HEAD is set", async () => {
// Create a repo with "master" as default branch
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-symref-"));
await runGit(repoRoot, ["init", "-b", "master"]);
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
const repoRoot = await createTempRepo("master");
// Set up a bare remote and push
const bareRemote = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-bare-symref-"));

View file

@ -49,6 +49,7 @@ import { createHostClientHandlers } from "@paperclipai/plugin-sdk";
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
type UiMode = "none" | "static" | "vite-dev";
const FEEDBACK_EXPORT_FLUSH_INTERVAL_MS = 5_000;
export function resolveViteHmrPort(serverPort: number): number {
if (serverPort <= 55_535) {
@ -63,6 +64,13 @@ export async function createApp(
uiMode: UiMode;
serverPort: number;
storageService: StorageService;
feedbackExportService?: {
flushPendingFeedbackTraces(input?: {
companyId?: string;
limit?: number;
now?: Date;
}): Promise<unknown>;
};
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
allowedHostnames: string[];
@ -288,6 +296,19 @@ export async function createApp(
jobCoordinator.start();
scheduler.start();
const feedbackExportTimer = opts.feedbackExportService
? setInterval(() => {
void opts.feedbackExportService?.flushPendingFeedbackTraces().catch((err) => {
logger.error({ err }, "Failed to flush pending feedback exports");
});
}, FEEDBACK_EXPORT_FLUSH_INTERVAL_MS)
: null;
feedbackExportTimer?.unref?.();
if (opts.feedbackExportService) {
void opts.feedbackExportService.flushPendingFeedbackTraces().catch((err) => {
logger.error({ err }, "Failed to flush pending feedback exports");
});
}
void toolDispatcher.initialize().catch((err) => {
logger.error({ err }, "Failed to initialize plugin tool dispatcher");
});
@ -308,6 +329,7 @@ export async function createApp(
logger.error({ err }, "Failed to load ready plugins on startup");
});
process.once("exit", () => {
if (feedbackExportTimer) clearInterval(feedbackExportTimer);
devWatcher?.close();
hostServiceCleanup.disposeAll();
hostServiceCleanup.teardown();

View file

@ -70,6 +70,8 @@ export interface Config {
storageS3Endpoint: string | undefined;
storageS3Prefix: string;
storageS3ForcePathStyle: boolean;
feedbackExportBackendUrl: string | undefined;
feedbackExportBackendToken: string | undefined;
heartbeatSchedulerEnabled: boolean;
heartbeatSchedulerIntervalMs: number;
companyDeletionEnabled: boolean;
@ -120,6 +122,14 @@ export function loadConfig(): Config {
process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE !== undefined
? process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE === "true"
: (fileStorage?.s3?.forcePathStyle ?? false);
const feedbackExportBackendUrl =
process.env.PAPERCLIP_FEEDBACK_EXPORT_BACKEND_URL?.trim() ||
process.env.PAPERCLIP_TELEMETRY_BACKEND_URL?.trim() ||
undefined;
const feedbackExportBackendToken =
process.env.PAPERCLIP_FEEDBACK_EXPORT_BACKEND_TOKEN?.trim() ||
process.env.PAPERCLIP_TELEMETRY_BACKEND_TOKEN?.trim() ||
undefined;
const deploymentModeFromEnvRaw = process.env.PAPERCLIP_DEPLOYMENT_MODE;
const deploymentModeFromEnv =
@ -252,6 +262,8 @@ export function loadConfig(): Config {
storageS3Endpoint,
storageS3Prefix,
storageS3ForcePathStyle,
feedbackExportBackendUrl,
feedbackExportBackendToken,
heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false",
heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000),
companyDeletionEnabled,

View file

@ -28,7 +28,13 @@ import { createApp } from "./app.js";
import { loadConfig } from "./config.js";
import { logger } from "./middleware/logger.js";
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, routineService } from "./services/index.js";
import {
feedbackService,
heartbeatService,
reconcilePersistedRuntimeServicesOnStartup,
routineService,
} from "./services/index.js";
import { createFeedbackTraceShareClientFromConfig } from "./services/feedback-share-client.js";
import { createStorageServiceFromConfig } from "./storage/index.js";
import { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
@ -516,10 +522,14 @@ export async function startServer(): Promise<StartedServer> {
});
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
const storageService = createStorageServiceFromConfig(config);
const feedback = feedbackService(db as any, {
shareClient: createFeedbackTraceShareClientFromConfig(config) ?? undefined,
});
const app = await createApp(db as any, {
uiMode,
serverPort: listenPort,
storageService,
feedbackExportService: feedback,
deploymentMode: config.deploymentMode,
deploymentExposure: config.deploymentExposure,
allowedHostnames: config.allowedHostnames,

View file

@ -1,14 +1,18 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
companyPortabilityExportSchema,
companyPortabilityImportSchema,
companyPortabilityPreviewSchema,
createCompanySchema,
feedbackTargetTypeSchema,
feedbackTraceStatusSchema,
feedbackVoteValueSchema,
updateCompanyBrandingSchema,
updateCompanySchema,
} from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { badRequest, forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js";
import {
accessService,
@ -16,6 +20,7 @@ import {
budgetService,
companyPortabilityService,
companyService,
feedbackService,
logActivity,
} from "../services/index.js";
import type { StorageService } from "../storage/types.js";
@ -28,6 +33,20 @@ export function companyRoutes(db: Db, storage?: StorageService) {
const portability = companyPortabilityService(db, storage);
const access = accessService(db);
const budgets = budgetService(db);
const feedback = feedbackService(db);
function parseBooleanQuery(value: unknown) {
return value === true || value === "true" || value === "1";
}
function parseDateQuery(value: unknown, field: string) {
if (typeof value !== "string" || value.trim().length === 0) return undefined;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw badRequest(`Invalid ${field} query value`);
}
return parsed;
}
async function assertCanUpdateBranding(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
@ -104,6 +123,34 @@ export function companyRoutes(db: Db, storage?: StorageService) {
res.json(company);
});
router.get("/:companyId/feedback-traces", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const targetTypeRaw = typeof req.query.targetType === "string" ? req.query.targetType : undefined;
const voteRaw = typeof req.query.vote === "string" ? req.query.vote : undefined;
const statusRaw = typeof req.query.status === "string" ? req.query.status : undefined;
const issueId = typeof req.query.issueId === "string" && req.query.issueId.trim().length > 0 ? req.query.issueId : undefined;
const projectId = typeof req.query.projectId === "string" && req.query.projectId.trim().length > 0
? req.query.projectId
: undefined;
const traces = await feedback.listFeedbackTraces({
companyId,
issueId,
projectId,
targetType: targetTypeRaw ? feedbackTargetTypeSchema.parse(targetTypeRaw) : undefined,
vote: voteRaw ? feedbackVoteValueSchema.parse(voteRaw) : undefined,
status: statusRaw ? feedbackTraceStatusSchema.parse(statusRaw) : undefined,
from: parseDateQuery(req.query.from, "from"),
to: parseDateQuery(req.query.to, "to"),
sharedOnly: parseBooleanQuery(req.query.sharedOnly),
includePayload: parseBooleanQuery(req.query.includePayload),
});
res.json(traces);
});
router.post("/:companyId/export", validate(companyPortabilityExportSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@ -246,6 +293,11 @@ export function companyRoutes(db: Db, storage?: StorageService) {
assertCompanyAccess(req, companyId);
const actor = getActorInfo(req);
const existingCompany = await svc.getById(companyId);
if (!existingCompany) {
res.status(404).json({ error: "Company not found" });
return;
}
let body: Record<string, unknown>;
if (req.actor.type === "agent") {
@ -262,6 +314,18 @@ export function companyRoutes(db: Db, storage?: StorageService) {
} else {
assertBoard(req);
body = updateCompanySchema.parse(req.body);
if (body.feedbackDataSharingEnabled === true && !existingCompany.feedbackDataSharingEnabled) {
body = {
...body,
feedbackDataSharingConsentAt: new Date(),
feedbackDataSharingConsentByUserId: req.actor.userId ?? "local-board",
feedbackDataSharingTermsVersion:
typeof body.feedbackDataSharingTermsVersion === "string" && body.feedbackDataSharingTermsVersion.length > 0
? body.feedbackDataSharingTermsVersion
: DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
};
}
}
const company = await svc.update(companyId, body);

View file

@ -9,6 +9,10 @@ import {
createIssueLabelSchema,
checkoutIssueSchema,
createIssueSchema,
feedbackTargetTypeSchema,
feedbackTraceStatusSchema,
feedbackVoteValueSchema,
upsertIssueFeedbackVoteSchema,
linkIssueApprovalSchema,
issueDocumentKeySchema,
restoreIssueDocumentRevisionSchema,
@ -22,8 +26,10 @@ import {
accessService,
agentService,
executionWorkspaceService,
feedbackService,
goalService,
heartbeatService,
instanceSettingsService,
issueApprovalService,
issueService,
documentService,
@ -49,6 +55,8 @@ export function issueRoutes(db: Db, storage: StorageService) {
const svc = issueService(db);
const access = accessService(db);
const heartbeat = heartbeatService(db);
const feedback = feedbackService(db);
const instanceSettings = instanceSettingsService(db);
const agentsSvc = agentService(db);
const projectsSvc = projectService(db);
const goalsSvc = goalService(db);
@ -69,6 +77,19 @@ export function issueRoutes(db: Db, storage: StorageService) {
};
}
function parseBooleanQuery(value: unknown) {
return value === true || value === "true" || value === "1";
}
function parseDateQuery(value: unknown, field: string) {
if (typeof value !== "string" || value.trim().length === 0) return undefined;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new HttpError(400, `Invalid ${field} query value`);
}
return parsed;
}
async function runSingleFileUpload(req: Request, res: Response) {
await new Promise<void>((resolve, reject) => {
upload.single("file")(req, res, (err: unknown) => {
@ -95,6 +116,13 @@ export function issueRoutes(db: Db, storage: StorageService) {
return false;
}
function actorCanAccessCompany(req: Request, companyId: string) {
if (req.actor.type === "none") return false;
if (req.actor.type === "agent") return req.actor.companyId === companyId;
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
return (req.actor.companyIds ?? []).includes(companyId);
}
function canCreateAgentsLegacy(agent: { permissions: Record<string, unknown> | null | undefined; role: string }) {
if (agent.role === "ceo") return true;
if (!agent.permissions || typeof agent.permissions !== "object") return false;
@ -542,6 +570,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
baseRevisionId: req.body.baseRevisionId ?? null,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
createdByRunId: actor.runId ?? null,
});
const doc = result.document;
@ -1153,6 +1182,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
comment = await svc.addComment(id, commentBody, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
});
await logActivity(db, {
@ -1462,6 +1492,86 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.json(comment);
});
router.get("/issues/:id/feedback-votes", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Only board users can view feedback votes" });
return;
}
const votes = await feedback.listIssueVotesForUser(id, req.actor.userId ?? "local-board");
res.json(votes);
});
router.get("/issues/:id/feedback-traces", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Only board users can view feedback traces" });
return;
}
const targetTypeRaw = typeof req.query.targetType === "string" ? req.query.targetType : undefined;
const voteRaw = typeof req.query.vote === "string" ? req.query.vote : undefined;
const statusRaw = typeof req.query.status === "string" ? req.query.status : undefined;
const targetType = targetTypeRaw ? feedbackTargetTypeSchema.parse(targetTypeRaw) : undefined;
const vote = voteRaw ? feedbackVoteValueSchema.parse(voteRaw) : undefined;
const status = statusRaw ? feedbackTraceStatusSchema.parse(statusRaw) : undefined;
const traces = await feedback.listFeedbackTraces({
companyId: issue.companyId,
issueId: issue.id,
targetType,
vote,
status,
from: parseDateQuery(req.query.from, "from"),
to: parseDateQuery(req.query.to, "to"),
sharedOnly: parseBooleanQuery(req.query.sharedOnly),
includePayload: parseBooleanQuery(req.query.includePayload),
});
res.json(traces);
});
router.get("/feedback-traces/:traceId", async (req, res) => {
const traceId = req.params.traceId as string;
if (req.actor.type !== "board") {
res.status(403).json({ error: "Only board users can view feedback traces" });
return;
}
const includePayload = parseBooleanQuery(req.query.includePayload) || req.query.includePayload === undefined;
const trace = await feedback.getFeedbackTraceById(traceId, includePayload);
if (!trace || !actorCanAccessCompany(req, trace.companyId)) {
res.status(404).json({ error: "Feedback trace not found" });
return;
}
res.json(trace);
});
router.get("/feedback-traces/:traceId/bundle", async (req, res) => {
const traceId = req.params.traceId as string;
if (req.actor.type !== "board") {
res.status(403).json({ error: "Only board users can view feedback trace bundles" });
return;
}
const bundle = await feedback.getFeedbackTraceBundle(traceId);
if (!bundle || !actorCanAccessCompany(req, bundle.companyId)) {
res.status(404).json({ error: "Feedback trace not found" });
return;
}
res.json(bundle);
});
router.post("/issues/:id/comments", validate(addIssueCommentSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
@ -1539,6 +1649,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
const comment = await svc.addComment(id, req.body.body, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
});
if (actor.runId) {
@ -1660,6 +1771,93 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.status(201).json(comment);
});
router.post("/issues/:id/feedback-votes", validate(upsertIssueFeedbackVoteSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Only board users can vote on AI feedback" });
return;
}
const actor = getActorInfo(req);
const result = await feedback.saveIssueVote({
issueId: id,
targetType: req.body.targetType,
targetId: req.body.targetId,
vote: req.body.vote,
reason: req.body.reason,
authorUserId: req.actor.userId ?? "local-board",
allowSharing: req.body.allowSharing === true,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.feedback_vote_saved",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
targetType: result.vote.targetType,
targetId: result.vote.targetId,
vote: result.vote.vote,
hasReason: Boolean(result.vote.reason),
sharingEnabled: result.sharingEnabled,
},
});
if (result.consentEnabledNow) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.feedback_data_sharing_updated",
entityType: "company",
entityId: issue.companyId,
details: {
feedbackDataSharingEnabled: true,
source: "issue_feedback_vote",
},
});
}
if (result.persistedSharingPreference) {
const settings = await instanceSettings.get();
const companyIds = await instanceSettings.listCompanyIds();
await Promise.all(
companyIds.map((companyId) =>
logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "instance.settings.general_updated",
entityType: "instance_settings",
entityId: settings.id,
details: {
general: settings.general,
changedKeys: ["feedbackDataSharingPreference"],
source: "issue_feedback_vote",
},
}),
),
);
}
res.status(201).json(result.vote);
});
router.get("/issues/:id/attachments", async (req, res) => {
const issueId = req.params.id as string;
const issue = await svc.getById(issueId);

View file

@ -41,6 +41,10 @@ export function companyService(db: Db) {
budgetMonthlyCents: companies.budgetMonthlyCents,
spentMonthlyCents: companies.spentMonthlyCents,
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled,
feedbackDataSharingConsentAt: companies.feedbackDataSharingConsentAt,
feedbackDataSharingConsentByUserId: companies.feedbackDataSharingConsentByUserId,
feedbackDataSharingTermsVersion: companies.feedbackDataSharingTermsVersion,
brandColor: companies.brandColor,
logoAssetId: companyLogos.assetId,
createdAt: companies.createdAt,

View file

@ -2289,7 +2289,7 @@ function buildManifestFromPackageFiles(
const skillPaths = Array.from(new Set([...referencedSkillPaths, ...discoveredSkillPaths])).sort();
const manifest: CompanyPortabilityManifest = {
schemaVersion: 4,
schemaVersion: 5,
generatedAt: new Date().toISOString(),
source: opts?.sourceLabel ?? null,
includes: {
@ -2309,6 +2309,18 @@ function buildManifestFromPackageFiles(
typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean"
? paperclipCompany.requireBoardApprovalForNewAgents
: readCompanyApprovalDefault(companyFrontmatter),
feedbackDataSharingEnabled:
typeof paperclipCompany.feedbackDataSharingEnabled === "boolean"
? paperclipCompany.feedbackDataSharingEnabled
: false,
feedbackDataSharingConsentAt:
typeof paperclipCompany.feedbackDataSharingConsentAt === "string"
? paperclipCompany.feedbackDataSharingConsentAt
: null,
feedbackDataSharingConsentByUserId:
asString(paperclipCompany.feedbackDataSharingConsentByUserId),
feedbackDataSharingTermsVersion:
asString(paperclipCompany.feedbackDataSharingTermsVersion),
},
sidebar: paperclipSidebar,
agents: [],
@ -3227,6 +3239,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
brandColor: company.brandColor ?? null,
logoPath: companyLogoPath,
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? undefined : false,
feedbackDataSharingEnabled: company.feedbackDataSharingEnabled ? true : undefined,
feedbackDataSharingConsentAt: company.feedbackDataSharingConsentAt?.toISOString() ?? null,
feedbackDataSharingConsentByUserId: company.feedbackDataSharingConsentByUserId ?? null,
feedbackDataSharingTermsVersion: company.feedbackDataSharingTermsVersion ?? null,
}),
sidebar: stripEmptyValues(sidebarOrder),
agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined,
@ -3736,6 +3752,18 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
requireBoardApprovalForNewAgents: include.company
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true)
: true,
feedbackDataSharingEnabled: include.company
? (sourceManifest.company?.feedbackDataSharingEnabled ?? false)
: false,
feedbackDataSharingConsentAt: include.company && sourceManifest.company?.feedbackDataSharingConsentAt
? new Date(sourceManifest.company.feedbackDataSharingConsentAt)
: null,
feedbackDataSharingConsentByUserId: include.company
? (sourceManifest.company?.feedbackDataSharingConsentByUserId ?? null)
: null,
feedbackDataSharingTermsVersion: include.company
? (sourceManifest.company?.feedbackDataSharingTermsVersion ?? null)
: null,
});
if (mode === "agent_safe" && options?.sourceCompanyId) {
await access.copyActiveUserMemberships(options.sourceCompanyId, created.id);
@ -3753,6 +3781,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
description: sourceManifest.company.description,
brandColor: sourceManifest.company.brandColor,
requireBoardApprovalForNewAgents: sourceManifest.company.requireBoardApprovalForNewAgents,
feedbackDataSharingEnabled: sourceManifest.company.feedbackDataSharingEnabled,
feedbackDataSharingConsentAt: sourceManifest.company.feedbackDataSharingConsentAt
? new Date(sourceManifest.company.feedbackDataSharingConsentAt)
: null,
feedbackDataSharingConsentByUserId: sourceManifest.company.feedbackDataSharingConsentByUserId,
feedbackDataSharingTermsVersion: sourceManifest.company.feedbackDataSharingTermsVersion,
});
targetCompany = updated ?? targetCompany;
companyAction = "updated";

View file

@ -171,6 +171,7 @@ export function documentService(db: Db) {
baseRevisionId?: string | null;
createdByAgentId?: string | null;
createdByUserId?: string | null;
createdByRunId?: string | null;
}) => {
const key = normalizeDocumentKey(input.key);
const issue = await db
@ -231,6 +232,7 @@ export function documentService(db: Db) {
changeSummary: input.changeSummary ?? null,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
createdByRunId: input.createdByRunId ?? null,
createdAt: now,
})
.returning();
@ -304,6 +306,7 @@ export function documentService(db: Db) {
changeSummary: input.changeSummary ?? null,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
createdByRunId: input.createdByRunId ?? null,
createdAt: now,
})
.returning();

View file

@ -0,0 +1,193 @@
import { createHash } from "node:crypto";
import { redactCurrentUserText } from "../log-redaction.js";
import { sanitizeRecord } from "../redaction.js";
export type FeedbackRedactionState = {
redactedFields: Set<string>;
truncatedFields: Set<string>;
omittedFields: Set<string>;
notes: Set<string>;
counts: Map<string, number>;
};
type PatternReplacement = string | ((match: string, ...args: string[]) => string);
type RedactionPattern = {
kind: string;
regex: RegExp;
replacement: PatternReplacement;
};
const SECRET_ASSIGNMENT_RE =
/\b(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)\s*[:=]\s*([^\s,;]+)/gi;
const FREE_TEXT_PATTERNS: RedactionPattern[] = [
{
kind: "pem_block",
regex: /-----BEGIN [^-]+-----[\s\S]+?-----END [^-]+-----/g,
replacement: "[REDACTED_PEM_BLOCK]",
},
{
kind: "secret_assignment",
regex: SECRET_ASSIGNMENT_RE,
replacement: (_match, key: string) => `${key}=[REDACTED]`,
},
{
kind: "bearer_token",
regex: /Bearer\s+[A-Za-z0-9._~+/-]+=*/gi,
replacement: "Bearer [REDACTED_TOKEN]",
},
{
kind: "github_token",
regex: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g,
replacement: "[REDACTED_GITHUB_TOKEN]",
},
{
kind: "provider_api_key",
regex: /\bsk-(?:ant-)?[A-Za-z0-9_-]{12,}\b/g,
replacement: "[REDACTED_API_KEY]",
},
{
kind: "jwt",
regex: /\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?\b/g,
replacement: "[REDACTED_JWT]",
},
{
kind: "dsn",
regex: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|kafka|nats|mssql):\/\/[^\s<>'")]+/gi,
replacement: "[REDACTED_CONNECTION_STRING]",
},
{
kind: "email",
regex: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
replacement: "[REDACTED_EMAIL]",
},
{
kind: "phone",
regex: /(?<!\w)(?:\+?\d[\d ()-]{7,}\d)(?!\w)/g,
replacement: "[REDACTED_PHONE]",
},
];
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function increment(state: FeedbackRedactionState, kind: string, count: number) {
if (count <= 0) return;
state.counts.set(kind, (state.counts.get(kind) ?? 0) + count);
}
function recordField(state: FeedbackRedactionState, fieldPath: string) {
if (fieldPath.trim().length === 0) return;
state.redactedFields.add(fieldPath);
}
function applyPattern(input: string, pattern: RedactionPattern) {
const matches = Array.from(input.matchAll(pattern.regex)).length;
if (matches === 0) {
pattern.regex.lastIndex = 0;
return { output: input, matches: 0 };
}
const output = input.replace(pattern.regex, pattern.replacement as never);
pattern.regex.lastIndex = 0;
return { output, matches };
}
export function createFeedbackRedactionState(): FeedbackRedactionState {
return {
redactedFields: new Set<string>(),
truncatedFields: new Set<string>(),
omittedFields: new Set<string>(),
notes: new Set<string>(),
counts: new Map<string, number>(),
};
}
export function sanitizeFeedbackText(
input: string,
state: FeedbackRedactionState,
fieldPath: string,
maxLength: number,
) {
let output = redactCurrentUserText(input);
if (output !== input) {
recordField(state, fieldPath);
increment(state, "current_user", 1);
}
for (const pattern of FREE_TEXT_PATTERNS) {
const result = applyPattern(output, pattern);
if (result.matches > 0) {
output = result.output;
recordField(state, fieldPath);
increment(state, pattern.kind, result.matches);
}
}
if (output.length > maxLength) {
output = `${output.slice(0, Math.max(0, maxLength - 1))}...`;
state.truncatedFields.add(fieldPath);
}
return output;
}
export function sanitizeFeedbackValue(
value: unknown,
state: FeedbackRedactionState,
fieldPath: string,
maxStringLength: number,
): unknown {
if (typeof value === "string") {
return sanitizeFeedbackText(value, state, fieldPath, maxStringLength);
}
if (Array.isArray(value)) {
return value.map((entry, index) =>
sanitizeFeedbackValue(entry, state, `${fieldPath}[${index}]`, maxStringLength));
}
if (!isPlainRecord(value)) {
return value;
}
const structurallySanitized = sanitizeRecord(value);
if (stableStringify(structurallySanitized) !== stableStringify(value)) {
recordField(state, fieldPath);
increment(state, "structured_secret", 1);
}
const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(structurallySanitized)) {
output[key] = sanitizeFeedbackValue(entry, state, `${fieldPath}.${key}`, maxStringLength);
}
return output;
}
export function finalizeFeedbackRedactionSummary(state: FeedbackRedactionState) {
return {
strategy: "deterministic_feedback_v2",
redactedFields: Array.from(state.redactedFields).sort(),
truncatedFields: Array.from(state.truncatedFields).sort(),
omittedFields: Array.from(state.omittedFields).sort(),
notes: Array.from(state.notes).sort(),
counts: Object.fromEntries(Array.from(state.counts.entries()).sort(([left], [right]) => left.localeCompare(right))),
} satisfies Record<string, unknown>;
}
export function stableStringify(value: unknown): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
}
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`);
return `{${entries.join(",")}}`;
}
export function sha256Digest(value: unknown) {
return createHash("sha256").update(stableStringify(value)).digest("hex");
}

View file

@ -0,0 +1,54 @@
import type { FeedbackTraceBundle } from "@paperclipai/shared";
import type { Config } from "../config.js";
function buildFeedbackShareObjectKey(bundle: FeedbackTraceBundle, exportedAt: Date) {
const year = String(exportedAt.getUTCFullYear());
const month = String(exportedAt.getUTCMonth() + 1).padStart(2, "0");
const day = String(exportedAt.getUTCDate()).padStart(2, "0");
return `feedback-traces/${bundle.companyId}/${year}/${month}/${day}/${bundle.exportId ?? bundle.traceId}.json`;
}
export interface FeedbackTraceShareClient {
uploadTraceBundle(bundle: FeedbackTraceBundle): Promise<{ objectKey: string }>;
}
export function createFeedbackTraceShareClientFromConfig(
config: Pick<Config, "feedbackExportBackendUrl" | "feedbackExportBackendToken">,
): FeedbackTraceShareClient | null {
const baseUrl = config.feedbackExportBackendUrl?.trim();
if (!baseUrl) return null;
const token = config.feedbackExportBackendToken?.trim();
const endpoint = new URL("/feedback-traces", baseUrl).toString();
return {
async uploadTraceBundle(bundle) {
const exportedAt = new Date();
const objectKey = buildFeedbackShareObjectKey(bundle, exportedAt);
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
objectKey,
exportedAt: exportedAt.toISOString(),
bundle,
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(detail.trim() || `Feedback trace upload failed with HTTP ${response.status}`);
}
const payload = await response.json().catch(() => null) as { objectKey?: unknown } | null;
return {
objectKey: typeof payload?.objectKey === "string" && payload.objectKey.trim().length > 0
? payload.objectKey
: objectKey,
};
},
};
}

File diff suppressed because it is too large Load diff

View file

@ -2615,7 +2615,7 @@ export function heartbeatService(db: Db) {
workspace: executionWorkspace,
runtimeServices,
}),
{ agentId: agent.id },
{ agentId: agent.id, runId: run.id },
);
} catch (err) {
await onLog(
@ -2705,7 +2705,7 @@ export function heartbeatService(db: Db) {
workspace: executionWorkspace,
runtimeServices: adapterManagedRuntimeServices,
}),
{ agentId: agent.id },
{ agentId: agent.id, runId: run.id },
);
} catch (err) {
await onLog(

View file

@ -1,4 +1,5 @@
export { companyService } from "./companies.js";
export { feedbackService } from "./feedback.js";
export { companySkillService } from "./company-skills.js";
export { agentService, deduplicateAgentName } from "./agents.js";
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";

View file

@ -1,6 +1,7 @@
import type { Db } from "@paperclipai/db";
import { companies, instanceSettings } from "@paperclipai/db";
import {
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
instanceGeneralSettingsSchema,
type InstanceGeneralSettings,
instanceExperimentalSettingsSchema,
@ -18,10 +19,13 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
if (parsed.success) {
return {
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
feedbackDataSharingPreference:
parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
};
}
return {
censorUsernameInLogs: false,
feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
};
}

View file

@ -21,7 +21,7 @@ import {
projectWorkspaces,
projects,
} from "@paperclipai/db";
import { extractAgentMentionIds, extractProjectMentionIds } from "@paperclipai/shared";
import { extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import {
defaultIssueExecutionWorkspaceSettingsForProject,
@ -467,6 +467,28 @@ function withActiveRuns(
export function issueService(db: Db) {
const instanceSettings = instanceSettingsService(db);
async function getIssueByUuid(id: string) {
const row = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
}
async function getIssueByIdentifier(identifier: string) {
const row = await db
.select()
.from(issues)
.where(eq(issues.identifier, identifier.toUpperCase()))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
}
function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
...comment,
@ -883,26 +905,19 @@ export function issueService(db: Db) {
return row ?? null;
},
getById: async (id: string) => {
const row = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
getById: async (raw: string) => {
const id = raw.trim();
if (/^[A-Z]+-\d+$/i.test(id)) {
return getIssueByIdentifier(id);
}
if (!isUuidLike(id)) {
return null;
}
return getIssueByUuid(id);
},
getByIdentifier: async (identifier: string) => {
const row = await db
.select()
.from(issues)
.where(eq(issues.identifier, identifier.toUpperCase()))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
return getIssueByIdentifier(identifier);
},
create: async (
@ -1542,7 +1557,11 @@ export function issueService(db: Db) {
return comment ? redactIssueComment(comment, censorUsernameInLogs) : null;
})),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
addComment: async (
issueId: string,
body: string,
actor: { agentId?: string; userId?: string; runId?: string | null },
) => {
const issue = await db
.select({ companyId: issues.companyId })
.from(issues)
@ -1562,6 +1581,7 @@ export function issueService(db: Db) {
issueId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
body: redactedBody,
})
.returning();

View file

@ -28,7 +28,14 @@ export const companiesApi = {
data: Partial<
Pick<
Company,
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor" | "logoAssetId"
| "name"
| "description"
| "status"
| "budgetMonthlyCents"
| "requireBoardApprovalForNewAgents"
| "feedbackDataSharingEnabled"
| "brandColor"
| "logoAssetId"
>
>,
) => api.patch<Company>(`/companies/${companyId}`, data),

View file

@ -1,6 +1,9 @@
import type {
Approval,
DocumentRevision,
FeedbackTargetType,
FeedbackTrace,
FeedbackVote,
Issue,
IssueAttachment,
IssueComment,
@ -76,6 +79,26 @@ export const issuesApi = {
}),
release: (id: string) => api.post<Issue>(`/issues/${id}/release`, {}),
listComments: (id: string) => api.get<IssueComment[]>(`/issues/${id}/comments`),
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters ?? {})) {
if (value === undefined) continue;
params.set(key, String(value));
}
const qs = params.toString();
return api.get<FeedbackTrace[]>(`/issues/${id}/feedback-traces${qs ? `?${qs}` : ""}`);
},
upsertFeedbackVote: (
id: string,
data: {
targetType: FeedbackTargetType;
targetId: string;
vote: "up" | "down";
reason?: string;
allowSharing?: boolean;
},
) => api.post<FeedbackVote>(`/issues/${id}/feedback-votes`, data),
addComment: (id: string, body: string, reopen?: boolean, interrupt?: boolean) =>
api.post<IssueComment>(
`/issues/${id}/comments`,

View file

@ -1,12 +1,19 @@
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { Link, useLocation } from "react-router-dom";
import type { IssueComment, Agent } from "@paperclipai/shared";
import type {
Agent,
FeedbackDataSharingPreference,
FeedbackVote,
FeedbackVoteValue,
IssueComment,
} from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Check, Copy, Paperclip } from "lucide-react";
import { Identity } from "./Identity";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { StatusBadge } from "./StatusBadge";
import { AgentIcon } from "./AgentIconPicker";
import { formatDateTime } from "../lib/utils";
@ -38,9 +45,17 @@ interface CommentReassignment {
interface CommentThreadProps {
comments: CommentWithRunMeta[];
queuedComments?: CommentWithRunMeta[];
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
linkedRuns?: LinkedRunItem[];
companyId?: string | null;
projectId?: string | null;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
issueStatus?: string;
agentMap?: Map<string, Agent>;
@ -127,6 +142,11 @@ function CommentCard({
agentMap,
companyId,
projectId,
feedbackVote = null,
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
onVote,
voting = false,
highlightCommentId,
queued = false,
}: {
@ -134,6 +154,14 @@ function CommentCard({
agentMap?: Map<string, Agent>;
companyId?: string | null;
projectId?: string | null;
feedbackVote?: FeedbackVoteValue | null;
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
onVote?: (
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
voting?: boolean;
highlightCommentId?: string | null;
queued?: boolean;
}) {
@ -218,6 +246,15 @@ function CommentCard({
/>
</div>
) : null}
{comment.authorAgentId && onVote && !isQueued && !isPending ? (
<OutputFeedbackButtons
activeVote={feedbackVote}
disabled={voting}
sharingPreference={feedbackDataSharingPreference}
termsUrl={feedbackTermsUrl}
onVote={onVote}
/>
) : null}
{comment.runId && !isPending ? (
<div className="mt-2 pt-2 border-t border-border/60">
{comment.runAgentId ? (
@ -247,12 +284,26 @@ const TimelineList = memo(function TimelineList({
agentMap,
companyId,
projectId,
feedbackVoteByTargetId,
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
onVote,
votingTargetId,
highlightCommentId,
}: {
timeline: TimelineItem[];
agentMap?: Map<string, Agent>;
companyId?: string | null;
projectId?: string | null;
feedbackVoteByTargetId?: Map<string, FeedbackVoteValue>;
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
votingTargetId?: string | null;
highlightCommentId?: string | null;
}) {
if (timeline.length === 0) {
@ -299,6 +350,11 @@ const TimelineList = memo(function TimelineList({
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
feedbackVote={feedbackVoteByTargetId?.get(comment.id) ?? null}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={feedbackTermsUrl}
onVote={onVote ? (vote, options) => onVote(comment.id, vote, options) : undefined}
voting={votingTargetId === comment.id}
highlightCommentId={highlightCommentId}
/>
);
@ -310,9 +366,13 @@ const TimelineList = memo(function TimelineList({
export function CommentThread({
comments,
queuedComments = [],
feedbackVotes = [],
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
linkedRuns = [],
companyId,
projectId,
onVote,
onAdd,
agentMap,
imageUploadHandler,
@ -334,6 +394,7 @@ export function CommentThread({
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
const [votingTargetId, setVotingTargetId] = useState<string | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -360,6 +421,15 @@ export function CommentThread({
});
}, [comments, linkedRuns]);
const feedbackVoteByTargetId = useMemo(() => {
const map = new Map<string, FeedbackVoteValue>();
for (const feedbackVote of feedbackVotes) {
if (feedbackVote.targetType !== "issue_comment") continue;
map.set(feedbackVote.targetId, feedbackVote.vote);
}
return map;
}, [feedbackVotes]);
// Build mention options from agent map (exclude terminated agents)
const mentions = useMemo<MentionOption[]>(() => {
if (providedMentions) return providedMentions;
@ -463,21 +533,38 @@ export function CommentThread({
}
}
async function handleFeedbackVote(
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) {
if (!onVote) return;
setVotingTargetId(commentId);
try {
await onVote(commentId, vote, options);
} finally {
setVotingTargetId(null);
}
}
const canSubmit = !submitting && !!body.trim();
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold">Comments &amp; Runs ({timeline.length + queuedComments.length})</h3>
{timeline.length > 0 ? (
<TimelineList
timeline={timeline}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
/>
) : null}
<TimelineList
timeline={timeline}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
feedbackVoteByTargetId={feedbackVoteByTargetId}
feedbackDataSharingPreference={feedbackDataSharingPreference}
onVote={onVote ? handleFeedbackVote : undefined}
votingTargetId={votingTargetId}
highlightCommentId={highlightCommentId}
feedbackTermsUrl={feedbackTermsUrl}
/>
{liveRunSlot}
@ -599,6 +686,7 @@ export function CommentThread({
</Button>
</div>
</div>
</div>
);
}

View file

@ -1,6 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { DocumentRevision, Issue, IssueDocument } from "@paperclipai/shared";
import type {
DocumentRevision,
FeedbackDataSharingPreference,
FeedbackVote,
FeedbackVoteValue,
Issue,
IssueDocument,
} from "@paperclipai/shared";
import { useLocation } from "@/lib/router";
import { ApiError } from "../api/client";
import { issuesApi } from "../api/issues";
@ -9,6 +16,7 @@ import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@ -101,14 +109,26 @@ function documentHasUnsavedChanges(doc: IssueDocument, draft: DraftState | null)
export function IssueDocumentsSection({
issue,
canDeleteDocuments,
feedbackVotes = [],
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
mentions,
imageUploadHandler,
onVote,
extraActions,
}: {
issue: Issue;
canDeleteDocuments: boolean;
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
mentions?: MentionOption[];
imageUploadHandler?: (file: File) => Promise<string>;
onVote?: (
revisionId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
extraActions?: ReactNode;
}) {
const queryClient = useQueryClient();
@ -207,6 +227,15 @@ export function IssueDocumentsSection({
});
}, [documents]);
const feedbackVoteByTargetId = useMemo(() => {
const map = new Map<string, FeedbackVoteValue>();
for (const feedbackVote of feedbackVotes) {
if (feedbackVote.targetType !== "issue_document_revision") continue;
map.set(feedbackVote.targetId, feedbackVote.vote);
}
return map;
}, [feedbackVotes]);
const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan");
const isEmpty = sortedDocuments.length === 0 && !issue.legacyPlanDocument;
const newDocumentKeyError =
@ -718,6 +747,7 @@ export function IssueDocumentsSection({
const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? doc.latestRevisionNumber;
const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? doc.updatedAt;
const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key);
const canVoteOnDocument = Boolean(doc.latestRevisionId && doc.updatedByAgentId && !doc.updatedByUserId && onVote);
return (
<div
@ -1053,6 +1083,16 @@ export function IssueDocumentsSection({
: ""}
</span>
</div>
{canVoteOnDocument && doc.latestRevisionId ? (
<OutputFeedbackButtons
activeVote={feedbackVoteByTargetId.get(doc.latestRevisionId) ?? null}
sharingPreference={feedbackDataSharingPreference}
termsUrl={feedbackTermsUrl}
onVote={(vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }) =>
onVote?.(doc.latestRevisionId!, vote, options) ?? Promise.resolve()
}
/>
) : null}
</div>
) : null}

View file

@ -0,0 +1,259 @@
import { useEffect, useState } from "react";
import type { FeedbackDataSharingPreference, FeedbackVoteValue } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ThumbsDown, ThumbsUp } from "lucide-react";
import { cn } from "../lib/utils";
export function OutputFeedbackButtons({
activeVote,
disabled = false,
sharingPreference = "prompt",
termsUrl = null,
onVote,
}: {
activeVote?: FeedbackVoteValue | null;
disabled?: boolean;
sharingPreference?: FeedbackDataSharingPreference;
termsUrl?: string | null;
onVote: (vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }) => Promise<void>;
}) {
const [pendingVote, setPendingVote] = useState<{
vote: FeedbackVoteValue;
reason?: string;
keepReasonPromptOpen?: boolean;
} | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [downvoteReason, setDownvoteReason] = useState("");
const [collectingDownvoteReason, setCollectingDownvoteReason] = useState(false);
const [downvoteAllowSharing, setDownvoteAllowSharing] = useState<boolean | undefined>(undefined);
const [optimisticVote, setOptimisticVote] = useState<FeedbackVoteValue | null>(null);
const visibleVote = optimisticVote ?? activeVote ?? null;
useEffect(() => {
if (optimisticVote && activeVote === optimisticVote) {
setOptimisticVote(null);
}
}, [activeVote, optimisticVote]);
async function submitVote(
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
behavior?: { keepReasonPromptOpen?: boolean },
) {
setIsSaving(true);
try {
await onVote(vote, options);
setPendingVote(null);
if (!behavior?.keepReasonPromptOpen) {
setCollectingDownvoteReason(false);
setDownvoteReason("");
setDownvoteAllowSharing(undefined);
}
} catch (error) {
setOptimisticVote(null);
throw error;
} finally {
setIsSaving(false);
}
}
function beginVote(
vote: FeedbackVoteValue,
reason?: string,
behavior?: { keepReasonPromptOpen?: boolean },
) {
if (sharingPreference === "prompt") {
setPendingVote({
vote,
...(reason ? { reason } : {}),
...(behavior?.keepReasonPromptOpen ? { keepReasonPromptOpen: true } : {}),
});
return;
}
const allowSharing = sharingPreference === "allowed";
if (vote === "down") {
setDownvoteAllowSharing(allowSharing);
}
void submitVote(
vote,
{
...(allowSharing ? { allowSharing: true } : {}),
...(reason ? { reason } : {}),
},
behavior,
);
}
function handleVote(vote: FeedbackVoteValue) {
setOptimisticVote(vote);
if (vote === "down") {
setCollectingDownvoteReason(true);
setDownvoteReason("");
setDownvoteAllowSharing(undefined);
void beginVote("down", undefined, { keepReasonPromptOpen: true });
return;
}
void beginVote(vote);
}
return (
<>
<div className="mt-3 flex items-center gap-2 border-t border-border/60 pt-3">
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || isSaving}
className={cn(visibleVote === "up" && "border-green-600/50 bg-green-500/10 text-green-700")}
onClick={() => handleVote("up")}
>
<ThumbsUp className="mr-1.5 h-3.5 w-3.5" />
Helpful
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || isSaving}
className={cn(visibleVote === "down" && "border-amber-600/50 bg-amber-500/10 text-amber-800")}
onClick={() => handleVote("down")}
>
<ThumbsDown className="mr-1.5 h-3.5 w-3.5" />
Needs work
</Button>
</div>
{collectingDownvoteReason ? (
<div className="mt-2 rounded-md border border-border/60 bg-accent/20 p-3">
<div className="mb-2 text-sm font-medium">What could have been better?</div>
<Textarea
value={downvoteReason}
onChange={(event) => setDownvoteReason(event.target.value)}
placeholder="Add a short note"
className="min-h-20 resize-y bg-background"
disabled={disabled || isSaving}
/>
<div className="mt-3 flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || isSaving}
onClick={() => {
setCollectingDownvoteReason(false);
setDownvoteReason("");
setDownvoteAllowSharing(undefined);
}}
>
Dismiss
</Button>
<Button
type="button"
size="sm"
disabled={disabled || isSaving || !downvoteReason.trim()}
onClick={() => {
void submitVote("down", {
...(downvoteAllowSharing ? { allowSharing: true } : {}),
reason: downvoteReason,
});
}}
>
{isSaving ? "Saving..." : "Save note"}
</Button>
</div>
</div>
) : null}
<Dialog
open={Boolean(pendingVote)}
onOpenChange={(open) => {
if (!open && !isSaving) {
setPendingVote(null);
setOptimisticVote(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Save your feedback sharing preference</DialogTitle>
<DialogDescription>
Choose whether voted AI outputs can be shared with Paperclip Labs. This
answer becomes the default for future thumbs up and thumbs down votes.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
This vote is always saved locally.
</p>
<p>
Choose <span className="font-medium text-foreground">Always allow</span> to share
this vote and future voted AI outputs. Choose{" "}
<span className="font-medium text-foreground">Don't allow</span> to keep this vote
and future votes local.
</p>
<p>
You can change this later in Instance Settings &gt; General.
</p>
{termsUrl ? (
<a
href={termsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex text-sm text-foreground underline underline-offset-4"
>
Read our terms of service
</a>
) : null}
</div>
<DialogFooter>
<Button
type="button"
disabled={!pendingVote || isSaving}
onClick={() => {
if (!pendingVote) return;
if (pendingVote.vote === "down") {
setDownvoteAllowSharing(false);
}
void submitVote(
pendingVote.vote,
pendingVote.reason ? { reason: pendingVote.reason } : undefined,
{ keepReasonPromptOpen: pendingVote.keepReasonPromptOpen },
);
}}
>
{isSaving ? "Saving..." : "Don't allow"}
</Button>
<Button
type="button"
disabled={!pendingVote || isSaving}
onClick={() => {
if (!pendingVote) return;
if (pendingVote.vote === "down") {
setDownvoteAllowSharing(true);
}
void submitVote(
pendingVote.vote,
{
allowSharing: true,
...(pendingVote.reason ? { reason: pendingVote.reason } : {}),
},
{ keepReasonPromptOpen: pendingVote.keepReasonPromptOpen },
);
}}
>
{isSaving ? "Saving..." : "Always allow"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -43,6 +43,7 @@ export const queryKeys = {
["issues", companyId, "execution-workspace", executionWorkspaceId] as const,
detail: (id: string) => ["issues", "detail", id] as const,
comments: (issueId: string) => ["issues", "comments", issueId] as const,
feedbackVotes: (issueId: string) => ["issues", "feedback-votes", issueId] as const,
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
documents: (issueId: string) => ["issues", "documents", issueId] as const,
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,

View file

@ -1,5 +1,6 @@
import { ChangeEvent, useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION } from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
@ -22,6 +23,8 @@ type AgentSnippetInput = {
testResolutionUrl?: string | null;
};
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
export function CompanySettings() {
const {
companies,
@ -80,6 +83,27 @@ export function CompanySettings() {
}
});
const feedbackSharingMutation = useMutation({
mutationFn: (enabled: boolean) =>
companiesApi.update(selectedCompanyId!, {
feedbackDataSharingEnabled: enabled,
}),
onSuccess: (_company, enabled) => {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
pushToast({
title: enabled ? "Feedback sharing enabled" : "Feedback sharing disabled",
tone: "success",
});
},
onError: (err) => {
pushToast({
title: "Failed to update feedback sharing",
body: err instanceof Error ? err.message : "Unknown error",
tone: "error",
});
},
});
const inviteMutation = useMutation({
mutationFn: () =>
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
@ -392,6 +416,48 @@ export function CompanySettings() {
</div>
</div>
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Feedback Sharing
</div>
<div className="space-y-3 rounded-md border border-border px-4 py-4">
<ToggleField
label="Allow sharing voted AI outputs with Paperclip Labs"
hint="Only AI-generated outputs you explicitly vote on are eligible for feedback sharing."
checked={!!selectedCompany.feedbackDataSharingEnabled}
onChange={(enabled) => feedbackSharingMutation.mutate(enabled)}
/>
<p className="text-sm text-muted-foreground">
Votes are always saved locally. This setting controls whether voted AI outputs may also be marked for sharing with Paperclip Labs.
</p>
<div className="space-y-1 text-xs text-muted-foreground">
<div>
Terms version: {selectedCompany.feedbackDataSharingTermsVersion ?? DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION}
</div>
{selectedCompany.feedbackDataSharingConsentAt ? (
<div>
Enabled {new Date(selectedCompany.feedbackDataSharingConsentAt).toLocaleString()}
{selectedCompany.feedbackDataSharingConsentByUserId
? ` by ${selectedCompany.feedbackDataSharingConsentByUserId}`
: ""}
</div>
) : (
<div>Sharing is currently disabled.</div>
)}
{FEEDBACK_TERMS_URL ? (
<a
href={FEEDBACK_TERMS_URL}
target="_blank"
rel="noreferrer"
className="inline-flex text-foreground underline underline-offset-4"
>
Read our terms of service
</a>
) : null}
</div>
</div>
</div>
{/* Invites */}
<div className="space-y-4" data-testid="company-settings-invites-section">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">

View file

@ -6,6 +6,8 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
export function InstanceGeneralSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
@ -23,9 +25,8 @@ export function InstanceGeneralSettings() {
queryFn: () => instanceSettingsApi.getGeneral(),
});
const toggleMutation = useMutation({
mutationFn: async (enabled: boolean) =>
instanceSettingsApi.updateGeneral({ censorUsernameInLogs: enabled }),
const updateGeneralMutation = useMutation({
mutationFn: instanceSettingsApi.updateGeneral,
onSuccess: async () => {
setActionError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings });
@ -50,6 +51,7 @@ export function InstanceGeneralSettings() {
}
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
return (
<div className="max-w-4xl space-y-6">
@ -83,12 +85,16 @@ export function InstanceGeneralSettings() {
type="button"
data-slot="toggle"
aria-label="Toggle username log censoring"
disabled={toggleMutation.isPending}
disabled={updateGeneralMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
censorUsernameInLogs ? "bg-green-600" : "bg-muted",
)}
onClick={() => toggleMutation.mutate(!censorUsernameInLogs)}
onClick={() =>
updateGeneralMutation.mutate({
censorUsernameInLogs: !censorUsernameInLogs,
})
}
>
<span
className={cn(
@ -99,6 +105,82 @@ export function InstanceGeneralSettings() {
</button>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="space-y-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">AI feedback sharing</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Control whether thumbs up and thumbs down votes can send the voted AI output to
Paperclip Labs. Votes are always saved locally.
</p>
{FEEDBACK_TERMS_URL ? (
<a
href={FEEDBACK_TERMS_URL}
target="_blank"
rel="noreferrer"
className="inline-flex text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Read our terms of service
</a>
) : null}
</div>
{feedbackDataSharingPreference === "prompt" ? (
<div className="rounded-lg border border-border/70 bg-accent/20 px-3 py-2 text-sm text-muted-foreground">
No default is saved yet. The next thumbs up or thumbs down choice will ask once and
then save the answer here.
</div>
) : null}
<div className="flex flex-wrap gap-2">
{[
{
value: "allowed",
label: "Always allow",
description: "Share voted AI outputs automatically.",
},
{
value: "not_allowed",
label: "Don't allow",
description: "Keep voted AI outputs local only.",
},
].map((option) => {
const active = feedbackDataSharingPreference === option.value;
return (
<button
key={option.value}
type="button"
disabled={updateGeneralMutation.isPending}
className={cn(
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
active
? "border-foreground bg-accent text-foreground"
: "border-border bg-background hover:bg-accent/50",
)}
onClick={() =>
updateGeneralMutation.mutate({
feedbackDataSharingPreference: option.value as
| "allowed"
| "not_allowed",
})
}
>
<div className="text-sm font-medium">{option.label}</div>
<div className="text-xs text-muted-foreground">
{option.description}
</div>
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
To retest the first-use prompt in local dev, remove the{" "}
<code>feedbackDataSharingPreference</code> key from the{" "}
<code>instance_settings.general</code> JSON row for this instance, or set it back to{" "}
<code>"prompt"</code>. Unset and <code>"prompt"</code> both mean no default has been
chosen yet.
</p>
</div>
</section>
</div>
);
}

View file

@ -7,6 +7,7 @@ import { activityApi } from "../api/activity";
import { heartbeatsApi } from "../api/heartbeats";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { usePanel } from "../context/PanelContext";
@ -64,7 +65,7 @@ import {
Trash2,
} from "lucide-react";
import type { ActivityEvent } from "@paperclipai/shared";
import type { Agent, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
import type { Agent, FeedbackVote, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
type CommentReassignment = IssueCommentReassignment;
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
@ -81,6 +82,7 @@ const ACTION_LABELS: Record<string, string> = {
"issue.checked_out": "checked out the issue",
"issue.released": "released the issue",
"issue.comment_added": "added a comment",
"issue.feedback_vote_saved": "saved feedback on an AI output",
"issue.attachment_added": "added an attachment",
"issue.attachment_removed": "removed an attachment",
"issue.document_created": "created a document",
@ -99,6 +101,8 @@ const ACTION_LABELS: Record<string, string> = {
"approval.rejected": "rejected",
};
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
function humanizeValue(value: unknown): string {
if (typeof value !== "string") return String(value ?? "none");
return value.replace(/_/g, " ");
@ -197,6 +201,63 @@ function formatAction(action: string, details?: Record<string, unknown> | null):
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
}
function mergeOptimisticFeedbackVote(
previousVotes: FeedbackVote[] | undefined,
nextVote: {
issueId: string;
targetType: "issue_comment" | "issue_document_revision";
targetId: string;
vote: "up" | "down";
reason?: string;
},
currentUserId: string | null,
): FeedbackVote[] {
const now = new Date();
const existingVotes = previousVotes ?? [];
const existingIndex = existingVotes.findIndex(
(feedbackVote) =>
feedbackVote.targetType === nextVote.targetType &&
feedbackVote.targetId === nextVote.targetId &&
(!currentUserId || feedbackVote.authorUserId === currentUserId),
);
if (existingIndex >= 0) {
const existingVote = existingVotes[existingIndex]!;
const updatedVote: FeedbackVote = {
...existingVote,
vote: nextVote.vote,
reason:
nextVote.reason !== undefined
? nextVote.reason.trim() || null
: existingVote.reason,
updatedAt: now,
};
const nextVotes = [...existingVotes];
nextVotes[existingIndex] = updatedVote;
return nextVotes;
}
return [
...existingVotes,
{
id: `optimistic:${nextVote.targetType}:${nextVote.targetId}`,
companyId: "",
issueId: nextVote.issueId,
targetType: nextVote.targetType,
targetId: nextVote.targetId,
authorUserId: currentUserId ?? "current-user",
vote: nextVote.vote,
reason: nextVote.reason?.trim() || null,
sharedWithLabs: false,
sharedAt: null,
consentVersion: null,
redactionSummary: null,
createdAt: now,
updatedAt: now,
},
];
}
function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<string, Agent> }) {
const id = evt.actorId;
if (evt.actorType === "agent") {
@ -210,7 +271,7 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<st
export function IssueDetail() {
const { issueId } = useParams<{ issueId: string }>();
const { selectedCompanyId } = useCompany();
const { selectedCompanyId, selectedCompany } = useCompany();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
@ -328,6 +389,18 @@ export function IssueDetail() {
enabled: !!selectedCompanyId,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const { data: feedbackVotes } = useQuery({
queryKey: queryKeys.issues.feedbackVotes(issueId!),
queryFn: () => issuesApi.listFeedbackVotes(issueId!),
enabled: !!issueId && !!currentUserId,
});
const { data: instanceGeneralSettings } = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
enabled: !!issueId,
retry: false,
});
const feedbackDataSharingPreference = instanceGeneralSettings?.feedbackDataSharingPreference ?? "prompt";
const { orderedProjects } = useProjectOrder({
projects: projects ?? [],
companyId: selectedCompanyId,
@ -517,6 +590,7 @@ export function IssueDetail() {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
@ -723,6 +797,71 @@ export function IssueDetail() {
},
});
const feedbackVoteMutation = useMutation({
mutationFn: (variables: {
targetType: "issue_comment" | "issue_document_revision";
targetId: string;
vote: "up" | "down";
reason?: string;
allowSharing?: boolean;
sharingPreferenceAtSubmit: "allowed" | "not_allowed" | "prompt";
}) =>
issuesApi.upsertFeedbackVote(issueId!, {
targetType: variables.targetType,
targetId: variables.targetId,
vote: variables.vote,
...(variables.reason ? { reason: variables.reason } : {}),
...(variables.allowSharing ? { allowSharing: true } : {}),
}),
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId!) });
const previousVotes = queryClient.getQueryData<FeedbackVote[]>(
queryKeys.issues.feedbackVotes(issueId!),
);
queryClient.setQueryData<FeedbackVote[]>(
queryKeys.issues.feedbackVotes(issueId!),
mergeOptimisticFeedbackVote(
previousVotes,
{
issueId: issueId!,
targetType: variables.targetType,
targetId: variables.targetId,
vote: variables.vote,
reason: variables.reason,
},
currentUserId,
),
);
return { previousVotes };
},
onSuccess: (_savedVote, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings });
pushToast({
title:
variables.sharingPreferenceAtSubmit === "prompt"
? variables.allowSharing
? "Feedback saved. Future votes will share"
: "Feedback saved. Future votes will stay local"
: variables.allowSharing
? "Feedback saved and sharing enabled"
: "Feedback saved",
tone: "success",
});
},
onError: (err, _variables, context) => {
if (context?.previousVotes) {
queryClient.setQueryData(queryKeys.issues.feedbackVotes(issueId!), context.previousVotes);
}
pushToast({
title: "Failed to save feedback",
body: err instanceof Error ? err.message : "Unknown error",
tone: "error",
});
},
});
const uploadAttachment = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected");
@ -1123,11 +1262,24 @@ export function IssueDetail() {
<IssueDocumentsSection
issue={issue}
canDeleteDocuments={Boolean(session?.user?.id)}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL}
mentions={mentionOptions}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
onVote={async (revisionId, vote, options) => {
await feedbackVoteMutation.mutateAsync({
targetType: "issue_document_revision",
targetId: revisionId,
vote,
reason: options?.reason,
allowSharing: options?.allowSharing,
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
});
}}
extraActions={!hasAttachments ? attachmentUploadButton : undefined}
/>
@ -1234,6 +1386,9 @@ export function IssueDetail() {
<CommentThread
comments={timelineComments}
queuedComments={queuedComments}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL}
linkedRuns={timelineRuns}
companyId={issue.companyId}
projectId={issue.projectId}
@ -1249,6 +1404,16 @@ export function IssueDetail() {
await interruptQueuedComment.mutateAsync(runId);
}}
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
onVote={async (commentId, vote, options) => {
await feedbackVoteMutation.mutateAsync({
targetType: "issue_comment",
targetId: commentId,
vote,
reason: options?.reason,
allowSharing: options?.allowSharing,
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
});
}}
onAdd={async (body, reopen, reassignment) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });

1
ui/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />