Add feedback voting and thumbs capture flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
3db6bdfc3c
commit
c0d0d03bce
66 changed files with 18988 additions and 78 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
177
cli/src/__tests__/feedback.test.ts
Normal file
177
cli/src/__tests__/feedback.test.ts
Normal 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]}`,
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import * as p from "@clack/prompts";
|
|||
import pc from "picocolors";
|
||||
import type {
|
||||
Company,
|
||||
FeedbackTrace,
|
||||
CompanyPortabilityFileEntry,
|
||||
CompanyPortabilityExportResult,
|
||||
CompanyPortabilityInclude,
|
||||
|
|
@ -44,6 +45,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;
|
||||
|
|
@ -165,6 +180,34 @@ function parseCsvValues(input: string | undefined): string[] {
|
|||
return Array.from(new Set(input.split(",").map((part) => part.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function buildFeedbackTraceQuery(opts: CompanyFeedbackOptions): 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 (opts.includePayload) params.set("includePayload", "true");
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
|
||||
function normalizeFeedbackExportFormat(value: string | undefined): "json" | "ndjson" {
|
||||
if (!value || value === "ndjson") return "ndjson";
|
||||
if (value === "json") return "json";
|
||||
throw new Error(`Unsupported export format: ${value}`);
|
||||
}
|
||||
|
||||
function serializeFeedbackTraces(traces: FeedbackTrace[], format: string | undefined): string {
|
||||
if (normalizeFeedbackExportFormat(format) === "json") {
|
||||
return JSON.stringify(traces, null, 2);
|
||||
}
|
||||
return traces.map((trace) => JSON.stringify(trace)).join("\n");
|
||||
}
|
||||
|
||||
function isInteractiveTerminal(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
}
|
||||
|
|
@ -1103,6 +1146,94 @@ 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,
|
||||
includePayload: 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: normalizeFeedbackExportFormat(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")
|
||||
|
|
|
|||
621
cli/src/commands/client/feedback.ts
Normal file
621
cli/src/commands/client/feedback.ts
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
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;
|
||||
}
|
||||
|
||||
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: FeedbackFilterOptions, 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 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -61,6 +63,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 +251,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,
|
||||
includePayload: 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: normalizeExportFormat(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")
|
||||
|
|
@ -311,3 +404,29 @@ function filterIssueRows(rows: Issue[], match: string | undefined): Issue[] {
|
|||
return text.includes(needle);
|
||||
});
|
||||
}
|
||||
|
||||
function buildFeedbackTraceQuery(opts: IssueFeedbackOptions): 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.from) params.set("from", opts.from);
|
||||
if (opts.to) params.set("to", opts.to);
|
||||
if (opts.sharedOnly) params.set("sharedOnly", "true");
|
||||
if (opts.includePayload) params.set("includePayload", "true");
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
|
||||
function normalizeExportFormat(value: string | undefined): "json" | "ndjson" {
|
||||
if (!value || value === "ndjson") return "ndjson";
|
||||
if (value === "json") return "json";
|
||||
throw new Error(`Unsupported export format: ${value}`);
|
||||
}
|
||||
|
||||
function serializeFeedbackTraces(traces: FeedbackTrace[], format: string | undefined): string {
|
||||
if (normalizeExportFormat(format) === "json") {
|
||||
return JSON.stringify(traces, null, 2);
|
||||
}
|
||||
return traces.map((trace) => JSON.stringify(trace)).join("\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
189
docs/feedback-voting.md
Normal 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
|
||||
```
|
||||
70
packages/db/src/migrations/0047_overjoyed_groot.sql
Normal file
70
packages/db/src/migrations/0047_overjoyed_groot.sql
Normal 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;
|
||||
12539
packages/db/src/migrations/meta/0047_snapshot.json
Normal file
12539
packages/db/src/migrations/meta/0047_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -330,6 +330,13 @@
|
|||
"when": 1774960197878,
|
||||
"tag": "0046_smooth_sentinels",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 47,
|
||||
"version": "7",
|
||||
"when": 1775137972687,
|
||||
"tag": "0047_overjoyed_groot",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
45
packages/db/src/schema/feedback_exports.ts
Normal file
45
packages/db/src/schema/feedback_exports.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
34
packages/db/src/schema/feedback_votes.ts
Normal file
34
packages/db/src/schema/feedback_votes.ts
Normal 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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
120
packages/shared/src/types/feedback.ts
Normal file
120
packages/shared/src/types/feedback.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import type { FeedbackDataSharingPreference } from "./feedback.js";
|
||||
|
||||
export interface InstanceGeneralSettings {
|
||||
censorUsernameInLogs: boolean;
|
||||
feedbackDataSharingPreference: FeedbackDataSharingPreference;
|
||||
}
|
||||
|
||||
export interface InstanceExperimentalSettings {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
22
packages/shared/src/validators/feedback.ts
Normal file
22
packages/shared/src/validators/feedback.ts
Normal 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>;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
1103
server/src/__tests__/feedback-service.test.ts
Normal file
1103
server/src/__tests__/feedback-service.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
173
server/src/__tests__/server-startup-feedback-export.test.ts
Normal file
173
server/src/__tests__/server-startup-feedback-export.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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-"));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
@ -542,6 +563,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 +1175,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 +1485,87 @@ 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;
|
||||
const trace = await feedback.getFeedbackTraceById(traceId, parseBooleanQuery(req.query.includePayload) || req.query.includePayload === undefined);
|
||||
if (!trace) {
|
||||
res.status(404).json({ error: "Feedback trace not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, trace.companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Only board users can view feedback traces" });
|
||||
return;
|
||||
}
|
||||
res.json(trace);
|
||||
});
|
||||
|
||||
router.get("/feedback-traces/:traceId/bundle", async (req, res) => {
|
||||
const traceId = req.params.traceId as string;
|
||||
const bundle = await feedback.getFeedbackTraceBundle(traceId);
|
||||
if (!bundle) {
|
||||
res.status(404).json({ error: "Feedback trace not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, bundle.companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Only board users can view feedback trace bundles" });
|
||||
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 +1643,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 +1765,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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
193
server/src/services/feedback-redaction.ts
Normal file
193
server/src/services/feedback-redaction.ts
Normal 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");
|
||||
}
|
||||
54
server/src/services/feedback-share-client.ts
Normal file
54
server/src/services/feedback-share-client.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
2045
server/src/services/feedback.ts
Normal file
2045
server/src/services/feedback.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
259
ui/src/components/OutputFeedbackButtons.tsx
Normal file
259
ui/src/components/OutputFeedbackButtons.tsx
Normal 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 > 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1
ui/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
Loading…
Add table
Reference in a new issue