mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-26 01:35:18 +02:00
Compare commits
21 Commits
PAPA-45-up
...
v2026.403.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a07237779b | ||
|
|
21dd6acb81 | ||
|
|
8adae848e4 | ||
|
|
00898e8194 | ||
|
|
ed95fc1dda | ||
|
|
e13c3f7c6c | ||
|
|
f8452a4520 | ||
|
|
68b2fe20bb | ||
|
|
aa256fee03 | ||
|
|
728fbdd199 | ||
|
|
9b3ad6e616 | ||
|
|
37b6ad42ea | ||
|
|
2ac40aba56 | ||
|
|
8db0c7fd2f | ||
|
|
993a3262f6 | ||
|
|
a13a67de54 | ||
|
|
422dd51a87 | ||
|
|
a80edfd6d9 | ||
|
|
931678db83 | ||
|
|
dda63a4324 | ||
|
|
43fa9c3a9a |
@@ -11,8 +11,9 @@ We really appreciate both small fixes and thoughtful larger changes.
|
||||
- Pick **one** clear thing to fix/improve
|
||||
- Touch the **smallest possible number of files**
|
||||
- Make sure the change is very targeted and easy to review
|
||||
- All automated checks pass (including Greptile comments)
|
||||
- No new lint/test failures
|
||||
- All tests pass and CI is green
|
||||
- Greptile score is 5/5 with all comments addressed
|
||||
- Use the [PR template](.github/PULL_REQUEST_TEMPLATE.md)
|
||||
|
||||
These almost always get merged quickly when they're clean.
|
||||
|
||||
@@ -26,11 +27,26 @@ These almost always get merged quickly when they're clean.
|
||||
- Before / After screenshots (or short video if UI/behavior change)
|
||||
- Clear description of what & why
|
||||
- Proof it works (manual testing notes)
|
||||
- All tests passing
|
||||
- All Greptile + other PR comments addressed
|
||||
- All tests passing and CI green
|
||||
- Greptile score 5/5 with all comments addressed
|
||||
- [PR template](.github/PULL_REQUEST_TEMPLATE.md) fully filled out
|
||||
|
||||
PRs that follow this path are **much** more likely to be accepted, even when they're large.
|
||||
|
||||
## PR Requirements (all PRs)
|
||||
|
||||
### Use the PR Template
|
||||
|
||||
Every pull request **must** follow the PR template at [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). If you create a PR via the GitHub API or other tooling that bypasses the template, copy its contents into your PR description manually. The template includes required sections: Thinking Path, What Changed, Verification, Risks, and a Checklist.
|
||||
|
||||
### Tests Must Pass
|
||||
|
||||
All tests must pass before a PR can be merged. Run them locally first and verify CI is green after pushing.
|
||||
|
||||
### Greptile Review
|
||||
|
||||
We use [Greptile](https://greptile.com) for automated code review. Your PR must achieve a **5/5 Greptile score** with **all Greptile comments addressed** before it can be merged. If Greptile leaves comments, fix or respond to each one and request a re-review.
|
||||
|
||||
## General Rules (both paths)
|
||||
|
||||
- Write clear commit messages
|
||||
@@ -41,7 +57,7 @@ PRs that follow this path are **much** more likely to be accepted, even when the
|
||||
|
||||
## Writing a Good PR message
|
||||
|
||||
Please include a "thinking path" at the top of your PR message that explains from the top of the project down to what you fixed. E.g.:
|
||||
Your PR description must follow the [PR template](.github/PULL_REQUEST_TEMPLATE.md). All sections are required. The "thinking path" at the top explains from the top of the project down to what you fixed. E.g.:
|
||||
|
||||
### Thinking Path Example 1:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export class TelemetryClient {
|
||||
app,
|
||||
schemaVersion,
|
||||
installId: state.installId,
|
||||
version: this.version,
|
||||
events,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
|
||||
@@ -23,6 +23,48 @@ export function trackCompanyImported(
|
||||
});
|
||||
}
|
||||
|
||||
export function trackProjectCreated(client: TelemetryClient): void {
|
||||
client.track("project.created");
|
||||
}
|
||||
|
||||
export function trackRoutineCreated(client: TelemetryClient): void {
|
||||
client.track("routine.created");
|
||||
}
|
||||
|
||||
export function trackRoutineRun(
|
||||
client: TelemetryClient,
|
||||
dims: { source: string; status: string },
|
||||
): void {
|
||||
client.track("routine.run", {
|
||||
source: dims.source,
|
||||
status: dims.status,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackGoalCreated(
|
||||
client: TelemetryClient,
|
||||
dims?: { goalLevel?: string | null },
|
||||
): void {
|
||||
client.track("goal.created", dims?.goalLevel ? { goal_level: dims.goalLevel } : undefined);
|
||||
}
|
||||
|
||||
export function trackAgentCreated(
|
||||
client: TelemetryClient,
|
||||
dims: { agentRole: string },
|
||||
): void {
|
||||
client.track("agent.created", { agent_role: dims.agentRole });
|
||||
}
|
||||
|
||||
export function trackSkillImported(
|
||||
client: TelemetryClient,
|
||||
dims: { sourceType: string; skillRef?: string | null },
|
||||
): void {
|
||||
client.track("skill.imported", {
|
||||
source_type: dims.sourceType,
|
||||
...(dims.skillRef ? { skill_ref: dims.skillRef } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function trackAgentFirstHeartbeat(
|
||||
client: TelemetryClient,
|
||||
dims: { agentRole: string },
|
||||
|
||||
@@ -5,6 +5,12 @@ export {
|
||||
trackInstallStarted,
|
||||
trackInstallCompleted,
|
||||
trackCompanyImported,
|
||||
trackProjectCreated,
|
||||
trackRoutineCreated,
|
||||
trackRoutineRun,
|
||||
trackGoalCreated,
|
||||
trackAgentCreated,
|
||||
trackSkillImported,
|
||||
trackAgentFirstHeartbeat,
|
||||
trackAgentTaskCompleted,
|
||||
trackErrorHandlerCrash,
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface TelemetryEventEnvelope {
|
||||
app: string;
|
||||
schemaVersion: string;
|
||||
installId: string;
|
||||
version: string;
|
||||
events: TelemetryEvent[];
|
||||
}
|
||||
|
||||
@@ -31,6 +32,12 @@ export type TelemetryEventName =
|
||||
| "install.started"
|
||||
| "install.completed"
|
||||
| "company.imported"
|
||||
| "project.created"
|
||||
| "routine.created"
|
||||
| "routine.run"
|
||||
| "goal.created"
|
||||
| "agent.created"
|
||||
| "skill.imported"
|
||||
| "agent.first_heartbeat"
|
||||
| "agent.task_completed"
|
||||
| "error.handler_crash"
|
||||
|
||||
@@ -143,6 +143,7 @@ export interface Issue {
|
||||
mentionedProjects?: Project[];
|
||||
myLastTouchAt?: Date | null;
|
||||
lastExternalCommentAt?: Date | null;
|
||||
lastActivityAt?: Date | null;
|
||||
isUnreadForMe?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
68
releases/v2026.403.0.md
Normal file
68
releases/v2026.403.0.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# v2026.403.0
|
||||
|
||||
> Released: 2026-04-03
|
||||
|
||||
## Highlights
|
||||
|
||||
- **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))
|
||||
- **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.
|
||||
- **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)
|
||||
- **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.
|
||||
- **CLI onboarding** — Onboarding reruns now preserve existing config; exported tsx CLI entrypoint for cleaner startup. ([#2071](https://github.com/paperclipai/paperclip/pull/2071))
|
||||
- **Board delegation guide** — New documentation for board-operator delegation patterns. ([#1889](https://github.com/paperclipai/paperclip/pull/1889))
|
||||
- **Agent capabilities in org chart** — Agent capabilities field now renders on org chart cards. ([#2349](https://github.com/paperclipai/paperclip/pull/2349))
|
||||
- **PR template updates** — Added Model Used section to PR template; CONTRIBUTING.md now requires PR template, Greptile 5/5, and tests. ([#2552](https://github.com/paperclipai/paperclip/pull/2552), [#2618](https://github.com/paperclipai/paperclip/pull/2618))
|
||||
- **Hermes adapter upgrade** — Upgraded hermes-paperclip-adapter with UI adapter and skills support, plus detectModel improvements.
|
||||
- **Markdown editor monospace** — Agent instruction file editors now use monospace font. ([#2620](https://github.com/paperclipai/paperclip/pull/2620))
|
||||
- **Markdown link styling** — Links in markdown now render with underline and pointer cursor.
|
||||
- **@-mention autocomplete** — Mention autocomplete in project descriptions now renders via portal to prevent overflow clipping.
|
||||
- **Skipped wakeup messages** — Agent detail view now surfaces skipped wakeup messages for better observability.
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Inbox ordering** — Self-touched issues no longer sink to the bottom of the inbox. ([#2144](https://github.com/paperclipai/paperclip/pull/2144))
|
||||
- **Env var type switching** — Switching an env var from Plain to Secret no longer loses the value; dropdown snap-back when switching is fixed. ([#2327](https://github.com/paperclipai/paperclip/pull/2327), @radiusred)
|
||||
- **Adapter type switching** — Adapter-agnostic keys are now preserved when changing adapter type.
|
||||
- **Project slug collisions** — Non-ASCII project names no longer produce duplicate slugs; a short UUID suffix is appended. ([#2328](https://github.com/paperclipai/paperclip/pull/2328), @bittoby)
|
||||
- **Codex RPC spawn error** — Fixed CodexRpcClient crash on ENOENT when spawning Codex. ([#2048](https://github.com/paperclipai/paperclip/pull/2048), @remdev)
|
||||
- **Heartbeat session reuse** — Fixed stale session reuse across heartbeat runs. ([#2065](https://github.com/paperclipai/paperclip/pull/2065), @edimuj)
|
||||
- **Vite HMR with reverse proxy** — Fixed WebSocket HMR connections behind reverse proxies and added StrictMode guard. ([#2171](https://github.com/paperclipai/paperclip/pull/2171))
|
||||
- **Copy button fallback** — Copy-to-clipboard now works in non-secure (HTTP) contexts. ([#2472](https://github.com/paperclipai/paperclip/pull/2472))
|
||||
- **Worktree default branch** — Worktree creation auto-detects the default branch when baseRef is not configured. ([#2463](https://github.com/paperclipai/paperclip/pull/2463))
|
||||
- **Session continuity** — Timer and heartbeat wakes now preserve session continuity.
|
||||
- **Worktree isolation** — Fixed worktree provision isolation, runtime recovery, and sibling port collisions.
|
||||
- **Cursor adapter auth** — Cursor adapter now checks native auth before warning about missing API key.
|
||||
- **Codex skill injection** — Fixed skill injection to use effective `$CODEX_HOME/skills/` instead of cwd.
|
||||
- **OpenCode config pollution** — Prevented `opencode.json` config pollution in workspace directories.
|
||||
- **Pi adapter** — Fixed Pi local adapter execution, transcript parsing, and model detection from stderr.
|
||||
- **x-forwarded-host origin check** — Board mutation origin check now includes x-forwarded-host header.
|
||||
- **Health DB probe** — Fixed database connectivity health check probe.
|
||||
- **Issue breadcrumb routing** — Hardened issue breadcrumb source routing.
|
||||
- **Instructions tab width** — Removed max-w-6xl constraint from instructions tab for full-width content. ([#2621](https://github.com/paperclipai/paperclip/pull/2621))
|
||||
- **Shell fallback on Windows** — Uses `sh` instead of `/bin/sh` as shell fallback on Windows. ([#891](https://github.com/paperclipai/paperclip/pull/891))
|
||||
- **Feedback migration** — Made feedback migration replay-safe after rebase.
|
||||
- **Issue detail polish** — Polished issue detail timelines and attachments display.
|
||||
|
||||
## Upgrade Guide
|
||||
|
||||
Four new database migrations (`0045`–`0048`) will run automatically on startup. These migrations add workspace lifecycle columns, routine variables, feedback tables, and document revision tracking. All migrations are additive — no existing data is modified.
|
||||
|
||||
If you use execution workspaces, note that follow-up issues now automatically inherit workspace linkage from their parent. For non-child follow-ups tied to the same workspace, set `inheritExecutionWorkspaceFromIssueId` explicitly when creating the issue.
|
||||
|
||||
## Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release!
|
||||
|
||||
@aronprins, @bittoby, @cryppadotta, @edimuj, @HenkDz, @kevmok, @mvanhorn, @radiusred, @remdev, @statxc, @vanductai
|
||||
92
scripts/screenshot.cjs
Normal file
92
scripts/screenshot.cjs
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Screenshot utility for Paperclip UI.
|
||||
*
|
||||
* Reads the board token from ~/.paperclip/auth.json and injects it as a
|
||||
* Bearer header so Playwright can access authenticated pages.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/screenshot.cjs <url-or-path> [output.png] [--width 1280] [--height 800] [--wait 2000]
|
||||
*
|
||||
* Examples:
|
||||
* node scripts/screenshot.cjs /PAPA/agents/cto/instructions /tmp/shot.png
|
||||
* node scripts/screenshot.cjs http://localhost:5173/PAPA/agents/cto/instructions
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
// --- CLI args -----------------------------------------------------------
|
||||
const args = process.argv.slice(2);
|
||||
function flag(name, fallback) {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
if (i === -1) return fallback;
|
||||
const val = args.splice(i, 2)[1];
|
||||
return Number.isNaN(Number(val)) ? fallback : Number(val);
|
||||
}
|
||||
const width = flag("width", 1280);
|
||||
const height = flag("height", 800);
|
||||
const waitMs = flag("wait", 2000);
|
||||
|
||||
const rawUrl = args[0];
|
||||
const outPath = args[1] || "/tmp/paperclip-screenshot.png";
|
||||
|
||||
if (!rawUrl) {
|
||||
console.error("Usage: node scripts/screenshot.cjs <url-or-path> [output.png]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Auth ----------------------------------------------------------------
|
||||
function loadBoardToken() {
|
||||
const authPath = path.resolve(os.homedir(), ".paperclip/auth.json");
|
||||
try {
|
||||
const auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
|
||||
const creds = auth.credentials || {};
|
||||
const entry = Object.values(creds)[0];
|
||||
if (entry && entry.token && entry.apiBase) return { token: entry.token, apiBase: entry.apiBase };
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const cred = loadBoardToken();
|
||||
if (!cred) {
|
||||
console.error("No board token found in ~/.paperclip/auth.json");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve URL — if it starts with / treat as path relative to apiBase
|
||||
const url = rawUrl.startsWith("http") ? rawUrl : `${cred.apiBase}${rawUrl}`;
|
||||
|
||||
// Validate URL before launching browser
|
||||
const origin = new URL(url).origin;
|
||||
|
||||
// --- Screenshot ----------------------------------------------------------
|
||||
(async () => {
|
||||
const { chromium } = require("playwright");
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
// Scope the auth header to the Paperclip origin only
|
||||
await page.route(`${origin}/**`, async (route) => {
|
||||
await route.continue({
|
||||
headers: { ...route.request().headers(), Authorization: `Bearer ${cred.token}` },
|
||||
});
|
||||
});
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 20000 });
|
||||
await page.waitForTimeout(waitMs);
|
||||
await page.screenshot({ path: outPath, fullPage: false });
|
||||
console.log(`Saved: ${outPath}`);
|
||||
} catch (err) {
|
||||
console.error(`Screenshot failed: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -51,12 +51,22 @@ const mockSecretService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockAdapter = vi.hoisted(() => ({
|
||||
listSkills: vi.fn(),
|
||||
syncSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentCreated: mockTrackAgentCreated,
|
||||
}));
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
@@ -132,6 +142,7 @@ function makeAgent(adapterType: string) {
|
||||
describe("agent skill routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockAgentService.resolveByReference.mockResolvedValue({
|
||||
ambiguous: false,
|
||||
agent: makeAgent("claude_local"),
|
||||
@@ -330,6 +341,9 @@ describe("agent skill routes", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockTrackAgentCreated).toHaveBeenCalledWith(expect.anything(), {
|
||||
agentRole: "engineer",
|
||||
});
|
||||
});
|
||||
|
||||
it("materializes a managed AGENTS.md for directly created local agents", async () => {
|
||||
|
||||
@@ -18,6 +18,22 @@ const mockCompanySkillService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackSkillImported = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
|
||||
"@paperclipai/shared/telemetry",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
trackSkillImported: mockTrackSkillImported,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
@@ -41,6 +57,7 @@ function createApp(actor: Record<string, unknown>) {
|
||||
describe("company skill mutation permissions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockCompanySkillService.importFromSource.mockResolvedValue({
|
||||
imported: [],
|
||||
warnings: [],
|
||||
@@ -68,6 +85,140 @@ describe("company skill mutation permissions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks public GitHub skill imports with an explicit skill reference", async () => {
|
||||
mockCompanySkillService.importFromSource.mockResolvedValue({
|
||||
imported: [
|
||||
{
|
||||
id: "skill-1",
|
||||
companyId: "company-1",
|
||||
key: "vercel-labs/agent-browser/find-skills",
|
||||
slug: "find-skills",
|
||||
name: "Find Skills",
|
||||
description: null,
|
||||
markdown: "# Find Skills",
|
||||
sourceType: "github",
|
||||
sourceLocator: "https://github.com/vercel-labs/agent-browser",
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [],
|
||||
metadata: {
|
||||
hostname: "github.com",
|
||||
owner: "vercel-labs",
|
||||
repo: "agent-browser",
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const res = await request(createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), {
|
||||
sourceType: "github",
|
||||
skillRef: "vercel-labs/agent-browser/find-skills",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose a skill reference for non-public skill imports", async () => {
|
||||
mockCompanySkillService.importFromSource.mockResolvedValue({
|
||||
imported: [
|
||||
{
|
||||
id: "skill-1",
|
||||
companyId: "company-1",
|
||||
key: "private-skill",
|
||||
slug: "private-skill",
|
||||
name: "Private Skill",
|
||||
description: null,
|
||||
markdown: "# Private Skill",
|
||||
sourceType: "github",
|
||||
sourceLocator: "https://ghe.example.com/acme/private-skill",
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [],
|
||||
metadata: {
|
||||
hostname: "ghe.example.com",
|
||||
owner: "acme",
|
||||
repo: "private-skill",
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const res = await request(createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://ghe.example.com/acme/private-skill" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), {
|
||||
sourceType: "github",
|
||||
skillRef: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose a skill reference when GitHub metadata is missing", async () => {
|
||||
mockCompanySkillService.importFromSource.mockResolvedValue({
|
||||
imported: [
|
||||
{
|
||||
id: "skill-1",
|
||||
companyId: "company-1",
|
||||
key: "unknown/private-skill",
|
||||
slug: "private-skill",
|
||||
name: "Private Skill",
|
||||
description: null,
|
||||
markdown: "# Private Skill",
|
||||
sourceType: "github",
|
||||
sourceLocator: "https://github.com/acme/private-skill",
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [],
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const res = await request(createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/acme/private-skill" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), {
|
||||
sourceType: "github",
|
||||
skillRef: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks same-company agents without management permission from mutating company skills", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
101
server/src/__tests__/feedback-share-client.test.ts
Normal file
101
server/src/__tests__/feedback-share-client.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
115
server/src/__tests__/project-goal-telemetry-routes.test.ts
Normal file
115
server/src/__tests__/project-goal-telemetry-routes.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { projectRoutes } from "../routes/projects.js";
|
||||
import { goalRoutes } from "../routes/goals.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockProjectService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
createWorkspace: vi.fn(),
|
||||
resolveByReference: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGoalService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
|
||||
const mockTrackGoalCreated = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
|
||||
"@paperclipai/shared/telemetry",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
trackProjectCreated: mockTrackProjectCreated,
|
||||
trackGoalCreated: mockTrackGoalCreated,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
goalService: () => mockGoalService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/workspace-runtime.js", () => ({
|
||||
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
function createApp(route: ReturnType<typeof projectRoutes> | ReturnType<typeof goalRoutes>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", route);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("project and goal telemetry routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
mockProjectService.create.mockResolvedValue({
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
name: "Telemetry project",
|
||||
description: null,
|
||||
status: "backlog",
|
||||
});
|
||||
mockGoalService.create.mockResolvedValue({
|
||||
id: "goal-1",
|
||||
companyId: "company-1",
|
||||
title: "Telemetry goal",
|
||||
description: null,
|
||||
level: "team",
|
||||
status: "planned",
|
||||
});
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("emits telemetry when a project is created", async () => {
|
||||
const res = await request(createApp(projectRoutes({} as any)))
|
||||
.post("/api/companies/company-1/projects")
|
||||
.send({ name: "Telemetry project" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockTrackProjectCreated).toHaveBeenCalledWith(expect.anything());
|
||||
});
|
||||
|
||||
it("emits telemetry when a goal is created", async () => {
|
||||
const res = await request(createApp(goalRoutes({} as any)))
|
||||
.post("/api/companies/company-1/goals")
|
||||
.send({ title: "Telemetry goal", level: "team" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockTrackGoalCreated).toHaveBeenCalledWith(expect.anything(), { goalLevel: "team" });
|
||||
});
|
||||
});
|
||||
163
server/src/__tests__/routine-run-telemetry.test.ts
Normal file
163
server/src/__tests__/routine-run-telemetry.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
executionWorkspaces,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
routineRuns,
|
||||
routines,
|
||||
routineTriggers,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() }));
|
||||
const mockTrackRoutineRun = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: () => mockTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
|
||||
"@paperclipai/shared/telemetry",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
trackRoutineRun: mockTrackRoutineRun,
|
||||
};
|
||||
});
|
||||
|
||||
import { routineService } from "../services/routines.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
describeEmbeddedPostgres("routine run telemetry", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routine-telemetry-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await db.delete(routineRuns);
|
||||
await db.delete(routineTriggers);
|
||||
await db.delete(routines);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedFixture() {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const projectId = 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: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Routines",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
const svc = routineService(db, {
|
||||
heartbeat: {
|
||||
wakeup: async (wakeupAgentId, wakeupOpts) => {
|
||||
const issueId =
|
||||
(typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId)
|
||||
|| (typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId)
|
||||
|| null;
|
||||
if (!issueId) return null;
|
||||
const queuedRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: queuedRunId,
|
||||
companyId,
|
||||
agentId: wakeupAgentId,
|
||||
invocationSource: wakeupOpts.source ?? "assignment",
|
||||
triggerDetail: wakeupOpts.triggerDetail ?? null,
|
||||
status: "queued",
|
||||
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
|
||||
});
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
executionRunId: queuedRunId,
|
||||
executionLockedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, issueId));
|
||||
return { id: queuedRunId };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const routine = await svc.create(
|
||||
companyId,
|
||||
{
|
||||
projectId,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "Run telemetry test",
|
||||
description: "Routine body",
|
||||
assigneeAgentId: agentId,
|
||||
priority: "medium",
|
||||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return { routine, svc };
|
||||
}
|
||||
|
||||
it("emits telemetry for routine runs from the service layer", async () => {
|
||||
const { routine, svc } = await seedFixture();
|
||||
|
||||
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
||||
|
||||
expect(run.status).toBe("issue_created");
|
||||
expect(mockTrackRoutineRun).toHaveBeenCalledWith(mockTelemetryClient, {
|
||||
source: "manual",
|
||||
status: "issue_created",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,22 @@ const mockAccessService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackRoutineCreated = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
|
||||
"@paperclipai/shared/telemetry",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
trackRoutineCreated: mockTrackRoutineCreated,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
@@ -104,6 +120,7 @@ function createApp(actor: Record<string, unknown>) {
|
||||
describe("routine routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockRoutineService.create.mockResolvedValue(routine);
|
||||
mockRoutineService.get.mockResolvedValue(routine);
|
||||
mockRoutineService.getTrigger.mockResolvedValue(trigger);
|
||||
@@ -267,5 +284,6 @@ describe("routine routes", () => {
|
||||
agentId: null,
|
||||
userId: "board-user",
|
||||
});
|
||||
expect(mockTrackRoutineCreated).toHaveBeenCalledWith(expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,25 @@ describe("TelemetryClient periodic flush", () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
const lastCall = vi.mocked(fetch).mock.calls.at(-1);
|
||||
expect(lastCall?.[0]).toBe("http://localhost:9999/ingest");
|
||||
const requestInit = lastCall?.[1] as RequestInit | undefined;
|
||||
expect(requestInit?.method).toBe("POST");
|
||||
expect(requestInit?.headers).toEqual({ "Content-Type": "application/json" });
|
||||
const body = JSON.parse(String(requestInit?.body ?? "{}"));
|
||||
expect(body).toMatchObject({
|
||||
app: "paperclip",
|
||||
schemaVersion: "1",
|
||||
installId: "test-install",
|
||||
version: "0.0.0-test",
|
||||
events: [
|
||||
{
|
||||
name: "install.started",
|
||||
dimensions: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(body.events[0]?.occurredAt).toEqual(expect.any(String));
|
||||
|
||||
// Second tick with no new events — no additional call
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
@@ -5,6 +5,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { parse as parseEnvContents } from "dotenv";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
@@ -540,13 +541,12 @@ describe("realizeExecutionWorkspace", () => {
|
||||
path.join(expectedInstanceRoot, "secrets", "master.key"),
|
||||
);
|
||||
expect(envContents).not.toContain("DATABASE_URL=");
|
||||
expect(envContents).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedWorktreeHome)}`);
|
||||
expect(envContents).toContain(`PAPERCLIP_INSTANCE_ID=${JSON.stringify(expectedInstanceId)}`);
|
||||
expect(envContents).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`);
|
||||
expect(envContents).toContain("PAPERCLIP_IN_WORKTREE=true");
|
||||
expect(envContents).toContain(
|
||||
`PAPERCLIP_WORKTREE_NAME=${JSON.stringify("PAP-885-show-worktree-banner")}`,
|
||||
);
|
||||
const envVars = parseEnvContents(envContents);
|
||||
expect(envVars.PAPERCLIP_HOME).toBe(isolatedWorktreeHome);
|
||||
expect(envVars.PAPERCLIP_INSTANCE_ID).toBe(expectedInstanceId);
|
||||
expect(await fs.realpath(envVars.PAPERCLIP_CONFIG!)).toBe(await fs.realpath(configPath));
|
||||
expect(envVars.PAPERCLIP_IN_WORKTREE).toBe("true");
|
||||
expect(envVars.PAPERCLIP_WORKTREE_NAME).toBe("PAP-885-show-worktree-banner");
|
||||
|
||||
process.chdir(workspace.cwd);
|
||||
expect(resolvePaperclipConfigPath()).toBe(configPath);
|
||||
|
||||
@@ -67,6 +67,7 @@ export async function createApp(
|
||||
feedbackExportService?: {
|
||||
flushPendingFeedbackTraces(input?: {
|
||||
companyId?: string;
|
||||
traceId?: string;
|
||||
limit?: number;
|
||||
now?: Date;
|
||||
}): Promise<unknown>;
|
||||
@@ -152,7 +153,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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
readPaperclipSkillSyncPreference,
|
||||
writePaperclipSkillSyncPreference,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { trackAgentCreated } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
agentService,
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
loadDefaultAgentInstructionsBundle,
|
||||
resolveDefaultAgentInstructionsBundleRole,
|
||||
} from "../services/default-agent-instructions.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
||||
export function agentRoutes(db: Db) {
|
||||
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
||||
@@ -1387,6 +1389,10 @@ export function agentRoutes(db: Db) {
|
||||
desiredSkills: desiredSkillAssignment.desiredSkills,
|
||||
},
|
||||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
trackAgentCreated(telemetryClient, { agentRole: agent.role });
|
||||
}
|
||||
|
||||
await applyDefaultAgentTaskAssignGrant(
|
||||
companyId,
|
||||
@@ -1469,6 +1475,10 @@ export function agentRoutes(db: Db) {
|
||||
desiredSkills: desiredSkillAssignment.desiredSkills,
|
||||
},
|
||||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
trackAgentCreated(telemetryClient, { agentRole: agent.role });
|
||||
}
|
||||
|
||||
await applyDefaultAgentTaskAssignGrant(
|
||||
companyId,
|
||||
|
||||
@@ -6,10 +6,20 @@ import {
|
||||
companySkillImportSchema,
|
||||
companySkillProjectScanRequestSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackSkillImported } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { accessService, agentService, companySkillService, logActivity } from "../services/index.js";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
||||
type SkillTelemetryInput = {
|
||||
key: string;
|
||||
slug: string;
|
||||
sourceType: string;
|
||||
sourceLocator: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function companySkillRoutes(db: Db) {
|
||||
const router = Router();
|
||||
@@ -22,6 +32,26 @@ export function companySkillRoutes(db: Db) {
|
||||
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function deriveTrackedSkillRef(skill: SkillTelemetryInput): string | null {
|
||||
if (skill.sourceType === "skills_sh") {
|
||||
return skill.key;
|
||||
}
|
||||
if (skill.sourceType !== "github") {
|
||||
return null;
|
||||
}
|
||||
const hostname = asString(skill.metadata?.hostname);
|
||||
if (hostname !== "github.com") {
|
||||
return null;
|
||||
}
|
||||
return skill.key;
|
||||
}
|
||||
|
||||
async function assertCanMutateCompanySkills(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
@@ -183,6 +213,15 @@ export function companySkillRoutes(db: Db) {
|
||||
warningCount: result.warnings.length,
|
||||
},
|
||||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
for (const skill of result.imported) {
|
||||
trackSkillImported(telemetryClient, {
|
||||
sourceType: skill.sourceType,
|
||||
skillRef: deriveTrackedSkillRef(skill),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(result);
|
||||
},
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { createGoalSchema, updateGoalSchema } from "@paperclipai/shared";
|
||||
import { trackGoalCreated } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { goalService, logActivity } from "../services/index.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
||||
export function goalRoutes(db: Db) {
|
||||
const router = Router();
|
||||
@@ -42,6 +44,10 @@ export function goalRoutes(db: Db) {
|
||||
entityId: goal.id,
|
||||
details: { title: goal.title },
|
||||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
trackGoalCreated(telemetryClient, { goalLevel: goal.level });
|
||||
}
|
||||
res.status(201).json(goal);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
updateProjectSchema,
|
||||
updateProjectWorkspaceSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { projectService, logActivity, workspaceOperationService } from "../services/index.js";
|
||||
import { conflict } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
||||
export function projectRoutes(db: Db) {
|
||||
const router = Router();
|
||||
@@ -107,6 +109,10 @@ export function projectRoutes(db: Db) {
|
||||
workspaceId: createdWorkspaceId,
|
||||
},
|
||||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
trackProjectCreated(telemetryClient);
|
||||
}
|
||||
res.status(201).json(hydratedProject ?? project);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
updateRoutineSchema,
|
||||
updateRoutineTriggerSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackRoutineCreated } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { accessService, logActivity, routineService } from "../services/index.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { forbidden, unauthorized } from "../errors.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
||||
export function routineRoutes(db: Db) {
|
||||
const router = Router();
|
||||
@@ -76,6 +78,10 @@ export function routineRoutes(db: Db) {
|
||||
entityId: created.id,
|
||||
details: { title: created.title, assigneeAgentId: created.assigneeAgentId },
|
||||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
trackRoutineCreated(telemetryClient);
|
||||
}
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,8 +31,10 @@ import {
|
||||
stringifyRoutineVariableValue,
|
||||
syncRoutineVariablesWithTemplate,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackRoutineRun } from "@paperclipai/shared/telemetry";
|
||||
import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { parseCron, validateCron } from "./cron.js";
|
||||
@@ -856,6 +858,14 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||
}
|
||||
}
|
||||
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
trackRoutineRun(telemetryClient, {
|
||||
source: run.source,
|
||||
status: run.status,
|
||||
});
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
|
||||
@@ -336,10 +336,24 @@ function CommentCard({
|
||||
sharingPreference={feedbackDataSharingPreference}
|
||||
termsUrl={feedbackTermsUrl}
|
||||
onVote={onVote}
|
||||
rightSlot={comment.runId && !isPending ? (
|
||||
comment.runAgentId ? (
|
||||
<Link
|
||||
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
|
||||
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
run {comment.runId.slice(0, 8)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
|
||||
run {comment.runId.slice(0, 8)}
|
||||
</span>
|
||||
)
|
||||
) : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{comment.runId && !isPending ? (
|
||||
<div className="mt-2 pt-2 border-t border-border/60">
|
||||
{comment.runId && !isPending && !(comment.authorAgentId && onVote && !isQueued) ? (
|
||||
<div className="mt-3 pt-3 border-t border-border/60">
|
||||
{comment.runAgentId ? (
|
||||
<Link
|
||||
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -32,6 +33,7 @@ import { AgentIcon } from "./AgentIconPicker";
|
||||
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
||||
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
|
||||
import { mentionDeletionPlugin } from "../lib/mention-deletion";
|
||||
import { looksLikeMarkdownPaste, normalizePastedMarkdown } from "../lib/markdownPaste";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
/* ---- Mention types ---- */
|
||||
@@ -167,6 +169,24 @@ function detectMention(container: HTMLElement): MentionState | null {
|
||||
};
|
||||
}
|
||||
|
||||
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
|
||||
if (!node || !container.contains(node)) return false;
|
||||
const el = node.nodeType === Node.ELEMENT_NODE
|
||||
? (node as HTMLElement)
|
||||
: node.parentElement;
|
||||
return Boolean(el?.closest("pre, code"));
|
||||
}
|
||||
|
||||
function isSelectionInsideCodeLikeElement(container: HTMLElement | null) {
|
||||
if (!container) return false;
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return false;
|
||||
for (const node of [selection.anchorNode, selection.focusNode]) {
|
||||
if (nodeInsideCodeLike(container, node)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mentionMarkdown(option: MentionOption): string {
|
||||
if (option.kind === "project" && option.projectId) {
|
||||
return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `;
|
||||
@@ -199,11 +219,17 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
onSubmit,
|
||||
}: MarkdownEditorProps, forwardedRef) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<MDXEditorMethods | null>(null);
|
||||
const ref = useRef<MDXEditorMethods>(null);
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
const latestValueRef = useRef(value);
|
||||
const latestPropValueRef = useRef(value);
|
||||
const pendingExternalValueRef = useRef<string | null>(null);
|
||||
const isFocusedRef = useRef(false);
|
||||
const initialChildOnChangeRef = useRef(true);
|
||||
/**
|
||||
* After imperative `setMarkdown` (prop sync, mentions, image upload), MDXEditor may emit `onChange`
|
||||
* with the same markdown. Skip notifying the parent for that echo so controlled parents that
|
||||
* normalize or transform values cannot loop. Replaces the older blur/focus gate for the same concern.
|
||||
*/
|
||||
const echoIgnoreMarkdownRef = useRef<string | null>(null);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
@@ -237,9 +263,19 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
|
||||
}, [mentionState?.query, mentions]);
|
||||
|
||||
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
|
||||
ref.current = instance;
|
||||
if (instance) {
|
||||
const v = valueRef.current;
|
||||
echoIgnoreMarkdownRef.current = v;
|
||||
instance.setMarkdown(v);
|
||||
latestValueRef.current = v;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: () => {
|
||||
editorRef.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||
},
|
||||
}), []);
|
||||
|
||||
@@ -266,10 +302,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
);
|
||||
if (updated !== current) {
|
||||
latestValueRef.current = updated;
|
||||
editorRef.current?.setMarkdown(updated);
|
||||
echoIgnoreMarkdownRef.current = updated;
|
||||
ref.current?.setMarkdown(updated);
|
||||
onChange(updated);
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
@@ -303,29 +340,14 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
return all;
|
||||
}, [hasImageUpload]);
|
||||
|
||||
const handleEditorRef = useCallback((instance: MDXEditorMethods | null) => {
|
||||
editorRef.current = instance;
|
||||
if (!instance) return;
|
||||
|
||||
const pendingValue = pendingExternalValueRef.current;
|
||||
if (pendingValue !== null && pendingValue !== latestValueRef.current) {
|
||||
instance.setMarkdown(pendingValue);
|
||||
latestValueRef.current = pendingValue;
|
||||
}
|
||||
pendingExternalValueRef.current = null;
|
||||
}, []);
|
||||
|
||||
latestPropValueRef.current = value;
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== latestValueRef.current) {
|
||||
if (!editorRef.current) {
|
||||
pendingExternalValueRef.current = value;
|
||||
return;
|
||||
if (ref.current) {
|
||||
// Pair with onChange echo suppression (echoIgnoreMarkdownRef).
|
||||
echoIgnoreMarkdownRef.current = value;
|
||||
ref.current.setMarkdown(value);
|
||||
latestValueRef.current = value;
|
||||
}
|
||||
editorRef.current.setMarkdown(value);
|
||||
latestValueRef.current = value;
|
||||
pendingExternalValueRef.current = null;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
@@ -416,7 +438,8 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
const next = applyMention(current, state.query, option);
|
||||
if (next !== current) {
|
||||
latestValueRef.current = next;
|
||||
editorRef.current?.setMarkdown(next);
|
||||
echoIgnoreMarkdownRef.current = next;
|
||||
ref.current?.setMarkdown(next);
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
@@ -486,6 +509,19 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
}
|
||||
|
||||
const canDropImage = Boolean(imageUploadHandler);
|
||||
const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard || !ref.current) return;
|
||||
const types = new Set(Array.from(clipboard.types));
|
||||
if (types.has("Files") || types.has("text/html")) return;
|
||||
if (isSelectionInsideCodeLikeElement(containerRef.current)) return;
|
||||
|
||||
const rawText = clipboard.getData("text/plain");
|
||||
if (!looksLikeMarkdownPaste(rawText)) return;
|
||||
|
||||
event.preventDefault();
|
||||
ref.current.insertMarkdown(normalizePastedMarkdown(rawText));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -563,35 +599,31 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
dragDepthRef.current = 0;
|
||||
setIsDragOver(false);
|
||||
}}
|
||||
onFocusCapture={() => {
|
||||
isFocusedRef.current = true;
|
||||
}}
|
||||
onBlurCapture={() => {
|
||||
isFocusedRef.current = false;
|
||||
}}
|
||||
onPasteCapture={handlePasteCapture}
|
||||
>
|
||||
<MDXEditor
|
||||
ref={handleEditorRef}
|
||||
ref={setEditorRef}
|
||||
markdown={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(next) => {
|
||||
const externalValue = latestPropValueRef.current;
|
||||
if (!isFocusedRef.current) {
|
||||
if (next === externalValue) {
|
||||
latestValueRef.current = externalValue;
|
||||
return;
|
||||
}
|
||||
|
||||
latestValueRef.current = externalValue;
|
||||
if (editorRef.current) {
|
||||
editorRef.current.setMarkdown(externalValue);
|
||||
pendingExternalValueRef.current = null;
|
||||
} else {
|
||||
pendingExternalValueRef.current = externalValue;
|
||||
}
|
||||
const echo = echoIgnoreMarkdownRef.current;
|
||||
if (echo !== null && next === echo) {
|
||||
echoIgnoreMarkdownRef.current = null;
|
||||
latestValueRef.current = next;
|
||||
return;
|
||||
}
|
||||
if (echo !== null) {
|
||||
echoIgnoreMarkdownRef.current = null;
|
||||
}
|
||||
|
||||
if (initialChildOnChangeRef.current) {
|
||||
initialChildOnChangeRef.current = false;
|
||||
if (next === "" && value !== "") {
|
||||
echoIgnoreMarkdownRef.current = value;
|
||||
ref.current?.setMarkdown(value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
latestValueRef.current = next;
|
||||
onChange(next);
|
||||
}}
|
||||
|
||||
@@ -19,12 +19,14 @@ export function OutputFeedbackButtons({
|
||||
sharingPreference = "prompt",
|
||||
termsUrl = null,
|
||||
onVote,
|
||||
rightSlot,
|
||||
}: {
|
||||
activeVote?: FeedbackVoteValue | null;
|
||||
disabled?: boolean;
|
||||
sharingPreference?: FeedbackDataSharingPreference;
|
||||
termsUrl?: string | null;
|
||||
onVote: (vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }) => Promise<void>;
|
||||
rightSlot?: React.ReactNode;
|
||||
}) {
|
||||
const [pendingVote, setPendingVote] = useState<{
|
||||
vote: FeedbackVoteValue;
|
||||
@@ -130,6 +132,7 @@ export function OutputFeedbackButtons({
|
||||
<ThumbsDown className="mr-1.5 h-3.5 w-3.5" />
|
||||
Needs work
|
||||
</Button>
|
||||
{rightSlot ? <div className="ml-auto">{rightSlot}</div> : null}
|
||||
</div>
|
||||
{collectingDownvoteReason ? (
|
||||
<div className="mt-2 rounded-md border border-border/60 bg-accent/20 p-3">
|
||||
@@ -216,6 +219,7 @@ export function OutputFeedbackButtons({
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!pendingVote || isSaving}
|
||||
onClick={() => {
|
||||
if (!pendingVote) return;
|
||||
|
||||
@@ -142,6 +142,11 @@
|
||||
label {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
/* Let font-mono (utilities layer) override for monospace editors */
|
||||
.paperclip-mdxeditor [class*="_placeholder_"],
|
||||
.paperclip-mdxeditor-content {
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
@@ -319,14 +324,12 @@
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor [class*="_placeholder_"] {
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
|
||||
@@ -180,6 +180,7 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue {
|
||||
labelIds: [],
|
||||
myLastTouchAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
lastExternalCommentAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
isUnreadForMe,
|
||||
};
|
||||
}
|
||||
@@ -357,10 +358,10 @@ describe("inbox helpers", () => {
|
||||
|
||||
it("mixes approvals into the inbox feed by most recent activity", () => {
|
||||
const newerIssue = makeIssue("1", true);
|
||||
newerIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
|
||||
newerIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
|
||||
|
||||
const olderIssue = makeIssue("2", false);
|
||||
olderIssue.lastExternalCommentAt = new Date("2026-03-11T02:00:00.000Z");
|
||||
olderIssue.lastActivityAt = new Date("2026-03-11T02:00:00.000Z");
|
||||
|
||||
const approval = makeApprovalWithTimestamps(
|
||||
"approval-between",
|
||||
@@ -385,19 +386,21 @@ describe("inbox helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts touched issues by latest external comment timestamp", () => {
|
||||
const newerIssue = makeIssue("1", true);
|
||||
newerIssue.lastExternalCommentAt = new Date("2026-03-11T05:00:00.000Z");
|
||||
it("prefers canonical lastActivityAt over comment-only timestamps", () => {
|
||||
const activityIssue = makeIssue("1", true);
|
||||
activityIssue.lastExternalCommentAt = new Date("2026-03-11T01:00:00.000Z");
|
||||
activityIssue.lastActivityAt = new Date("2026-03-11T05:00:00.000Z");
|
||||
|
||||
const olderIssue = makeIssue("2", true);
|
||||
olderIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
|
||||
const commentIssue = makeIssue("2", true);
|
||||
commentIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
|
||||
commentIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
|
||||
|
||||
expect(getRecentTouchedIssues([olderIssue, newerIssue]).map((issue) => issue.id)).toEqual(["1", "2"]);
|
||||
expect(getRecentTouchedIssues([commentIssue, activityIssue]).map((issue) => issue.id)).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("mixes join requests into the inbox feed by most recent activity", () => {
|
||||
const issue = makeIssue("1", true);
|
||||
issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
|
||||
issue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
|
||||
|
||||
const joinRequest = makeJoinRequest("join-1");
|
||||
joinRequest.createdAt = new Date("2026-03-11T03:00:00.000Z");
|
||||
@@ -482,7 +485,7 @@ describe("inbox helpers", () => {
|
||||
it("limits recent touched issues before unread badge counting", () => {
|
||||
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
|
||||
const issue = makeIssue(String(index + 1), index < 3);
|
||||
issue.lastExternalCommentAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000);
|
||||
issue.lastActivityAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000);
|
||||
return issue;
|
||||
});
|
||||
|
||||
|
||||
@@ -217,6 +217,9 @@ export function normalizeTimestamp(value: string | Date | null | undefined): num
|
||||
}
|
||||
|
||||
export function issueLastActivityTimestamp(issue: Issue): number {
|
||||
const lastActivityAt = normalizeTimestamp(issue.lastActivityAt);
|
||||
if (lastActivityAt > 0) return lastActivityAt;
|
||||
|
||||
const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt);
|
||||
if (lastExternalCommentAt > 0) return lastExternalCommentAt;
|
||||
|
||||
|
||||
50
ui/src/lib/markdownPaste.test.ts
Normal file
50
ui/src/lib/markdownPaste.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeMarkdownPaste, normalizePastedMarkdown } from "./markdownPaste";
|
||||
|
||||
describe("markdownPaste", () => {
|
||||
it("normalizes windows line endings", () => {
|
||||
expect(normalizePastedMarkdown("a\r\nb\r\n")).toBe("a\nb\n");
|
||||
});
|
||||
|
||||
it("normalizes old mac line endings", () => {
|
||||
expect(normalizePastedMarkdown("a\rb\r")).toBe("a\nb\n");
|
||||
});
|
||||
|
||||
it("treats markdown blocks as markdown paste", () => {
|
||||
expect(looksLikeMarkdownPaste("# Title\n\n- item 1\n- item 2")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a fenced code block as markdown paste", () => {
|
||||
expect(looksLikeMarkdownPaste("```\nconst x = 1;\n```")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a tilde fence as markdown paste", () => {
|
||||
expect(looksLikeMarkdownPaste("~~~\nraw\n~~~")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a blockquote as markdown paste", () => {
|
||||
expect(looksLikeMarkdownPaste("> some quoted text")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an ordered list as markdown paste", () => {
|
||||
expect(looksLikeMarkdownPaste("1. first\n2. second")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a table row as markdown paste", () => {
|
||||
expect(looksLikeMarkdownPaste("| col1 | col2 |")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats horizontal rules as markdown paste", () => {
|
||||
expect(looksLikeMarkdownPaste("---")).toBe(true);
|
||||
expect(looksLikeMarkdownPaste("***")).toBe(true);
|
||||
expect(looksLikeMarkdownPaste("___")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves plain multi-line text on the native paste path", () => {
|
||||
expect(looksLikeMarkdownPaste("first paragraph\nsecond paragraph")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves single-line plain text on the native paste path", () => {
|
||||
expect(looksLikeMarkdownPaste("just a sentence")).toBe(false);
|
||||
});
|
||||
});
|
||||
23
ui/src/lib/markdownPaste.ts
Normal file
23
ui/src/lib/markdownPaste.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
const BLOCK_MARKER_PATTERNS = [
|
||||
/^#{1,6}\s+/m,
|
||||
/^>\s+/m,
|
||||
/^[-*+]\s+/m,
|
||||
/^\d+\.\s+/m,
|
||||
/^```/m,
|
||||
/^~~~/m,
|
||||
/^\|.+\|$/m,
|
||||
/^---$/m,
|
||||
/^\*\*\*$/m,
|
||||
/^___$/m,
|
||||
];
|
||||
|
||||
export function normalizePastedMarkdown(text: string): string {
|
||||
return text.replace(/\r\n?/g, "\n");
|
||||
}
|
||||
|
||||
export function looksLikeMarkdownPaste(text: string): boolean {
|
||||
const normalized = normalizePastedMarkdown(text).trim();
|
||||
if (!normalized) return false;
|
||||
|
||||
return BLOCK_MARKER_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
@@ -1917,7 +1917,7 @@ function PromptsTab({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<div className="space-y-6">
|
||||
{(bundle?.warnings ?? []).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{(bundle?.warnings ?? []).map((warning) => (
|
||||
|
||||
@@ -56,6 +56,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
labelIds: [],
|
||||
myLastTouchAt: null,
|
||||
lastExternalCommentAt: null,
|
||||
lastActivityAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
isUnreadForMe: false,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -214,7 +214,7 @@ export function InboxIssueMetaLeading({
|
||||
}
|
||||
|
||||
function issueActivityText(issue: Issue): string {
|
||||
return `Updated ${timeAgo(issue.lastExternalCommentAt ?? issue.updatedAt)}`;
|
||||
return `Updated ${timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt)}`;
|
||||
}
|
||||
|
||||
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
|
||||
@@ -246,7 +246,7 @@ export function InboxIssueTrailingColumns({
|
||||
assigneeName: string | null;
|
||||
currentUserId: string | null;
|
||||
}) {
|
||||
const activityText = timeAgo(issue.lastExternalCommentAt ?? issue.updatedAt);
|
||||
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
||||
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user