mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
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
|
|
@ -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-"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue