Add feedback voting and thumbs capture flow

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-04-02 09:11:49 -05:00
parent 3db6bdfc3c
commit c0d0d03bce
66 changed files with 18988 additions and 78 deletions
@@ -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,
}));
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),
+38 -12
View File
@@ -228,6 +228,42 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
});
it("accepts issue identifiers through getById", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
issueNumber: 1064,
identifier: "PAP-1064",
title: "Feedback votes error",
status: "todo",
priority: "medium",
createdByUserId: "user-1",
});
const issue = await svc.getById("PAP-1064");
expect(issue).toEqual(
expect.objectContaining({
id: issueId,
identifier: "PAP-1064",
}),
);
});
it("returns null instead of throwing for malformed non-uuid issue refs", async () => {
await expect(svc.getById("not-a-uuid")).resolves.toBeNull();
});
it("filters issues by execution workspace id", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
@@ -357,18 +393,8 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
},
]);
await svc.archiveInbox(
companyId,
archivedIssueId,
userId,
new Date("2026-03-26T12:30:00.000Z"),
);
await svc.archiveInbox(
companyId,
resurfacedIssueId,
userId,
new Date("2026-03-26T13:00:00.000Z"),
);
await svc.archiveInbox(companyId, archivedIssueId, userId, new Date("2026-03-26T12:30:00.000Z"));
await svc.archiveInbox(companyId, resurfacedIssueId, userId, new Date("2026-03-26T13:00:00.000Z"));
await db.insert(issueComments).values({
companyId,
@@ -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,
});
});
});
+4 -16
View File
@@ -51,7 +51,7 @@ async function runGit(cwd: string, args: string[]) {
await execFileAsync("git", args, { cwd });
}
async function createTempRepo() {
async function createTempRepo(defaultBranch = "main") {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-"));
await runGit(repoRoot, ["init"]);
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
@@ -59,7 +59,7 @@ async function createTempRepo() {
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
await runGit(repoRoot, ["checkout", "-B", "main"]);
await runGit(repoRoot, ["checkout", "-B", defaultBranch]);
return repoRoot;
}
@@ -658,13 +658,7 @@ describe("realizeExecutionWorkspace", () => {
it("auto-detects the default branch when baseRef is not configured", async () => {
// Create a repo with "master" as default branch (not "main")
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-master-"));
await runGit(repoRoot, ["init", "-b", "master"]);
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
const repoRoot = await createTempRepo("master");
// Set up a bare remote and push master so refs/remotes/origin/master
// exists locally. Note: refs/remotes/origin/HEAD is NOT set by a manual
@@ -716,13 +710,7 @@ describe("realizeExecutionWorkspace", () => {
it("auto-detects the default branch via symbolic-ref when origin/HEAD is set", async () => {
// Create a repo with "master" as default branch
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-symref-"));
await runGit(repoRoot, ["init", "-b", "master"]);
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
const repoRoot = await createTempRepo("master");
// Set up a bare remote and push
const bareRemote = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-bare-symref-"));
+22
View File
@@ -49,6 +49,7 @@ import { createHostClientHandlers } from "@paperclipai/plugin-sdk";
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
type UiMode = "none" | "static" | "vite-dev";
const FEEDBACK_EXPORT_FLUSH_INTERVAL_MS = 5_000;
export function resolveViteHmrPort(serverPort: number): number {
if (serverPort <= 55_535) {
@@ -63,6 +64,13 @@ export async function createApp(
uiMode: UiMode;
serverPort: number;
storageService: StorageService;
feedbackExportService?: {
flushPendingFeedbackTraces(input?: {
companyId?: string;
limit?: number;
now?: Date;
}): Promise<unknown>;
};
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
allowedHostnames: string[];
@@ -288,6 +296,19 @@ export async function createApp(
jobCoordinator.start();
scheduler.start();
const feedbackExportTimer = opts.feedbackExportService
? setInterval(() => {
void opts.feedbackExportService?.flushPendingFeedbackTraces().catch((err) => {
logger.error({ err }, "Failed to flush pending feedback exports");
});
}, FEEDBACK_EXPORT_FLUSH_INTERVAL_MS)
: null;
feedbackExportTimer?.unref?.();
if (opts.feedbackExportService) {
void opts.feedbackExportService.flushPendingFeedbackTraces().catch((err) => {
logger.error({ err }, "Failed to flush pending feedback exports");
});
}
void toolDispatcher.initialize().catch((err) => {
logger.error({ err }, "Failed to initialize plugin tool dispatcher");
});
@@ -308,6 +329,7 @@ export async function createApp(
logger.error({ err }, "Failed to load ready plugins on startup");
});
process.once("exit", () => {
if (feedbackExportTimer) clearInterval(feedbackExportTimer);
devWatcher?.close();
hostServiceCleanup.disposeAll();
hostServiceCleanup.teardown();
+12
View File
@@ -70,6 +70,8 @@ export interface Config {
storageS3Endpoint: string | undefined;
storageS3Prefix: string;
storageS3ForcePathStyle: boolean;
feedbackExportBackendUrl: string | undefined;
feedbackExportBackendToken: string | undefined;
heartbeatSchedulerEnabled: boolean;
heartbeatSchedulerIntervalMs: number;
companyDeletionEnabled: boolean;
@@ -120,6 +122,14 @@ export function loadConfig(): Config {
process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE !== undefined
? process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE === "true"
: (fileStorage?.s3?.forcePathStyle ?? false);
const feedbackExportBackendUrl =
process.env.PAPERCLIP_FEEDBACK_EXPORT_BACKEND_URL?.trim() ||
process.env.PAPERCLIP_TELEMETRY_BACKEND_URL?.trim() ||
undefined;
const feedbackExportBackendToken =
process.env.PAPERCLIP_FEEDBACK_EXPORT_BACKEND_TOKEN?.trim() ||
process.env.PAPERCLIP_TELEMETRY_BACKEND_TOKEN?.trim() ||
undefined;
const deploymentModeFromEnvRaw = process.env.PAPERCLIP_DEPLOYMENT_MODE;
const deploymentModeFromEnv =
@@ -252,6 +262,8 @@ export function loadConfig(): Config {
storageS3Endpoint,
storageS3Prefix,
storageS3ForcePathStyle,
feedbackExportBackendUrl,
feedbackExportBackendToken,
heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false",
heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000),
companyDeletionEnabled,
+11 -1
View File
@@ -28,7 +28,13 @@ import { createApp } from "./app.js";
import { loadConfig } from "./config.js";
import { logger } from "./middleware/logger.js";
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, routineService } from "./services/index.js";
import {
feedbackService,
heartbeatService,
reconcilePersistedRuntimeServicesOnStartup,
routineService,
} from "./services/index.js";
import { createFeedbackTraceShareClientFromConfig } from "./services/feedback-share-client.js";
import { createStorageServiceFromConfig } from "./storage/index.js";
import { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
@@ -516,10 +522,14 @@ export async function startServer(): Promise<StartedServer> {
});
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
const storageService = createStorageServiceFromConfig(config);
const feedback = feedbackService(db as any, {
shareClient: createFeedbackTraceShareClientFromConfig(config) ?? undefined,
});
const app = await createApp(db as any, {
uiMode,
serverPort: listenPort,
storageService,
feedbackExportService: feedback,
deploymentMode: config.deploymentMode,
deploymentExposure: config.deploymentExposure,
allowedHostnames: config.allowedHostnames,
+65 -1
View File
@@ -1,14 +1,18 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
companyPortabilityExportSchema,
companyPortabilityImportSchema,
companyPortabilityPreviewSchema,
createCompanySchema,
feedbackTargetTypeSchema,
feedbackTraceStatusSchema,
feedbackVoteValueSchema,
updateCompanyBrandingSchema,
updateCompanySchema,
} from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { badRequest, forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js";
import {
accessService,
@@ -16,6 +20,7 @@ import {
budgetService,
companyPortabilityService,
companyService,
feedbackService,
logActivity,
} from "../services/index.js";
import type { StorageService } from "../storage/types.js";
@@ -28,6 +33,20 @@ export function companyRoutes(db: Db, storage?: StorageService) {
const portability = companyPortabilityService(db, storage);
const access = accessService(db);
const budgets = budgetService(db);
const feedback = feedbackService(db);
function parseBooleanQuery(value: unknown) {
return value === true || value === "true" || value === "1";
}
function parseDateQuery(value: unknown, field: string) {
if (typeof value !== "string" || value.trim().length === 0) return undefined;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw badRequest(`Invalid ${field} query value`);
}
return parsed;
}
async function assertCanUpdateBranding(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
@@ -104,6 +123,34 @@ export function companyRoutes(db: Db, storage?: StorageService) {
res.json(company);
});
router.get("/:companyId/feedback-traces", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const targetTypeRaw = typeof req.query.targetType === "string" ? req.query.targetType : undefined;
const voteRaw = typeof req.query.vote === "string" ? req.query.vote : undefined;
const statusRaw = typeof req.query.status === "string" ? req.query.status : undefined;
const issueId = typeof req.query.issueId === "string" && req.query.issueId.trim().length > 0 ? req.query.issueId : undefined;
const projectId = typeof req.query.projectId === "string" && req.query.projectId.trim().length > 0
? req.query.projectId
: undefined;
const traces = await feedback.listFeedbackTraces({
companyId,
issueId,
projectId,
targetType: targetTypeRaw ? feedbackTargetTypeSchema.parse(targetTypeRaw) : undefined,
vote: voteRaw ? feedbackVoteValueSchema.parse(voteRaw) : undefined,
status: statusRaw ? feedbackTraceStatusSchema.parse(statusRaw) : undefined,
from: parseDateQuery(req.query.from, "from"),
to: parseDateQuery(req.query.to, "to"),
sharedOnly: parseBooleanQuery(req.query.sharedOnly),
includePayload: parseBooleanQuery(req.query.includePayload),
});
res.json(traces);
});
router.post("/:companyId/export", validate(companyPortabilityExportSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -246,6 +293,11 @@ export function companyRoutes(db: Db, storage?: StorageService) {
assertCompanyAccess(req, companyId);
const actor = getActorInfo(req);
const existingCompany = await svc.getById(companyId);
if (!existingCompany) {
res.status(404).json({ error: "Company not found" });
return;
}
let body: Record<string, unknown>;
if (req.actor.type === "agent") {
@@ -262,6 +314,18 @@ export function companyRoutes(db: Db, storage?: StorageService) {
} else {
assertBoard(req);
body = updateCompanySchema.parse(req.body);
if (body.feedbackDataSharingEnabled === true && !existingCompany.feedbackDataSharingEnabled) {
body = {
...body,
feedbackDataSharingConsentAt: new Date(),
feedbackDataSharingConsentByUserId: req.actor.userId ?? "local-board",
feedbackDataSharingTermsVersion:
typeof body.feedbackDataSharingTermsVersion === "string" && body.feedbackDataSharingTermsVersion.length > 0
? body.feedbackDataSharingTermsVersion
: DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
};
}
}
const company = await svc.update(companyId, body);
+192
View File
@@ -9,6 +9,10 @@ import {
createIssueLabelSchema,
checkoutIssueSchema,
createIssueSchema,
feedbackTargetTypeSchema,
feedbackTraceStatusSchema,
feedbackVoteValueSchema,
upsertIssueFeedbackVoteSchema,
linkIssueApprovalSchema,
issueDocumentKeySchema,
restoreIssueDocumentRevisionSchema,
@@ -22,8 +26,10 @@ import {
accessService,
agentService,
executionWorkspaceService,
feedbackService,
goalService,
heartbeatService,
instanceSettingsService,
issueApprovalService,
issueService,
documentService,
@@ -49,6 +55,8 @@ export function issueRoutes(db: Db, storage: StorageService) {
const svc = issueService(db);
const access = accessService(db);
const heartbeat = heartbeatService(db);
const feedback = feedbackService(db);
const instanceSettings = instanceSettingsService(db);
const agentsSvc = agentService(db);
const projectsSvc = projectService(db);
const goalsSvc = goalService(db);
@@ -69,6 +77,19 @@ export function issueRoutes(db: Db, storage: StorageService) {
};
}
function parseBooleanQuery(value: unknown) {
return value === true || value === "true" || value === "1";
}
function parseDateQuery(value: unknown, field: string) {
if (typeof value !== "string" || value.trim().length === 0) return undefined;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new HttpError(400, `Invalid ${field} query value`);
}
return parsed;
}
async function runSingleFileUpload(req: Request, res: Response) {
await new Promise<void>((resolve, reject) => {
upload.single("file")(req, res, (err: unknown) => {
@@ -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);
+4
View File
@@ -41,6 +41,10 @@ export function companyService(db: Db) {
budgetMonthlyCents: companies.budgetMonthlyCents,
spentMonthlyCents: companies.spentMonthlyCents,
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled,
feedbackDataSharingConsentAt: companies.feedbackDataSharingConsentAt,
feedbackDataSharingConsentByUserId: companies.feedbackDataSharingConsentByUserId,
feedbackDataSharingTermsVersion: companies.feedbackDataSharingTermsVersion,
brandColor: companies.brandColor,
logoAssetId: companyLogos.assetId,
createdAt: companies.createdAt,
+35 -1
View File
@@ -2289,7 +2289,7 @@ function buildManifestFromPackageFiles(
const skillPaths = Array.from(new Set([...referencedSkillPaths, ...discoveredSkillPaths])).sort();
const manifest: CompanyPortabilityManifest = {
schemaVersion: 4,
schemaVersion: 5,
generatedAt: new Date().toISOString(),
source: opts?.sourceLabel ?? null,
includes: {
@@ -2309,6 +2309,18 @@ function buildManifestFromPackageFiles(
typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean"
? paperclipCompany.requireBoardApprovalForNewAgents
: readCompanyApprovalDefault(companyFrontmatter),
feedbackDataSharingEnabled:
typeof paperclipCompany.feedbackDataSharingEnabled === "boolean"
? paperclipCompany.feedbackDataSharingEnabled
: false,
feedbackDataSharingConsentAt:
typeof paperclipCompany.feedbackDataSharingConsentAt === "string"
? paperclipCompany.feedbackDataSharingConsentAt
: null,
feedbackDataSharingConsentByUserId:
asString(paperclipCompany.feedbackDataSharingConsentByUserId),
feedbackDataSharingTermsVersion:
asString(paperclipCompany.feedbackDataSharingTermsVersion),
},
sidebar: paperclipSidebar,
agents: [],
@@ -3227,6 +3239,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
brandColor: company.brandColor ?? null,
logoPath: companyLogoPath,
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? undefined : false,
feedbackDataSharingEnabled: company.feedbackDataSharingEnabled ? true : undefined,
feedbackDataSharingConsentAt: company.feedbackDataSharingConsentAt?.toISOString() ?? null,
feedbackDataSharingConsentByUserId: company.feedbackDataSharingConsentByUserId ?? null,
feedbackDataSharingTermsVersion: company.feedbackDataSharingTermsVersion ?? null,
}),
sidebar: stripEmptyValues(sidebarOrder),
agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined,
@@ -3736,6 +3752,18 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
requireBoardApprovalForNewAgents: include.company
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true)
: true,
feedbackDataSharingEnabled: include.company
? (sourceManifest.company?.feedbackDataSharingEnabled ?? false)
: false,
feedbackDataSharingConsentAt: include.company && sourceManifest.company?.feedbackDataSharingConsentAt
? new Date(sourceManifest.company.feedbackDataSharingConsentAt)
: null,
feedbackDataSharingConsentByUserId: include.company
? (sourceManifest.company?.feedbackDataSharingConsentByUserId ?? null)
: null,
feedbackDataSharingTermsVersion: include.company
? (sourceManifest.company?.feedbackDataSharingTermsVersion ?? null)
: null,
});
if (mode === "agent_safe" && options?.sourceCompanyId) {
await access.copyActiveUserMemberships(options.sourceCompanyId, created.id);
@@ -3753,6 +3781,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
description: sourceManifest.company.description,
brandColor: sourceManifest.company.brandColor,
requireBoardApprovalForNewAgents: sourceManifest.company.requireBoardApprovalForNewAgents,
feedbackDataSharingEnabled: sourceManifest.company.feedbackDataSharingEnabled,
feedbackDataSharingConsentAt: sourceManifest.company.feedbackDataSharingConsentAt
? new Date(sourceManifest.company.feedbackDataSharingConsentAt)
: null,
feedbackDataSharingConsentByUserId: sourceManifest.company.feedbackDataSharingConsentByUserId,
feedbackDataSharingTermsVersion: sourceManifest.company.feedbackDataSharingTermsVersion,
});
targetCompany = updated ?? targetCompany;
companyAction = "updated";
+3
View File
@@ -171,6 +171,7 @@ export function documentService(db: Db) {
baseRevisionId?: string | null;
createdByAgentId?: string | null;
createdByUserId?: string | null;
createdByRunId?: string | null;
}) => {
const key = normalizeDocumentKey(input.key);
const issue = await db
@@ -231,6 +232,7 @@ export function documentService(db: Db) {
changeSummary: input.changeSummary ?? null,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
createdByRunId: input.createdByRunId ?? null,
createdAt: now,
})
.returning();
@@ -304,6 +306,7 @@ export function documentService(db: Db) {
changeSummary: input.changeSummary ?? null,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
createdByRunId: input.createdByRunId ?? null,
createdAt: now,
})
.returning();
+193
View File
@@ -0,0 +1,193 @@
import { createHash } from "node:crypto";
import { redactCurrentUserText } from "../log-redaction.js";
import { sanitizeRecord } from "../redaction.js";
export type FeedbackRedactionState = {
redactedFields: Set<string>;
truncatedFields: Set<string>;
omittedFields: Set<string>;
notes: Set<string>;
counts: Map<string, number>;
};
type PatternReplacement = string | ((match: string, ...args: string[]) => string);
type RedactionPattern = {
kind: string;
regex: RegExp;
replacement: PatternReplacement;
};
const SECRET_ASSIGNMENT_RE =
/\b(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)\s*[:=]\s*([^\s,;]+)/gi;
const FREE_TEXT_PATTERNS: RedactionPattern[] = [
{
kind: "pem_block",
regex: /-----BEGIN [^-]+-----[\s\S]+?-----END [^-]+-----/g,
replacement: "[REDACTED_PEM_BLOCK]",
},
{
kind: "secret_assignment",
regex: SECRET_ASSIGNMENT_RE,
replacement: (_match, key: string) => `${key}=[REDACTED]`,
},
{
kind: "bearer_token",
regex: /Bearer\s+[A-Za-z0-9._~+/-]+=*/gi,
replacement: "Bearer [REDACTED_TOKEN]",
},
{
kind: "github_token",
regex: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g,
replacement: "[REDACTED_GITHUB_TOKEN]",
},
{
kind: "provider_api_key",
regex: /\bsk-(?:ant-)?[A-Za-z0-9_-]{12,}\b/g,
replacement: "[REDACTED_API_KEY]",
},
{
kind: "jwt",
regex: /\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?\b/g,
replacement: "[REDACTED_JWT]",
},
{
kind: "dsn",
regex: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|kafka|nats|mssql):\/\/[^\s<>'")]+/gi,
replacement: "[REDACTED_CONNECTION_STRING]",
},
{
kind: "email",
regex: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
replacement: "[REDACTED_EMAIL]",
},
{
kind: "phone",
regex: /(?<!\w)(?:\+?\d[\d ()-]{7,}\d)(?!\w)/g,
replacement: "[REDACTED_PHONE]",
},
];
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function increment(state: FeedbackRedactionState, kind: string, count: number) {
if (count <= 0) return;
state.counts.set(kind, (state.counts.get(kind) ?? 0) + count);
}
function recordField(state: FeedbackRedactionState, fieldPath: string) {
if (fieldPath.trim().length === 0) return;
state.redactedFields.add(fieldPath);
}
function applyPattern(input: string, pattern: RedactionPattern) {
const matches = Array.from(input.matchAll(pattern.regex)).length;
if (matches === 0) {
pattern.regex.lastIndex = 0;
return { output: input, matches: 0 };
}
const output = input.replace(pattern.regex, pattern.replacement as never);
pattern.regex.lastIndex = 0;
return { output, matches };
}
export function createFeedbackRedactionState(): FeedbackRedactionState {
return {
redactedFields: new Set<string>(),
truncatedFields: new Set<string>(),
omittedFields: new Set<string>(),
notes: new Set<string>(),
counts: new Map<string, number>(),
};
}
export function sanitizeFeedbackText(
input: string,
state: FeedbackRedactionState,
fieldPath: string,
maxLength: number,
) {
let output = redactCurrentUserText(input);
if (output !== input) {
recordField(state, fieldPath);
increment(state, "current_user", 1);
}
for (const pattern of FREE_TEXT_PATTERNS) {
const result = applyPattern(output, pattern);
if (result.matches > 0) {
output = result.output;
recordField(state, fieldPath);
increment(state, pattern.kind, result.matches);
}
}
if (output.length > maxLength) {
output = `${output.slice(0, Math.max(0, maxLength - 1))}...`;
state.truncatedFields.add(fieldPath);
}
return output;
}
export function sanitizeFeedbackValue(
value: unknown,
state: FeedbackRedactionState,
fieldPath: string,
maxStringLength: number,
): unknown {
if (typeof value === "string") {
return sanitizeFeedbackText(value, state, fieldPath, maxStringLength);
}
if (Array.isArray(value)) {
return value.map((entry, index) =>
sanitizeFeedbackValue(entry, state, `${fieldPath}[${index}]`, maxStringLength));
}
if (!isPlainRecord(value)) {
return value;
}
const structurallySanitized = sanitizeRecord(value);
if (stableStringify(structurallySanitized) !== stableStringify(value)) {
recordField(state, fieldPath);
increment(state, "structured_secret", 1);
}
const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(structurallySanitized)) {
output[key] = sanitizeFeedbackValue(entry, state, `${fieldPath}.${key}`, maxStringLength);
}
return output;
}
export function finalizeFeedbackRedactionSummary(state: FeedbackRedactionState) {
return {
strategy: "deterministic_feedback_v2",
redactedFields: Array.from(state.redactedFields).sort(),
truncatedFields: Array.from(state.truncatedFields).sort(),
omittedFields: Array.from(state.omittedFields).sort(),
notes: Array.from(state.notes).sort(),
counts: Object.fromEntries(Array.from(state.counts.entries()).sort(([left], [right]) => left.localeCompare(right))),
} satisfies Record<string, unknown>;
}
export function stableStringify(value: unknown): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
}
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`);
return `{${entries.join(",")}}`;
}
export function sha256Digest(value: unknown) {
return createHash("sha256").update(stableStringify(value)).digest("hex");
}
@@ -0,0 +1,54 @@
import type { FeedbackTraceBundle } from "@paperclipai/shared";
import type { Config } from "../config.js";
function buildFeedbackShareObjectKey(bundle: FeedbackTraceBundle, exportedAt: Date) {
const year = String(exportedAt.getUTCFullYear());
const month = String(exportedAt.getUTCMonth() + 1).padStart(2, "0");
const day = String(exportedAt.getUTCDate()).padStart(2, "0");
return `feedback-traces/${bundle.companyId}/${year}/${month}/${day}/${bundle.exportId ?? bundle.traceId}.json`;
}
export interface FeedbackTraceShareClient {
uploadTraceBundle(bundle: FeedbackTraceBundle): Promise<{ objectKey: string }>;
}
export function createFeedbackTraceShareClientFromConfig(
config: Pick<Config, "feedbackExportBackendUrl" | "feedbackExportBackendToken">,
): FeedbackTraceShareClient | null {
const baseUrl = config.feedbackExportBackendUrl?.trim();
if (!baseUrl) return null;
const token = config.feedbackExportBackendToken?.trim();
const endpoint = new URL("/feedback-traces", baseUrl).toString();
return {
async uploadTraceBundle(bundle) {
const exportedAt = new Date();
const objectKey = buildFeedbackShareObjectKey(bundle, exportedAt);
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
objectKey,
exportedAt: exportedAt.toISOString(),
bundle,
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(detail.trim() || `Feedback trace upload failed with HTTP ${response.status}`);
}
const payload = await response.json().catch(() => null) as { objectKey?: unknown } | null;
return {
objectKey: typeof payload?.objectKey === "string" && payload.objectKey.trim().length > 0
? payload.objectKey
: objectKey,
};
},
};
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -2615,7 +2615,7 @@ export function heartbeatService(db: Db) {
workspace: executionWorkspace,
runtimeServices,
}),
{ agentId: agent.id },
{ agentId: agent.id, runId: run.id },
);
} catch (err) {
await onLog(
@@ -2705,7 +2705,7 @@ export function heartbeatService(db: Db) {
workspace: executionWorkspace,
runtimeServices: adapterManagedRuntimeServices,
}),
{ agentId: agent.id },
{ agentId: agent.id, runId: run.id },
);
} catch (err) {
await onLog(
+1
View File
@@ -1,4 +1,5 @@
export { companyService } from "./companies.js";
export { feedbackService } from "./feedback.js";
export { companySkillService } from "./company-skills.js";
export { agentService, deduplicateAgentName } from "./agents.js";
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
+4
View File
@@ -1,6 +1,7 @@
import type { Db } from "@paperclipai/db";
import { companies, instanceSettings } from "@paperclipai/db";
import {
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
instanceGeneralSettingsSchema,
type InstanceGeneralSettings,
instanceExperimentalSettingsSchema,
@@ -18,10 +19,13 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
if (parsed.success) {
return {
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
feedbackDataSharingPreference:
parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
};
}
return {
censorUsernameInLogs: false,
feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
};
}
+39 -19
View File
@@ -21,7 +21,7 @@ import {
projectWorkspaces,
projects,
} from "@paperclipai/db";
import { extractAgentMentionIds, extractProjectMentionIds } from "@paperclipai/shared";
import { extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import {
defaultIssueExecutionWorkspaceSettingsForProject,
@@ -467,6 +467,28 @@ function withActiveRuns(
export function issueService(db: Db) {
const instanceSettings = instanceSettingsService(db);
async function getIssueByUuid(id: string) {
const row = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
}
async function getIssueByIdentifier(identifier: string) {
const row = await db
.select()
.from(issues)
.where(eq(issues.identifier, identifier.toUpperCase()))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
}
function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
...comment,
@@ -883,26 +905,19 @@ export function issueService(db: Db) {
return row ?? null;
},
getById: async (id: string) => {
const row = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
getById: async (raw: string) => {
const id = raw.trim();
if (/^[A-Z]+-\d+$/i.test(id)) {
return getIssueByIdentifier(id);
}
if (!isUuidLike(id)) {
return null;
}
return getIssueByUuid(id);
},
getByIdentifier: async (identifier: string) => {
const row = await db
.select()
.from(issues)
.where(eq(issues.identifier, identifier.toUpperCase()))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
return getIssueByIdentifier(identifier);
},
create: async (
@@ -1542,7 +1557,11 @@ export function issueService(db: Db) {
return comment ? redactIssueComment(comment, censorUsernameInLogs) : null;
})),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
addComment: async (
issueId: string,
body: string,
actor: { agentId?: string; userId?: string; runId?: string | null },
) => {
const issue = await db
.select({ companyId: issues.companyId })
.from(issues)
@@ -1562,6 +1581,7 @@ export function issueService(db: Db) {
issueId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
body: redactedBody,
})
.returning();