mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path > - Paperclip manages AI-agent work and its delivery to users and agents. > - Five delivery queues scan the database even when empty. > - Each queue already has durable rows; empty polling wastes idle hosting capacity. > - Producers can register intent inside their existing database transaction. > - Central transaction tracking can wake consumers after the outer commit without extra caller wrappers. > - A lost response needs reconciliation against PostgreSQL before an empty queue is safe to leave idle. > - This change uses one scheduler for outstanding work and lets settled, empty queues go quiet. ## Linked Issues or Issue Description Supersedes the closed #15614. Related idle-safety work: #15522 and #15599. **What existing behavior does this improve?** Delivery scheduling for feedback exports, chat completions, connection continuations, question answers, and tool-action receipts. **Current behavior** Feedback scans every five seconds. Four delivery queues scan on the heartbeat interval, normally 30 seconds. Startup and some event paths also scan them. **Proposed behavior** Each producer awaits one intent registration before its queue write. The existing `createDb` transaction boundary tracks nested savepoints and wakes the consumer after outer settlement. Startup scans restore durable work. Pending rows, failures, and unresolved transactions retain retry deadlines. Empty, settled queues have no timer or recurring scan. **Reason and benefit** Normal delivery starts after commit. Central tracking removes caller-specific post-commit plumbing. PostgreSQL transaction status resolves lost responses without new tables or a permanent uncertainty latch. **Breaking changes** No API or schema changes. The notification scope is one DB owner and its dedicated child pools. Direct SQL, separate roots, and other processes require an explicit wake/recovery integration. This path requires PostgreSQL 14+ transaction-information functions; embedded PostgreSQL uses 18. ## What Changed - Add one named-deadline scheduler and app-owned coordinator for five existing queues. - Instrument `createDb().transaction()` centrally, including nested savepoints. Dedicated child connections share their owner's signal scope. - Register work before the five queue insert paths. The first registration obtains the outer XID; unrelated transactions issue no extra SQL. Tool receipt insertion gains a small transaction around its existing insert. - Query `pg_xact_status` only for rejected, tracked transactions. Probe before scanning the queue. Retain the idle hold while PostgreSQL still reports an in-progress transaction or the probe fails. Release it after settlement and reconciliation. - Remove unconditional delivery recovery scans and caller-specific completion post-commit actions. Retain the existing task-scoped completion activity fast path and durable claims. - Coalesce wakes without postponing an earlier deadline. Retry outstanding rows and failures; suppress dispatch during idle drain while allowing transaction and queue reconciliation; suppress both during warm standby. Cancel deadlines and await active delivery sweeps on shutdown. - Serialize feedback flushes. Vote routes return after saving. Uploads have a 30-second deadline and shutdown cancellation; unfinished exports remain recoverable. - Document the writer contract and update the dated sleep inventory. ## Verification - Final head: `a4609956841107ad60c4cb50fe2acb05636b1363`. - Passed: all final-head hosted checks, with 54 successes and two intentional skips. The full test matrix, typecheck, build, canary, and aggregate verification passed in [run 37940948440](https://github.com/paperclipai/paperclip/actions/runs/37940948440). - Passed: Greptile 5/5 on the same head, with a successful check run and zero unresolved review threads. The branch is mergeable. - Passed locally after the final rebase: 50 coordinator, scheduler, and startup tests; server typecheck; server build. - Passed locally before the final import-only rebase: repo-wide typecheck and build; 237 PostgreSQL producer/delivery and database signal tests; feedback, native-question, and idle-safety regression tests. - Real PostgreSQL tests hold transactions open after an injected client response loss, then commit or abort. They verify that an empty scan cannot clear an in-progress transaction. Another test terminates a real backend and verifies recovery without replay. - Coordinator tests cover an hour with no empty-queue timers, pending/error retries, worker replacement, concurrent writes, earliest-deadline preservation, rollback during idle drain, committed work during drain, standby, and shutdown. - Local `pnpm test:run` was started and stopped after embedded PostgreSQL startup failures appeared. It did not finish and is not reported as a pass. Later local database reruns had skipped suites; those skips are not claimed as verification. The earlier PostgreSQL tests did execute and pass, and the final hosted full matrix passed. ## Risks - One XID query is added per transaction that registers delivery work. Nested transactions share that ID. Registration must be awaited before writing; new enqueue paths must follow this contract. - Work signals are local to a DB owner and its dedicated child pools. Independent roots, direct SQL, or other processes are not observed. Startup scans recover already committed rows, but do not fence late transactions from a previous process. Cross-process ownership and database failover require separate work. - A database outage or genuinely unresolved transaction keeps an idle hold until status can be reconciled. No timer clears uncertainty by assumption. - These changes remove five empty polling loops. They do not implement whole-instance sleep, database teardown, or an external waker. The earliest deadline covers only the registered queues. - Waiting tool reviews retain their existing retry cadence until their receipts can be delivered. - Feedback vote responses no longer wait for the remote upload. Sharing consent and the saved vote response are unchanged. - Real hosting-provider sleep and cost savings have not been measured. ## Model Used OpenAI Codex, GPT-6. Used reasoning, repository inspection, code editing, and command execution. The runtime did not expose the exact model revision or context window. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1223 lines
39 KiB
TypeScript
1223 lines
39 KiB
TypeScript
import { subscribeDeliveryWork } from "../services/delivery-work-notifications.js";
|
|
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
|
import {
|
|
agents,
|
|
companies,
|
|
companySkills,
|
|
costEvents,
|
|
createDb,
|
|
documents,
|
|
documentRevisions,
|
|
feedbackExports,
|
|
feedbackVotes,
|
|
heartbeatRuns,
|
|
instanceSettings,
|
|
issueComments,
|
|
issueDocuments,
|
|
issues,
|
|
} from "@paperclipai/db";
|
|
import { feedbackService } from "../services/feedback.ts";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.ts";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres feedback service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
async function closeDbClient(db: ReturnType<typeof createDb> | undefined) {
|
|
await db?.$client?.end?.({ timeout: 0 });
|
|
}
|
|
|
|
describeEmbeddedPostgres("feedbackService.saveIssueVote", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof feedbackService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
let tempDirs: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
const started = await startEmbeddedPostgresTestDatabase("paperclip-feedback-service-");
|
|
db = createDb(started.connectionString);
|
|
svc = feedbackService(db);
|
|
tempDb = started;
|
|
}, 120_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(feedbackExports);
|
|
await db.delete(feedbackVotes);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(issueDocuments);
|
|
await db.delete(documentRevisions);
|
|
await db.delete(documents);
|
|
await db.delete(issueComments);
|
|
await db.delete(costEvents);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(companySkills);
|
|
await db.delete(issues);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
for (const dir of tempDirs) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
vi.unstubAllEnvs();
|
|
tempDirs = [];
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await closeDbClient(db);
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedIssueWithAgentComment() {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const commentId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `F${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Add feedback voting",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: "user-1",
|
|
});
|
|
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId,
|
|
issueId,
|
|
authorAgentId: agentId,
|
|
body: "AI generated update",
|
|
});
|
|
|
|
return { companyId, issueId, commentId };
|
|
}
|
|
|
|
async function seedIssueWithRichAgentComment() {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const targetCommentId = randomUUID();
|
|
const earlierCommentId = randomUUID();
|
|
const laterCommentId = 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");
|
|
fs.writeFileSync(
|
|
instructionsPath,
|
|
"You are a coder.\nUse api_key=secret-value.\nPrefer /Users/dotta/private-workspace.",
|
|
"utf8",
|
|
);
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `R${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(companySkills).values([
|
|
{
|
|
id: randomUUID(),
|
|
companyId,
|
|
key: "paperclipai/paperclip/paperclip",
|
|
slug: "paperclip",
|
|
name: "Paperclip",
|
|
markdown: "# Paperclip",
|
|
sourceType: "catalog",
|
|
sourceLocator: null,
|
|
sourceRef: null,
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
|
},
|
|
{
|
|
id: randomUUID(),
|
|
companyId,
|
|
key: "octo/research/public-skill",
|
|
slug: "public-skill",
|
|
name: "Public Skill",
|
|
markdown: "# Public Skill",
|
|
sourceType: "github",
|
|
sourceLocator: "https://github.com/octo/research/tree/main/skills/public-skill",
|
|
sourceRef: "main",
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
|
},
|
|
]);
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: writePaperclipSkillSyncPreference(
|
|
{
|
|
model: "gpt-5.4",
|
|
instructionsBundleMode: "external",
|
|
instructionsRootPath: instructionsDir,
|
|
instructionsEntryFile: "AGENTS.md",
|
|
instructionsFilePath: instructionsPath,
|
|
},
|
|
["paperclipai/paperclip/paperclip", "octo/research/public-skill"],
|
|
),
|
|
runtimeConfig: {
|
|
heartbeat: {
|
|
enabled: true,
|
|
intervalSec: 3600,
|
|
},
|
|
},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Trace-rich feedback",
|
|
description: "Issue context includes ops@example.com and a backup phone 555 111 2222.",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: "user-1",
|
|
});
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "manual",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-03-30T10:00:00.000Z"),
|
|
finishedAt: new Date("2026-03-30T10:05:00.000Z"),
|
|
usageJson: {
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
inputTokens: 123,
|
|
outputTokens: 45,
|
|
costUsd: 0.12,
|
|
},
|
|
});
|
|
|
|
await db.insert(costEvents).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId,
|
|
issueId,
|
|
heartbeatRunId: runId,
|
|
provider: "openai",
|
|
biller: "openai",
|
|
billingType: "metered",
|
|
model: "gpt-5.4",
|
|
inputTokens: 123,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 45,
|
|
costCents: 12,
|
|
occurredAt: new Date("2026-03-30T10:05:00.000Z"),
|
|
});
|
|
|
|
await db.insert(issueComments).values([
|
|
{
|
|
id: earlierCommentId,
|
|
companyId,
|
|
issueId,
|
|
authorAgentId: agentId,
|
|
createdByRunId: runId,
|
|
body: "Previous comment with ops@example.com in it.",
|
|
createdAt: new Date("2026-03-30T10:01:00.000Z"),
|
|
},
|
|
{
|
|
id: targetCommentId,
|
|
companyId,
|
|
issueId,
|
|
authorAgentId: agentId,
|
|
createdByRunId: runId,
|
|
body: "Target output with api_key=secret-value and Bearer secret-token.",
|
|
createdAt: new Date("2026-03-30T10:02:00.000Z"),
|
|
},
|
|
{
|
|
id: laterCommentId,
|
|
companyId,
|
|
issueId,
|
|
authorAgentId: agentId,
|
|
createdByRunId: runId,
|
|
body: "Later comment mentions 555 111 2222 for follow-up.",
|
|
createdAt: new Date("2026-03-30T10:03:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
return { companyId, issueId, targetCommentId, runId };
|
|
}
|
|
|
|
async function seedIssueWithAgentDocument() {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const documentId = randomUUID();
|
|
const revisionId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `D${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Document feedback",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: "user-1",
|
|
});
|
|
|
|
await db.insert(documents).values({
|
|
id: documentId,
|
|
companyId,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
latestBody: "Drafted by an agent",
|
|
latestRevisionId: revisionId,
|
|
latestRevisionNumber: 1,
|
|
createdByAgentId: agentId,
|
|
updatedByAgentId: agentId,
|
|
});
|
|
|
|
await db.insert(documentRevisions).values({
|
|
id: revisionId,
|
|
companyId,
|
|
documentId,
|
|
revisionNumber: 1,
|
|
body: "Drafted by an agent",
|
|
createdByAgentId: agentId,
|
|
});
|
|
|
|
await db.insert(issueDocuments).values({
|
|
companyId,
|
|
issueId,
|
|
documentId,
|
|
key: "plan",
|
|
});
|
|
|
|
return { companyId, issueId, revisionId };
|
|
}
|
|
|
|
async function seedIssueWithAdapterRunComment(input: {
|
|
adapterType: "claude_local" | "opencode_local";
|
|
sessionId: string;
|
|
}) {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const commentId = randomUUID();
|
|
const runId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "TraceCollector",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: input.adapterType,
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Trace-backed feedback",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: "user-1",
|
|
});
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "manual",
|
|
status: "succeeded",
|
|
sessionIdAfter: input.sessionId,
|
|
startedAt: new Date("2026-04-01T10:00:00.000Z"),
|
|
finishedAt: new Date("2026-04-01T10:05:00.000Z"),
|
|
usageJson: {
|
|
provider: input.adapterType === "claude_local" ? "anthropic" : "opencode",
|
|
model: input.adapterType === "claude_local" ? "claude-opus-4-6" : "opencode/minimax-m2.5-free",
|
|
},
|
|
});
|
|
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId,
|
|
issueId,
|
|
authorAgentId: agentId,
|
|
createdByRunId: runId,
|
|
body: "Trace-backed agent output",
|
|
});
|
|
|
|
return { companyId, issueId, commentId };
|
|
}
|
|
|
|
it("stores a local vote without enabling sharing by default", async () => {
|
|
const { companyId, issueId, commentId } = await seedIssueWithAgentComment();
|
|
|
|
const result = await svc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: commentId,
|
|
vote: "up",
|
|
authorUserId: "user-1",
|
|
});
|
|
|
|
expect(result.vote.vote).toBe("up");
|
|
expect(result.sharingEnabled).toBe(false);
|
|
expect(result.persistedSharingPreference).toBe("not_allowed");
|
|
expect(result.vote.consentVersion).toBeNull();
|
|
|
|
const company = await db
|
|
.select()
|
|
.from(companies)
|
|
.where(eq(companies.id, companyId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(company?.feedbackDataSharingEnabled).toBe(false);
|
|
expect(company?.feedbackDataSharingConsentAt).toBeNull();
|
|
|
|
const settings = await db
|
|
.select()
|
|
.from(instanceSettings)
|
|
.where(eq(instanceSettings.singletonKey, "default"))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(settings?.general).toMatchObject({
|
|
feedbackDataSharingPreference: "not_allowed",
|
|
});
|
|
|
|
const traces = await svc.listFeedbackTraces({
|
|
companyId,
|
|
issueId,
|
|
includePayload: true,
|
|
});
|
|
expect(traces[0]?.payloadSnapshot?.bundle).toBeNull();
|
|
expect(traces[0]?.exportId).toBeNull();
|
|
});
|
|
|
|
it("keeps a stored sharing preference when the settings row still carries retired keys", async () => {
|
|
const { issueId, commentId } = await seedIssueWithAgentComment();
|
|
await db.insert(instanceSettings).values({
|
|
singletonKey: "default",
|
|
general: { feedbackDataSharingPreference: "not_allowed", keyboardShortcuts: true },
|
|
experimental: {},
|
|
});
|
|
|
|
const result = await svc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: commentId,
|
|
vote: "up",
|
|
authorUserId: "user-1",
|
|
allowSharing: true,
|
|
});
|
|
|
|
expect(result.persistedSharingPreference).toBeNull();
|
|
|
|
const settings = await db
|
|
.select()
|
|
.from(instanceSettings)
|
|
.where(eq(instanceSettings.singletonKey, "default"))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(settings?.general).toMatchObject({
|
|
feedbackDataSharingPreference: "not_allowed",
|
|
});
|
|
});
|
|
|
|
it("enables sharing metadata on the first consented vote and upserts subsequent votes", async () => {
|
|
const { companyId, issueId, commentId } = await seedIssueWithAgentComment();
|
|
|
|
const first = await svc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: commentId,
|
|
vote: "up",
|
|
authorUserId: "user-1",
|
|
allowSharing: true,
|
|
});
|
|
|
|
expect(first.consentEnabledNow).toBe(true);
|
|
expect(first.sharingEnabled).toBe(true);
|
|
expect(first.persistedSharingPreference).toBe("allowed");
|
|
expect(first.vote.sharedWithLabs).toBe(true);
|
|
expect(first.vote.sharedAt).toBeInstanceOf(Date);
|
|
expect(first.vote.consentVersion).toBe("feedback-data-sharing-v1");
|
|
|
|
const second = await svc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: commentId,
|
|
vote: "down",
|
|
authorUserId: "user-1",
|
|
});
|
|
|
|
expect(second.consentEnabledNow).toBe(false);
|
|
expect(second.sharingEnabled).toBe(false);
|
|
expect(second.persistedSharingPreference).toBeNull();
|
|
expect(second.vote.vote).toBe("down");
|
|
expect(second.vote.sharedWithLabs).toBe(false);
|
|
expect(second.vote.sharedAt).toBeNull();
|
|
expect(second.vote.consentVersion).toBeNull();
|
|
|
|
const votes = await svc.listIssueVotesForUser(issueId, "user-1");
|
|
expect(votes).toHaveLength(1);
|
|
expect(votes[0]?.vote).toBe("down");
|
|
expect(votes[0]?.sharedWithLabs).toBe(false);
|
|
expect(votes[0]?.consentVersion).toBeNull();
|
|
|
|
const company = await db
|
|
.select()
|
|
.from(companies)
|
|
.where(eq(companies.id, companyId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(company?.feedbackDataSharingEnabled).toBe(true);
|
|
expect(company?.feedbackDataSharingConsentByUserId).toBe("user-1");
|
|
expect(company?.feedbackDataSharingTermsVersion).toBe("feedback-data-sharing-v1");
|
|
|
|
const settings = await db
|
|
.select()
|
|
.from(instanceSettings)
|
|
.where(eq(instanceSettings.singletonKey, "default"))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(settings?.general).toMatchObject({
|
|
feedbackDataSharingPreference: "allowed",
|
|
});
|
|
});
|
|
|
|
it("stores a trace record for document revision feedback targets", async () => {
|
|
const { issueId, revisionId } = await seedIssueWithAgentDocument();
|
|
|
|
const result = await svc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_document_revision",
|
|
targetId: revisionId,
|
|
vote: "up",
|
|
authorUserId: "user-1",
|
|
allowSharing: true,
|
|
});
|
|
|
|
expect(result.vote.vote).toBe("up");
|
|
expect(result.sharingEnabled).toBe(true);
|
|
|
|
const traces = await svc.listFeedbackTraces({
|
|
companyId: result.vote.companyId,
|
|
issueId,
|
|
includePayload: true,
|
|
});
|
|
|
|
expect(traces).toHaveLength(1);
|
|
expect(traces[0]?.targetType).toBe("issue_document_revision");
|
|
expect(traces[0]?.status).toBe("pending");
|
|
expect(traces[0]?.targetSummary.documentKey).toBe("plan");
|
|
expect(traces[0]?.targetSummary.revisionNumber).toBe(1);
|
|
expect(traces[0]?.payloadSnapshot?.target).toMatchObject({
|
|
type: "issue_document_revision",
|
|
id: revisionId,
|
|
documentKey: "plan",
|
|
revisionNumber: 1,
|
|
});
|
|
});
|
|
|
|
it("stores a downvote reason and includes it in the trace payload", async () => {
|
|
const { issueId, commentId } = await seedIssueWithAgentComment();
|
|
|
|
const result = await svc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: commentId,
|
|
vote: "down",
|
|
reason: "The update missed the edge case handling.",
|
|
authorUserId: "user-1",
|
|
});
|
|
|
|
expect(result.vote.reason).toBe("The update missed the edge case handling.");
|
|
|
|
const traces = await svc.listFeedbackTraces({
|
|
companyId: result.vote.companyId,
|
|
issueId,
|
|
includePayload: true,
|
|
});
|
|
|
|
expect(traces[0]?.payloadSnapshot?.vote).toMatchObject({
|
|
value: "down",
|
|
reason: "The update missed the edge case handling.",
|
|
sharedWithLabs: false,
|
|
});
|
|
});
|
|
|
|
it("updates an existing downvote reason in place without creating a second trace", async () => {
|
|
const { issueId, commentId } = await seedIssueWithAgentComment();
|
|
|
|
const firstResult = await svc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: commentId,
|
|
vote: "down",
|
|
authorUserId: "user-1",
|
|
});
|
|
|
|
const secondResult = await svc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: commentId,
|
|
vote: "down",
|
|
reason: "Needed concrete next steps.",
|
|
authorUserId: "user-1",
|
|
});
|
|
|
|
expect(secondResult.vote.id).toBe(firstResult.vote.id);
|
|
expect(secondResult.vote.reason).toBe("Needed concrete next steps.");
|
|
|
|
const traces = await svc.listFeedbackTraces({
|
|
companyId: secondResult.vote.companyId,
|
|
issueId,
|
|
includePayload: true,
|
|
});
|
|
|
|
expect(traces).toHaveLength(1);
|
|
expect(traces[0]?.feedbackVoteId).toBe(firstResult.vote.id);
|
|
expect(traces[0]?.payloadSnapshot?.vote).toMatchObject({
|
|
value: "down",
|
|
reason: "Needed concrete next steps.",
|
|
sharedWithLabs: false,
|
|
});
|
|
});
|
|
|
|
it("builds a sanitized shared bundle without reading external instruction roots", async () => {
|
|
const { companyId, issueId, targetCommentId, runId } = await seedIssueWithRichAgentComment();
|
|
|
|
await svc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: targetCommentId,
|
|
vote: "up",
|
|
authorUserId: "user-1",
|
|
allowSharing: true,
|
|
});
|
|
|
|
const traces = await svc.listFeedbackTraces({
|
|
companyId,
|
|
issueId,
|
|
includePayload: true,
|
|
});
|
|
const trace = traces[0];
|
|
const payload = trace?.payloadSnapshot;
|
|
const bundle = payload?.bundle as Record<string, unknown> | null;
|
|
const primaryContent = bundle?.primaryContent as Record<string, unknown> | null;
|
|
const issueContext = bundle?.issueContext as Record<string, unknown> | null;
|
|
const issueContextItems = issueContext?.items as Array<Record<string, unknown>> | undefined;
|
|
const agentContext = bundle?.agentContext as Record<string, unknown> | null;
|
|
const runtime = agentContext?.runtime as Record<string, unknown> | null;
|
|
const sourceRun = runtime?.sourceRun as Record<string, unknown> | null;
|
|
const skills = agentContext?.skills as Record<string, unknown> | null;
|
|
const skillItems = skills?.items as Array<Record<string, unknown>> | undefined;
|
|
const instructions = agentContext?.instructions as Record<string, unknown> | null;
|
|
|
|
expect(trace?.status).toBe("pending");
|
|
expect(trace?.exportId).toMatch(/^fbexp_/);
|
|
expect(trace?.schemaVersion).toBe("paperclip-feedback-envelope-v2");
|
|
expect(trace?.bundleVersion).toBe("paperclip-feedback-bundle-v2");
|
|
expect(trace?.payloadDigest).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(primaryContent?.createdByRunId).toBe(runId);
|
|
expect(String(primaryContent?.body)).toContain("[REDACTED]");
|
|
expect(String(primaryContent?.body)).not.toContain("secret-value");
|
|
expect(issueContextItems).toHaveLength(2);
|
|
expect(JSON.stringify(issueContextItems)).toContain("[REDACTED_EMAIL]");
|
|
expect(JSON.stringify(issueContextItems)).toContain("[REDACTED_PHONE]");
|
|
expect(sourceRun?.id).toBe(runId);
|
|
expect(JSON.stringify(sourceRun)).toContain("gpt-5.4");
|
|
expect(skillItems?.[1]?.sourceLocator).toBe("https://github.com/octo/research/tree/main/skills/public-skill");
|
|
expect(instructions).toBeNull();
|
|
expect(runtime?.configuredInstructionsBundleMode).toBe("external");
|
|
expect(runtime?.configuredInstructionsFilePath).toBeNull();
|
|
expect(runtime?.configuredInstructionsRootPath).toBeNull();
|
|
expect(JSON.stringify(bundle)).not.toContain("secret-value");
|
|
expect(JSON.stringify(bundle)).not.toContain("private-workspace");
|
|
});
|
|
|
|
it("keeps earlier local votes local when a later vote enables sharing", 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",
|
|
});
|
|
|
|
await svc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: firstCommentId,
|
|
vote: "up",
|
|
authorUserId: "user-1",
|
|
});
|
|
|
|
await svc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: secondCommentId,
|
|
vote: "up",
|
|
authorUserId: "user-1",
|
|
allowSharing: true,
|
|
});
|
|
|
|
const traces = await svc.listFeedbackTraces({
|
|
companyId,
|
|
issueId,
|
|
includePayload: true,
|
|
});
|
|
const localTrace = traces.find((trace) => trace.targetId === firstCommentId);
|
|
const sharedTrace = traces.find((trace) => trace.targetId === secondCommentId);
|
|
|
|
expect(localTrace?.status).toBe("local_only");
|
|
expect(localTrace?.exportId).toBeNull();
|
|
expect(localTrace?.payloadVersion).toBe("paperclip-feedback-v1");
|
|
expect(localTrace?.payloadSnapshot?.bundle).toBeNull();
|
|
expect(sharedTrace?.status).toBe("pending");
|
|
expect(sharedTrace?.exportId).toMatch(/^fbexp_/);
|
|
expect(sharedTrace?.payloadVersion).toBe("paperclip-feedback-v1");
|
|
});
|
|
|
|
it("captures Claude project session artifacts as full traces", async () => {
|
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-feedback-claude-"));
|
|
tempDirs.push(claudeRoot);
|
|
const sessionId = randomUUID();
|
|
const projectDir = path.join(claudeRoot, "projects", "workspace-1");
|
|
fs.mkdirSync(path.join(projectDir, sessionId, "tool-results"), { recursive: true });
|
|
fs.mkdirSync(path.join(claudeRoot, "debug"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(projectDir, `${sessionId}.jsonl`),
|
|
[
|
|
JSON.stringify({
|
|
type: "user",
|
|
sessionId,
|
|
message: { role: "user", content: "Open AGENTS.md and continue the task." },
|
|
}),
|
|
JSON.stringify({
|
|
type: "assistant",
|
|
sessionId,
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "tool_use", name: "Read", input: { file_path: "/tmp/AGENTS.md" } }],
|
|
},
|
|
}),
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(projectDir, sessionId, "tool-results", "result.txt"),
|
|
"Read tool output with api_key=secret-value",
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(claudeRoot, "debug", `${sessionId}.txt`),
|
|
"Claude debug log with /Users/dotta/private-workspace and api_key=secret-value",
|
|
"utf8",
|
|
);
|
|
vi.stubEnv("CLAUDE_CONFIG_DIR", claudeRoot);
|
|
const uploadTraceBundle = vi.fn().mockResolvedValue({ objectKey: "feedback-traces/test.json" });
|
|
const flushingSvc = feedbackService(db, {
|
|
shareClient: {
|
|
uploadTraceBundle,
|
|
},
|
|
});
|
|
|
|
const { issueId, commentId } = await seedIssueWithAdapterRunComment({
|
|
adapterType: "claude_local",
|
|
sessionId,
|
|
});
|
|
|
|
const notified = vi.fn();
|
|
const unsubscribe = subscribeDeliveryWork(db, DELIVERY_QUEUES.feedback, notified);
|
|
await flushingSvc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: commentId,
|
|
vote: "up",
|
|
authorUserId: "user-1",
|
|
allowSharing: true,
|
|
});
|
|
unsubscribe();
|
|
expect(notified).toHaveBeenCalledTimes(1);
|
|
expect(await flushingSvc.hasPendingFeedbackTraces()).toBe(true);
|
|
// The route and worker may request a flush together after the commit.
|
|
await Promise.all([flushingSvc.flushPendingFeedbackTraces(), flushingSvc.flushPendingFeedbackTraces()]);
|
|
expect(await flushingSvc.hasPendingFeedbackTraces()).toBe(false);
|
|
|
|
expect(uploadTraceBundle).toHaveBeenCalledTimes(1);
|
|
const bundle = uploadTraceBundle.mock.calls[0]?.[0] as Record<string, unknown> | undefined;
|
|
const files = Array.isArray(bundle?.files) ? (bundle.files as Array<Record<string, unknown>>) : [];
|
|
const filePaths = files.map((file) => String(file.path));
|
|
const rawAdapterTrace = bundle?.rawAdapterTrace as Record<string, unknown> | null;
|
|
|
|
expect(bundle?.captureStatus).toBe("full");
|
|
expect(filePaths).toContain("adapter/claude/session.jsonl");
|
|
expect(filePaths).toContain("adapter/claude/session/tool-results/result.txt");
|
|
expect(filePaths).toContain("adapter/claude/debug.txt");
|
|
expect(rawAdapterTrace?.projectSessionFound).toBe(true);
|
|
expect(rawAdapterTrace?.projectArtifactsCount).toBe(1);
|
|
expect(rawAdapterTrace?.debugLogFound).toBe(true);
|
|
});
|
|
|
|
it("captures OpenCode message and part files as full traces", async () => {
|
|
const opencodeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-feedback-opencode-"));
|
|
tempDirs.push(opencodeRoot);
|
|
const sessionId = "ses_test_feedback_trace";
|
|
const sessionDir = path.join(opencodeRoot, "storage", "session", "global");
|
|
const messageDir = path.join(opencodeRoot, "storage", "message", sessionId);
|
|
const partDir = path.join(opencodeRoot, "storage", "part");
|
|
fs.mkdirSync(sessionDir, { recursive: true });
|
|
fs.mkdirSync(path.join(opencodeRoot, "storage", "session_diff"), { recursive: true });
|
|
fs.mkdirSync(messageDir, { recursive: true });
|
|
fs.mkdirSync(path.join(opencodeRoot, "storage", "project"), { recursive: true });
|
|
fs.mkdirSync(path.join(opencodeRoot, "storage", "todo"), { recursive: true });
|
|
const userMessageId = "msg_user_trace";
|
|
const assistantMessageId = "msg_assistant_trace";
|
|
fs.mkdirSync(path.join(partDir, userMessageId), { recursive: true });
|
|
fs.mkdirSync(path.join(partDir, assistantMessageId), { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(sessionDir, `${sessionId}.json`),
|
|
JSON.stringify({
|
|
id: sessionId,
|
|
projectID: "project-trace",
|
|
title: "Feedback export verification",
|
|
}),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(opencodeRoot, "storage", "session_diff", `${sessionId}.json`),
|
|
JSON.stringify([{ op: "replace", path: "/title", value: "Feedback export verification" }]),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(messageDir, `${userMessageId}.json`),
|
|
JSON.stringify({
|
|
id: userMessageId,
|
|
sessionID: sessionId,
|
|
role: "user",
|
|
summary: { title: "Continue the issue" },
|
|
}),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(messageDir, `${assistantMessageId}.json`),
|
|
JSON.stringify({
|
|
id: assistantMessageId,
|
|
sessionID: sessionId,
|
|
role: "assistant",
|
|
finish: "tool-calls",
|
|
}),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(partDir, userMessageId, "prt_prompt.json"),
|
|
JSON.stringify({
|
|
id: "prt_prompt",
|
|
sessionID: sessionId,
|
|
messageID: userMessageId,
|
|
type: "text",
|
|
text: "Open AGENTS.md and continue the task.",
|
|
}),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(partDir, assistantMessageId, "prt_tool.json"),
|
|
JSON.stringify({
|
|
id: "prt_tool",
|
|
sessionID: sessionId,
|
|
messageID: assistantMessageId,
|
|
type: "tool",
|
|
tool: "read",
|
|
state: {
|
|
status: "completed",
|
|
input: { filePath: "/tmp/AGENTS.md" },
|
|
output: "api_key=secret-value",
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(opencodeRoot, "storage", "project", "project-trace.json"),
|
|
JSON.stringify({
|
|
id: "project-trace",
|
|
worktree: "/Users/dotta/project",
|
|
}),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(opencodeRoot, "storage", "todo", `${sessionId}.json`),
|
|
JSON.stringify([{ content: "Verify exported traces" }]),
|
|
"utf8",
|
|
);
|
|
vi.stubEnv("PAPERCLIP_OPENCODE_STORAGE_DIR", opencodeRoot);
|
|
const uploadTraceBundle = vi.fn().mockResolvedValue({ objectKey: "feedback-traces/test.json" });
|
|
const flushingSvc = feedbackService(db, {
|
|
shareClient: {
|
|
uploadTraceBundle,
|
|
},
|
|
});
|
|
|
|
const { issueId, commentId } = await seedIssueWithAdapterRunComment({
|
|
adapterType: "opencode_local",
|
|
sessionId,
|
|
});
|
|
|
|
await flushingSvc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: commentId,
|
|
vote: "up",
|
|
authorUserId: "user-1",
|
|
allowSharing: true,
|
|
});
|
|
await flushingSvc.flushPendingFeedbackTraces();
|
|
|
|
expect(uploadTraceBundle).toHaveBeenCalledTimes(1);
|
|
const bundle = uploadTraceBundle.mock.calls[0]?.[0] as Record<string, unknown> | undefined;
|
|
const files = Array.isArray(bundle?.files) ? (bundle.files as Array<Record<string, unknown>>) : [];
|
|
const filePaths = files.map((file) => String(file.path));
|
|
const rawAdapterTrace = bundle?.rawAdapterTrace as Record<string, unknown> | null;
|
|
|
|
expect(bundle?.captureStatus).toBe("full");
|
|
expect(filePaths).toContain("adapter/opencode/session.json");
|
|
expect(filePaths).toContain("adapter/opencode/session-diff.json");
|
|
expect(filePaths).toContain(`adapter/opencode/messages/${userMessageId}.json`);
|
|
expect(filePaths).toContain(`adapter/opencode/parts/${assistantMessageId}/prt_tool.json`);
|
|
expect(filePaths).toContain("adapter/opencode/project.json");
|
|
expect(filePaths).toContain("adapter/opencode/todo.json");
|
|
expect(rawAdapterTrace?.messageFilesCount).toBe(2);
|
|
expect(rawAdapterTrace?.partFilesCount).toBe(2);
|
|
});
|
|
|
|
it("rejects feedback votes on human-authored comments", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const commentId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `H${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Human-authored comment",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: "user-1",
|
|
});
|
|
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-2",
|
|
body: "Board comment",
|
|
});
|
|
|
|
await expect(
|
|
svc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: commentId,
|
|
vote: "up",
|
|
authorUserId: "user-1",
|
|
}),
|
|
).rejects.toThrow("Feedback voting is only available on agent-authored issue comments");
|
|
});
|
|
|
|
it("flushes pending shared traces into configured object storage and marks them sent", async () => {
|
|
const { companyId, issueId, commentId } = await seedIssueWithAgentComment();
|
|
const uploadTraceBundle = vi.fn().mockResolvedValue({
|
|
objectKey: `feedback-traces/${companyId}/2026/04/01/test-trace.json`,
|
|
});
|
|
const flushingSvc = feedbackService(db, {
|
|
shareClient: {
|
|
uploadTraceBundle,
|
|
},
|
|
});
|
|
|
|
await flushingSvc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: commentId,
|
|
vote: "up",
|
|
authorUserId: "user-1",
|
|
allowSharing: true,
|
|
});
|
|
|
|
const flushResult = await flushingSvc.flushPendingFeedbackTraces();
|
|
expect(flushResult).toMatchObject({
|
|
attempted: 1,
|
|
sent: 1,
|
|
failed: 0,
|
|
});
|
|
|
|
const traces = await flushingSvc.listFeedbackTraces({
|
|
companyId,
|
|
issueId,
|
|
includePayload: true,
|
|
});
|
|
expect(traces[0]?.status).toBe("sent");
|
|
expect(traces[0]?.attemptCount).toBe(1);
|
|
expect(traces[0]?.exportedAt).toBeInstanceOf(Date);
|
|
expect(traces[0]?.failureReason).toBeNull();
|
|
expect(uploadTraceBundle).toHaveBeenCalledTimes(1);
|
|
expect(uploadTraceBundle.mock.calls[0]?.[0]).toMatchObject({
|
|
traceId: traces[0]?.id,
|
|
exportId: traces[0]?.exportId,
|
|
companyId,
|
|
issueId,
|
|
issueIdentifier: traces[0]?.issueIdentifier,
|
|
captureStatus: expect.stringMatching(/^(full|partial|unavailable)$/),
|
|
envelope: {
|
|
destination: "paperclip_labs_feedback_v1",
|
|
exportId: traces[0]?.exportId,
|
|
},
|
|
});
|
|
});
|
|
|
|
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"));
|
|
const flushingSvc = feedbackService(db, {
|
|
shareClient: {
|
|
uploadTraceBundle,
|
|
},
|
|
});
|
|
|
|
await flushingSvc.saveIssueVote({
|
|
issueId,
|
|
targetType: "issue_comment",
|
|
targetId: commentId,
|
|
vote: "up",
|
|
authorUserId: "user-1",
|
|
allowSharing: true,
|
|
});
|
|
|
|
const flushResult = await flushingSvc.flushPendingFeedbackTraces();
|
|
expect(flushResult).toMatchObject({
|
|
attempted: 1,
|
|
sent: 0,
|
|
failed: 1,
|
|
});
|
|
|
|
const traces = await flushingSvc.listFeedbackTraces({
|
|
companyId,
|
|
issueId,
|
|
includePayload: true,
|
|
});
|
|
expect(traces[0]?.status).toBe("failed");
|
|
expect(traces[0]?.attemptCount).toBe(1);
|
|
expect(traces[0]?.lastAttemptedAt).toBeInstanceOf(Date);
|
|
expect(traces[0]?.failureReason).toContain("telemetry unavailable");
|
|
expect(traces[0]?.exportedAt).toBeNull();
|
|
expect(uploadTraceBundle).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("leaves aborted and unstarted exports available for recovery after shutdown", async () => {
|
|
const first = await seedIssueWithAgentComment();
|
|
const second = await seedIssueWithAgentComment();
|
|
const shutdown = new AbortController();
|
|
const uploadTraceBundle = vi.fn().mockResolvedValue({ objectKey: "saved" })
|
|
.mockImplementationOnce(async (_bundle, signal: AbortSignal) => {
|
|
shutdown.abort(new Error("shutdown"));
|
|
signal.throwIfAborted();
|
|
});
|
|
const flushingSvc = feedbackService(db, { shareClient: { uploadTraceBundle } });
|
|
for (const item of [first, second]) {
|
|
await flushingSvc.saveIssueVote({ issueId: item.issueId, targetType: "issue_comment",
|
|
targetId: item.commentId, vote: "up", authorUserId: "user-1", allowSharing: true });
|
|
}
|
|
expect(await flushingSvc.flushPendingFeedbackTraces({ signal: shutdown.signal }))
|
|
.toMatchObject({ attempted: 1, sent: 0, failed: 1 });
|
|
const rows = await db.select().from(feedbackExports);
|
|
expect(rows.filter(row => row.status === "failed")).toHaveLength(1);
|
|
expect(rows.filter(row => row.status === "pending")).toHaveLength(1);
|
|
expect(await flushingSvc.hasPendingFeedbackTraces()).toBe(true);
|
|
expect(await flushingSvc.flushPendingFeedbackTraces()).toMatchObject({ attempted: 2, sent: 2, failed: 0 });
|
|
expect(await flushingSvc.hasPendingFeedbackTraces()).toBe(false);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|