Merge remote-tracking branch 'upstream/master' into feat/external-adapter-phase1

This commit is contained in:
HenkDz
2026-04-04 14:33:48 +01:00
10 changed files with 366 additions and 29 deletions

View File

@@ -19,7 +19,7 @@ Each vote creates two local records:
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.
When a vote is marked for sharing, Paperclip immediately tries to upload the trace bundle through the Telemetry Backend. The upload is compressed in transit so full trace bundles stay under gateway size limits. If that immediate push fails, the trace is left in a retriable failed state for later flush attempts. The app server never uploads raw feedback trace bundles directly to object storage.
## Viewing your votes
@@ -148,6 +148,8 @@ Open any file in `traces/` to see:
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.
Each entry in `bundle.json.files[]` includes the actual captured file payload under `contents`, not just a pathname. For text artifacts this is stored as UTF-8 text; binary artifacts use base64 plus an `encoding` marker.
Built-in local adapters now export their native session artifacts more directly:
- `codex_local`: `adapter/codex/session.jsonl`
@@ -168,19 +170,21 @@ Your preference is saved per-company. You can change it any time via the feedbac
| Status | Meaning |
|--------|---------|
| `local_only` | Vote stored locally, not marked for sharing |
| `pending` | Marked for sharing, waiting to be sent |
| `pending` | Marked for sharing, saved locally, and waiting for the immediate upload attempt |
| `sent` | Successfully transmitted |
| `failed` | Transmission attempted but failed (will retry) |
| `failed` | Transmission attempted but failed (for example the backend is unreachable or not configured); later flushes retry once a backend is available |
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.
Votes you choose to share are sent to the Telemetry Backend immediately from the vote request. The server also keeps a background flush worker so failed traces can retry later. 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
- Default endpoint: when no feedback export backend URL is configured, Paperclip falls back to `https://telemetry.paperclip.ing`
- Important nuance: the uploaded object is a snapshot of the full bundle at vote time. If you fetch a local bundle later and the underlying adapter session file has continued to grow, the local regenerated bundle may be larger than the already-uploaded snapshot for that same trace.
Exported objects use a deterministic key pattern so they are easy to inspect:

View File

@@ -4,19 +4,19 @@
## Highlights
- **Execution workspaces** — Full workspace lifecycle management for agent runs: workspace-aware routine runs, execution workspace detail pages with linked issues, runtime controls (start/stop), close readiness checks, and follow-up issue workspace inheritance. Project workspaces get their own detail pages and a dedicated tab on the project view. ([#2074](https://github.com/paperclipai/paperclip/pull/2074), [#2203](https://github.com/paperclipai/paperclip/pull/2203))
- **Inbox overhaul** — New "Mine" inbox tab with mail-client keyboard shortcuts (j/k navigation, a/y archive, o open), swipe-to-archive, "Mark all as read" button, operator search with keyboard controls, and a "Today" divider. Read/dismissed state now extends to all inbox item types. ([#2072](https://github.com/paperclipai/paperclip/pull/2072), [#2540](https://github.com/paperclipai/paperclip/pull/2540))
- **Telemetry** — App-side telemetry sender with periodic flush, graceful shutdown, plugin telemetry bridge, and server-side telemetry aligned with the backend schema. Agent role is used in task-completed events. ([#2527](https://github.com/paperclipai/paperclip/pull/2527))
- **Feedback and evals** — Thumbs-up/down feedback capture flow with voting UI, feedback modal styling, and run link placement in the feedback row. ([#2529](https://github.com/paperclipai/paperclip/pull/2529))
- **Document revisions** — Issue document revision history with a restore flow, replay-safe migrations, and revision tracking API. ([#2317](https://github.com/paperclipai/paperclip/pull/2317))
- **Telemetry** — Anonymized App-side telemetry. Disable with `DO_NOT_TRACK=1` or `PAPERCLIP_TELEMETRY_DISABLED=1` ([#2527](https://github.com/paperclipai/paperclip/pull/2527))
- **Execution workspaces (EXPERIMENTAL)** — Full workspace lifecycle management for agent runs: workspace-aware routine runs, execution workspace detail pages with linked issues, runtime controls (start/stop), close readiness checks, and follow-up issue workspace inheritance. Project workspaces get their own detail pages and a dedicated tab on the project view. ([#2074](https://github.com/paperclipai/paperclip/pull/2074), [#2203](https://github.com/paperclipai/paperclip/pull/2203))
## Improvements
- **Comment interrupts** — New interrupt support for issue comments with queued comment thread UX.
- **Docker improvements** — Improved base image organization, host UID/GID mapping for volume mounts, and Docker file structure. ([#2407](https://github.com/paperclipai/paperclip/pull/2407), [#1923](https://github.com/paperclipai/paperclip/pull/1923), @radiusred)
- **Optimistic comments** — Comments render instantly with optimistic IDs while the server confirms; draft clearing is fixed for a smoother composing experience.
- **Comment interrupts** — New interrupt support for issue comments with queued comment thread UX. The interrupt checkbox was later removed in favor of a cleaner flow.
- **GitHub Enterprise URL support** — Skill and company imports now accept GitHub Enterprise URLs with hardened GHE URL detection and shared GitHub helpers. ([#2449](https://github.com/paperclipai/paperclip/pull/2449), @statxc)
- **Gemini local adapter** — Added `gemini_local` to the adapter types validation enum so Gemini agents no longer fail validation. ([#2430](https://github.com/paperclipai/paperclip/pull/2430), @bittoby)
- **Docker improvements** — Improved base image organization, host UID/GID mapping for volume mounts, and Docker file structure. ([#2407](https://github.com/paperclipai/paperclip/pull/2407), [#1923](https://github.com/paperclipai/paperclip/pull/1923), @radiusred)
- **Routines skill** — New `paperclip-routines` skill with documentation moved into Paperclip references. Routine runs now support workspace awareness and variables. ([#2414](https://github.com/paperclipai/paperclip/pull/2414), @aronprins)
- **GPT-5.4 and xhigh effort** — Added GPT-5.4 model fallback and xhigh effort options for OpenAI-based adapters. ([#112](https://github.com/paperclipai/paperclip/pull/112), @kevmok)
- **Commit metrics** — New Paperclip commit metrics script with filtered exports and edge case handling.

View File

@@ -187,7 +187,11 @@ describe("feedbackService.saveIssueVote", () => {
const targetCommentId = randomUUID();
const earlierCommentId = randomUUID();
const laterCommentId = randomUUID();
const runId = randomUUID();
// Use a deterministic UUID whose hyphen-separated segments cannot be
// mistaken for a phone number by the PII redactor's phone regex.
// Random UUIDs occasionally produce digit pairs like "4880-8614" that
// cross segment boundaries and match the phone pattern.
const runId = "abcde123-face-beef-cafe-abcdef654321";
const instructionsDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-feedback-instructions-"));
tempDirs.push(instructionsDir);
const instructionsPath = path.join(instructionsDir, "AGENTS.md");
@@ -1065,6 +1069,73 @@ describe("feedbackService.saveIssueVote", () => {
});
});
it("can flush a single shared trace immediately by trace id", async () => {
const { companyId, issueId, commentId: firstCommentId } = await seedIssueWithAgentComment();
const secondCommentId = randomUUID();
const agentId = await db
.select({ authorAgentId: issueComments.authorAgentId })
.from(issueComments)
.where(eq(issueComments.id, firstCommentId))
.then((rows) => rows[0]?.authorAgentId ?? null);
await db.insert(issueComments).values({
id: secondCommentId,
companyId,
issueId,
authorAgentId: agentId,
body: "Second AI generated update",
});
const uploadTraceBundle = vi.fn().mockResolvedValue({
objectKey: `feedback-traces/${companyId}/2026/04/01/test-trace.json`,
});
const flushingSvc = feedbackService(db, {
shareClient: {
uploadTraceBundle,
},
});
const first = await flushingSvc.saveIssueVote({
issueId,
targetType: "issue_comment",
targetId: firstCommentId,
vote: "up",
authorUserId: "user-1",
allowSharing: true,
});
await flushingSvc.saveIssueVote({
issueId,
targetType: "issue_comment",
targetId: secondCommentId,
vote: "up",
authorUserId: "user-1",
allowSharing: true,
});
const flushResult = await flushingSvc.flushPendingFeedbackTraces({
companyId,
traceId: first.traceId ?? undefined,
limit: 1,
});
expect(flushResult).toMatchObject({
attempted: 1,
sent: 1,
failed: 0,
});
expect(uploadTraceBundle).toHaveBeenCalledTimes(1);
const traces = await flushingSvc.listFeedbackTraces({
companyId,
issueId,
includePayload: true,
});
const firstTrace = traces.find((trace) => trace.targetId === firstCommentId);
const secondTrace = traces.find((trace) => trace.targetId === secondCommentId);
expect(firstTrace?.status).toBe("sent");
expect(secondTrace?.status).toBe("pending");
});
it("marks pending shared traces as failed when remote export upload fails", async () => {
const { companyId, issueId, commentId } = await seedIssueWithAgentComment();
const uploadTraceBundle = vi.fn().mockRejectedValue(new Error("telemetry unavailable"));
@@ -1102,4 +1173,39 @@ describe("feedbackService.saveIssueVote", () => {
expect(traces[0]?.exportedAt).toBeNull();
expect(uploadTraceBundle).toHaveBeenCalledTimes(1);
});
it("marks pending shared traces as failed when no feedback export backend is configured", async () => {
const { companyId, issueId, commentId } = await seedIssueWithAgentComment();
const result = await svc.saveIssueVote({
issueId,
targetType: "issue_comment",
targetId: commentId,
vote: "up",
authorUserId: "user-1",
allowSharing: true,
});
const flushResult = await svc.flushPendingFeedbackTraces({
companyId,
traceId: result.traceId ?? undefined,
limit: 1,
});
expect(flushResult).toMatchObject({
attempted: 1,
sent: 0,
failed: 1,
});
const traces = await svc.listFeedbackTraces({
companyId,
issueId,
includePayload: true,
});
expect(traces[0]?.status).toBe("failed");
expect(traces[0]?.attemptCount).toBe(1);
expect(traces[0]?.failureReason).toBe("Feedback export backend is not configured");
expect(traces[0]?.exportedAt).toBeNull();
});
});

View File

@@ -0,0 +1,101 @@
import { gunzipSync } from "node:zlib";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFeedbackTraceShareClientFromConfig } from "../services/feedback-share-client.js";
describe("feedback trace share client", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ objectKey: "feedback-traces/test.json" }),
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("defaults to telemetry.paperclip.ing when no backend url is configured", async () => {
const client = createFeedbackTraceShareClientFromConfig({
feedbackExportBackendUrl: undefined,
feedbackExportBackendToken: undefined,
});
await client.uploadTraceBundle({
traceId: "trace-1",
exportId: "export-1",
companyId: "company-1",
issueId: "issue-1",
issueIdentifier: "PAP-1",
adapterType: "codex_local",
captureStatus: "full",
notes: [],
envelope: {},
surface: null,
paperclipRun: null,
rawAdapterTrace: null,
normalizedAdapterTrace: null,
privacy: null,
integrity: {},
files: [],
});
expect(fetch).toHaveBeenCalledWith(
"https://telemetry.paperclip.ing/feedback-traces",
expect.objectContaining({
method: "POST",
}),
);
});
it("wraps the feedback trace payload as gzip+base64 json before upload", async () => {
const client = createFeedbackTraceShareClientFromConfig({
feedbackExportBackendUrl: "https://telemetry.paperclip.ing",
feedbackExportBackendToken: "test-token",
});
await client.uploadTraceBundle({
traceId: "trace-1",
exportId: "export-1",
companyId: "company-1",
issueId: "issue-1",
issueIdentifier: "PAP-1",
adapterType: "codex_local",
captureStatus: "full",
notes: [],
envelope: { hello: "world" },
surface: null,
paperclipRun: null,
rawAdapterTrace: null,
normalizedAdapterTrace: null,
privacy: null,
integrity: {},
files: [],
});
const call = vi.mocked(fetch).mock.calls[0];
expect(call?.[0]).toBe("https://telemetry.paperclip.ing/feedback-traces");
expect(call?.[1]).toMatchObject({
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer test-token",
},
});
const body = JSON.parse(String(call?.[1]?.body ?? "{}")) as {
encoding?: string;
payload?: string;
};
expect(body.encoding).toBe("gzip+base64+json");
expect(typeof body.payload).toBe("string");
const decoded = gunzipSync(Buffer.from(body.payload ?? "", "base64")).toString("utf8");
const parsed = JSON.parse(decoded) as {
objectKey: string;
bundle: { envelope: { hello: string } };
};
expect(parsed.objectKey).toContain("feedback-traces/company-1/");
expect(parsed.objectKey.endsWith("/export-1.json")).toBe(true);
expect(parsed.bundle.envelope).toEqual({ hello: "world" });
});
});

View File

@@ -12,6 +12,18 @@ const mockFeedbackService = vi.hoisted(() => ({
saveIssueVote: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdentifier: vi.fn(),
update: vi.fn(),
addComment: vi.fn(),
findMentionedAgents: vi.fn(),
}));
const mockFeedbackExportService = vi.hoisted(() => ({
flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 1, sent: 1, failed: 0 })),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
@@ -42,12 +54,7 @@ vi.mock("../services/index.js", () => ({
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => ({
getById: vi.fn(),
update: vi.fn(),
addComment: vi.fn(),
findMentionedAgents: vi.fn(),
}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
@@ -63,7 +70,7 @@ function createApp(actor: Record<string, unknown>) {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use("/api", issueRoutes({} as any, {} as any, { feedbackExportService: mockFeedbackExportService }));
app.use(errorHandler);
return app;
}
@@ -73,6 +80,50 @@ describe("issue feedback trace routes", () => {
vi.clearAllMocks();
});
it("flushes a newly shared feedback trace immediately after saving the vote", async () => {
const targetId = "11111111-1111-4111-8111-111111111111";
mockIssueService.getById.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
identifier: "PAP-1",
});
mockFeedbackService.saveIssueVote.mockResolvedValue({
vote: {
targetType: "issue_comment",
targetId,
vote: "up",
reason: null,
},
traceId: "trace-1",
consentEnabledNow: false,
persistedSharingPreference: null,
sharingEnabled: true,
});
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: true,
companyIds: ["company-1"],
});
const res = await request(app)
.post("/api/issues/issue-1/feedback-votes")
.send({
targetType: "issue_comment",
targetId,
vote: "up",
allowSharing: true,
});
expect(res.status).toBe(201);
expect(mockFeedbackExportService.flushPendingFeedbackTraces).toHaveBeenCalledWith({
companyId: "company-1",
traceId: "trace-1",
limit: 1,
});
});
it("rejects non-board callers before fetching a feedback trace", async () => {
const app = createApp({
type: "agent",

View File

@@ -68,6 +68,7 @@ export async function createApp(
feedbackExportService?: {
flushPendingFeedbackTraces(input?: {
companyId?: string;
traceId?: string;
limit?: number;
now?: Date;
}): Promise<unknown>;
@@ -153,7 +154,9 @@ export async function createApp(
api.use(agentRoutes(db));
api.use(assetRoutes(db, opts.storageService));
api.use(projectRoutes(db));
api.use(issueRoutes(db, opts.storageService));
api.use(issueRoutes(db, opts.storageService, {
feedbackExportService: opts.feedbackExportService,
}));
api.use(routineRoutes(db));
api.use(executionWorkspaceRoutes(db));
api.use(goalRoutes(db));

View File

@@ -525,7 +525,7 @@ 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,
shareClient: createFeedbackTraceShareClientFromConfig(config),
});
const app = await createApp(db as any, {
uiMode,

View File

@@ -52,7 +52,20 @@ const updateIssueRouteSchema = updateIssueSchema.extend({
interrupt: z.boolean().optional(),
});
export function issueRoutes(db: Db, storage: StorageService) {
export function issueRoutes(
db: Db,
storage: StorageService,
opts?: {
feedbackExportService?: {
flushPendingFeedbackTraces(input?: {
companyId?: string;
traceId?: string;
limit?: number;
now?: Date;
}): Promise<unknown>;
};
},
) {
const router = Router();
const svc = issueService(db);
const access = accessService(db);
@@ -67,6 +80,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
const workProductsSvc = workProductService(db);
const documentsSvc = documentService(db);
const routinesSvc = routineService(db);
const feedbackExportService = opts?.feedbackExportService;
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
@@ -1867,6 +1881,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
);
}
if (result.sharingEnabled && result.traceId && feedbackExportService) {
try {
await feedbackExportService.flushPendingFeedbackTraces({
companyId: issue.companyId,
traceId: result.traceId,
limit: 1,
});
} catch (err) {
logger.warn({ err, issueId: issue.id, traceId: result.traceId }, "failed to flush shared feedback trace immediately");
}
}
res.status(201).json(result.vote);
});

View File

@@ -1,6 +1,9 @@
import { gzipSync } from "node:zlib";
import type { FeedbackTraceBundle } from "@paperclipai/shared";
import type { Config } from "../config.js";
const DEFAULT_FEEDBACK_EXPORT_BACKEND_URL = "https://telemetry.paperclip.ing";
function buildFeedbackShareObjectKey(bundle: FeedbackTraceBundle, exportedAt: Date) {
const year = String(exportedAt.getUTCFullYear());
const month = String(exportedAt.getUTCMonth() + 1).padStart(2, "0");
@@ -14,10 +17,8 @@ export interface FeedbackTraceShareClient {
export function createFeedbackTraceShareClientFromConfig(
config: Pick<Config, "feedbackExportBackendUrl" | "feedbackExportBackendToken">,
): FeedbackTraceShareClient | null {
const baseUrl = config.feedbackExportBackendUrl?.trim();
if (!baseUrl) return null;
): FeedbackTraceShareClient {
const baseUrl = config.feedbackExportBackendUrl?.trim() || DEFAULT_FEEDBACK_EXPORT_BACKEND_URL;
const token = config.feedbackExportBackendToken?.trim();
const endpoint = new URL("/feedback-traces", baseUrl).toString();
@@ -25,6 +26,11 @@ export function createFeedbackTraceShareClientFromConfig(
async uploadTraceBundle(bundle) {
const exportedAt = new Date();
const objectKey = buildFeedbackShareObjectKey(bundle, exportedAt);
const requestBody = JSON.stringify({
objectKey,
exportedAt: exportedAt.toISOString(),
bundle,
});
const response = await fetch(endpoint, {
method: "POST",
headers: {
@@ -32,9 +38,8 @@ export function createFeedbackTraceShareClientFromConfig(
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
objectKey,
exportedAt: exportedAt.toISOString(),
bundle,
encoding: "gzip+base64+json",
payload: gzipSync(requestBody).toString("base64"),
}),
});

View File

@@ -63,6 +63,7 @@ const MAX_SKILLS = 20;
const MAX_INSTRUCTION_FILES = 20;
const MAX_TRACE_FILE_CHARS = 10_000_000;
const DEFAULT_INSTANCE_SETTINGS_SINGLETON_KEY = "default";
const FEEDBACK_EXPORT_BACKEND_NOT_CONFIGURED = "Feedback export backend is not configured";
type FeedbackTraceRow = typeof feedbackExports.$inferSelect & {
issueIdentifier: string | null;
@@ -1742,15 +1743,48 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
flushPendingFeedbackTraces: async (input?: {
companyId?: string;
traceId?: string;
limit?: number;
now?: Date;
}) => {
const shareClient = options.shareClient;
if (!shareClient) {
const filters = [eq(feedbackExports.status, "pending")];
if (input?.companyId) {
filters.push(eq(feedbackExports.companyId, input.companyId));
}
if (input?.traceId) {
filters.push(eq(feedbackExports.id, input.traceId));
}
const rows = await db
.select({
id: feedbackExports.id,
attemptCount: feedbackExports.attemptCount,
})
.from(feedbackExports)
.where(and(...filters))
.orderBy(asc(feedbackExports.createdAt), asc(feedbackExports.id))
.limit(Math.max(1, Math.min(input?.limit ?? 25, 200)));
const attemptAt = input?.now ?? new Date();
for (const row of rows) {
await db
.update(feedbackExports)
.set({
status: "failed",
attemptCount: row.attemptCount + 1,
lastAttemptedAt: attemptAt,
failureReason: FEEDBACK_EXPORT_BACKEND_NOT_CONFIGURED,
updatedAt: attemptAt,
})
.where(eq(feedbackExports.id, row.id));
}
return {
attempted: 0,
attempted: rows.length,
sent: 0,
failed: 0,
failed: rows.length,
};
}
@@ -1761,6 +1795,9 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
if (input?.companyId) {
filters.push(eq(feedbackExports.companyId, input.companyId));
}
if (input?.traceId) {
filters.push(eq(feedbackExports.id, input.traceId));
}
const rows = await db
.select({
@@ -1983,7 +2020,7 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
})
.where(eq(feedbackVotes.id, savedVote.id));
await tx
const [savedTrace] = await tx
.insert(feedbackExports)
.values({
companyId: issue.companyId,
@@ -2030,6 +2067,9 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
failureReason: null,
updatedAt: now,
},
})
.returning({
id: feedbackExports.id,
});
return {
@@ -2037,6 +2077,7 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
...savedVote,
redactionSummary: artifacts.redactionSummary,
},
traceId: savedTrace?.id ?? null,
consentEnabledNow,
persistedSharingPreference,
sharingEnabled: sharedWithLabs,