From cc44d309c0a5f5bbc6d718d901f6e9ab27bae34d Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Tue, 7 Apr 2026 09:41:13 +0200 Subject: [PATCH 01/85] feat(backups): gzip compress backups and add retention config to Instance Settings Compress database backups with gzip (.sql.gz), reducing file size ~83%. Add backup retention configuration to Instance Settings UI with preset options (7 days, 2 weeks, 1 month). The backup scheduler now reads retention from the database on each tick so changes take effect without restart. Default retention changed from 30 to 7 days. --- packages/db/src/backup-lib.test.ts | 4 +- packages/db/src/backup-lib.ts | 24 +++++++++-- packages/shared/src/config-schema.ts | 4 +- packages/shared/src/index.ts | 6 +++ packages/shared/src/types/index.ts | 3 +- packages/shared/src/types/instance.ts | 5 +++ packages/shared/src/validators/instance.ts | 8 ++++ server/src/config.ts | 2 +- server/src/index.ts | 16 ++++--- server/src/services/instance-settings.ts | 3 ++ ui/src/pages/InstanceGeneralSettings.tsx | 49 +++++++++++++++++++++- 11 files changed, 107 insertions(+), 17 deletions(-) diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index dcdc87c5..2367d26d 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -129,8 +129,8 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { filenamePrefix: "paperclip-test", }); - expect(result.backupFile).toMatch(/paperclip-test-.*\.sql$/); - expect(result.sizeBytes).toBeGreaterThan(1024 * 1024); + expect(result.backupFile).toMatch(/paperclip-test-.*\.sql\.gz$/); + expect(result.sizeBytes).toBeGreaterThan(0); expect(fs.existsSync(result.backupFile)).toBe(true); await runDatabaseRestore({ diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index ea76a2b6..36b5ee98 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -1,6 +1,8 @@ import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; import { basename, resolve } from "node:path"; import { createInterface } from "node:readline"; +import { pipeline } from "node:stream/promises"; +import { createGunzip, createGzip } from "node:zlib"; import postgres from "postgres"; export type RunDatabaseBackupOptions = { @@ -82,7 +84,8 @@ function pruneOldBackups(backupDir: string, retentionDays: number, filenamePrefi let pruned = 0; for (const name of readdirSync(backupDir)) { - if (!name.startsWith(`${filenamePrefix}-`) || !name.endsWith(".sql")) continue; + if (!name.startsWith(`${filenamePrefix}-`)) continue; + if (!name.endsWith(".sql") && !name.endsWith(".sql.gz")) continue; const fullPath = resolve(backupDir, name); const stat = statSync(fullPath); if (stat.mtimeMs < cutoff) { @@ -148,7 +151,9 @@ function tableKey(schemaName: string, tableName: string): string { } async function* readRestoreStatements(backupFile: string): AsyncGenerator { - const stream = createReadStream(backupFile, { encoding: "utf8" }); + const raw = createReadStream(backupFile); + const stream = backupFile.endsWith(".gz") ? raw.pipe(createGunzip()) : raw; + stream.setEncoding("utf8"); const reader = createInterface({ input: stream, crlfDelay: Infinity, @@ -180,6 +185,7 @@ async function* readRestoreStatements(backupFile: string): AsyncGenerator + (BACKUP_RETENTION_PRESETS as readonly number[]).includes(v), + { message: `Must be one of: ${BACKUP_RETENTION_PRESETS.join(", ")}` }, +); + export const instanceGeneralSettingsSchema = z.object({ censorUsernameInLogs: z.boolean().default(false), keyboardShortcuts: z.boolean().default(false), feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default( DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, ), + backupRetentionDays: backupRetentionDaysSchema.default(DEFAULT_BACKUP_RETENTION_DAYS), }).strict(); export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial(); diff --git a/server/src/config.ts b/server/src/config.ts index 71084cc0..5ae50400 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -216,7 +216,7 @@ export function loadConfig(): Config { 1, Number(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) || fileDatabaseBackup?.retentionDays || - 30, + 7, ); const databaseBackupDir = resolveHomeAwarePath( process.env.PAPERCLIP_DB_BACKUP_DIR ?? diff --git a/server/src/index.ts b/server/src/index.ts index a384342f..768d1fd6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -31,6 +31,7 @@ import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { feedbackService, heartbeatService, + instanceSettingsService, reconcilePersistedRuntimeServicesOnStartup, routineService, } from "./services/index.js"; @@ -628,20 +629,25 @@ export async function startServer(): Promise { if (config.databaseBackupEnabled) { const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000; + const settingsSvc = instanceSettingsService(db); let backupInFlight = false; - + const runScheduledBackup = async () => { if (backupInFlight) { logger.warn("Skipping scheduled database backup because a previous backup is still running"); return; } - + backupInFlight = true; try { + // Read retention from Instance Settings (DB) so changes take effect without restart + const generalSettings = await settingsSvc.getGeneral(); + const retentionDays = generalSettings.backupRetentionDays; + const result = await runDatabaseBackup({ connectionString: activeDatabaseConnectionString, backupDir: config.databaseBackupDir, - retentionDays: config.databaseBackupRetentionDays, + retentionDays, filenamePrefix: "paperclip", }); logger.info( @@ -650,7 +656,7 @@ export async function startServer(): Promise { sizeBytes: result.sizeBytes, prunedCount: result.prunedCount, backupDir: config.databaseBackupDir, - retentionDays: config.databaseBackupRetentionDays, + retentionDays, }, `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`, ); @@ -660,7 +666,7 @@ export async function startServer(): Promise { backupInFlight = false; } }; - + logger.info( { intervalMinutes: config.databaseBackupIntervalMinutes, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 7856591d..5c7db61d 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -2,6 +2,7 @@ import type { Db } from "@paperclipai/db"; import { companies, instanceSettings } from "@paperclipai/db"; import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + DEFAULT_BACKUP_RETENTION_DAYS, instanceGeneralSettingsSchema, type InstanceGeneralSettings, instanceExperimentalSettingsSchema, @@ -22,12 +23,14 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { keyboardShortcuts: parsed.data.keyboardShortcuts ?? false, feedbackDataSharingPreference: parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + backupRetentionDays: parsed.data.backupRetentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS, }; } return { censorUsernameInLogs: false, keyboardShortcuts: false, feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + backupRetentionDays: DEFAULT_BACKUP_RETENTION_DAYS, }; } diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx index 28e00b29..9004e418 100644 --- a/ui/src/pages/InstanceGeneralSettings.tsx +++ b/ui/src/pages/InstanceGeneralSettings.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { PatchInstanceGeneralSettings } from "@paperclipai/shared"; -import { LogOut, SlidersHorizontal } from "lucide-react"; +import type { PatchInstanceGeneralSettings, BackupRetentionDays } from "@paperclipai/shared"; +import { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "@paperclipai/shared"; +import { Database, LogOut, SlidersHorizontal } from "lucide-react"; import { authApi } from "@/api/auth"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { Button } from "../components/ui/button"; @@ -67,6 +68,7 @@ export function InstanceGeneralSettings() { const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true; const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true; const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt"; + const backupRetentionDays: BackupRetentionDays = generalQuery.data?.backupRetentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS; return (
@@ -123,6 +125,49 @@ export function InstanceGeneralSettings() {
+
+
+
+ +
+

Backup retention

+

+ How long to keep automatic database backups before pruning. Backups are compressed + with gzip to minimize disk usage. +

+
+
+
+ {BACKUP_RETENTION_PRESETS.map((days) => { + const active = backupRetentionDays === days; + const label = + days === 7 ? "7 days" : days === 14 ? "2 weeks" : "1 month"; + return ( + + ); + })} +
+
+
+
-- 2.52.0 From fcbae62baf1e821e2b65f64545381defccda3230 Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Tue, 7 Apr 2026 09:54:39 +0200 Subject: [PATCH 02/85] feat(backups): tiered daily/weekly/monthly retention with UI controls Replace single retentionDays with a three-tier BackupRetentionPolicy: - Daily: keep all backups (presets: 3, 7, 14 days; default 7) - Weekly: keep one per calendar week (presets: 1, 2, 4 weeks; default 4) - Monthly: keep one per calendar month (presets: 1, 3, 6 months; default 1) Pruning sorts backups newest-first and applies each tier's cutoff, keeping only the newest entry per ISO week/month bucket. The Instance Settings General page now shows three preset selectors (no icon, matches existing page design). Remove Database icon import. --- cli/src/commands/db-backup.ts | 2 +- cli/src/commands/worktree.ts | 2 +- packages/db/src/backup-lib.test.ts | 2 +- packages/db/src/backup-lib.ts | 97 ++++++++++++-- packages/db/src/backup.ts | 4 +- packages/db/src/index.ts | 1 + packages/shared/src/index.ts | 8 +- packages/shared/src/types/index.ts | 4 +- packages/shared/src/types/instance.ts | 20 ++- packages/shared/src/validators/instance.ts | 26 +++- server/src/index.ts | 6 +- server/src/services/instance-settings.ts | 6 +- ui/src/pages/InstanceGeneralSettings.tsx | 144 +++++++++++++++------ 13 files changed, 243 insertions(+), 79 deletions(-) diff --git a/cli/src/commands/db-backup.ts b/cli/src/commands/db-backup.ts index bdbf739f..7e1adce5 100644 --- a/cli/src/commands/db-backup.ts +++ b/cli/src/commands/db-backup.ts @@ -73,7 +73,7 @@ export async function dbBackupCommand(opts: DbBackupOptions): Promise { const result = await runDatabaseBackup({ connectionString: connection.value, backupDir, - retentionDays, + retention: { dailyDays: retentionDays, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix, }); spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 3025e955..d67bd154 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -903,7 +903,7 @@ async function seedWorktreeDatabase(input: { const backup = await runDatabaseBackup({ connectionString: sourceConnectionString, backupDir: path.resolve(input.targetPaths.backupDir, "seed"), - retentionDays: 7, + retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: `${input.instanceId}-seed`, includeMigrationJournal: true, excludeTables: seedPlan.excludedTables, diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index 2367d26d..2ea9b070 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -125,7 +125,7 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { const result = await runDatabaseBackup({ connectionString: sourceConnectionString, backupDir, - retentionDays: 7, + retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: "paperclip-test", }); diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 36b5ee98..0fe70f13 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -5,10 +5,16 @@ import { pipeline } from "node:stream/promises"; import { createGunzip, createGzip } from "node:zlib"; import postgres from "postgres"; +export type BackupRetentionPolicy = { + dailyDays: number; + weeklyWeeks: number; + monthlyMonths: number; +}; + export type RunDatabaseBackupOptions = { connectionString: string; backupDir: string; - retentionDays: number; + retention: BackupRetentionPolicy; filenamePrefix?: string; connectTimeoutSeconds?: number; includeMigrationJournal?: boolean; @@ -77,24 +83,91 @@ function timestamp(date: Date = new Date()): string { return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; } -function pruneOldBackups(backupDir: string, retentionDays: number, filenamePrefix: string): number { +/** + * ISO week key for grouping backups by calendar week (ISO 8601). + */ +function isoWeekKey(date: Date): string { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`; +} + +function monthKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +/** + * Tiered backup pruning: + * - Daily tier: keep ALL backups from the last `dailyDays` days + * - Weekly tier: keep the NEWEST backup per calendar week for `weeklyWeeks` weeks + * - Monthly tier: keep the NEWEST backup per calendar month for `monthlyMonths` months + * - Everything else is deleted + */ +function pruneOldBackups(backupDir: string, retention: BackupRetentionPolicy, filenamePrefix: string): number { if (!existsSync(backupDir)) return 0; - const safeRetention = Math.max(1, Math.trunc(retentionDays)); - const cutoff = Date.now() - safeRetention * 24 * 60 * 60 * 1000; - let pruned = 0; + + const now = Date.now(); + const dailyCutoff = now - Math.max(1, retention.dailyDays) * 24 * 60 * 60 * 1000; + const weeklyCutoff = now - Math.max(1, retention.weeklyWeeks) * 7 * 24 * 60 * 60 * 1000; + const monthlyCutoff = now - Math.max(1, retention.monthlyMonths) * 30 * 24 * 60 * 60 * 1000; + + type BackupEntry = { name: string; fullPath: string; mtimeMs: number }; + const entries: BackupEntry[] = []; for (const name of readdirSync(backupDir)) { if (!name.startsWith(`${filenamePrefix}-`)) continue; if (!name.endsWith(".sql") && !name.endsWith(".sql.gz")) continue; const fullPath = resolve(backupDir, name); const stat = statSync(fullPath); - if (stat.mtimeMs < cutoff) { - unlinkSync(fullPath); - pruned++; - } + entries.push({ name, fullPath, mtimeMs: stat.mtimeMs }); } - return pruned; + // Sort newest first so the first entry per week/month bucket is the one we keep + entries.sort((a, b) => b.mtimeMs - a.mtimeMs); + + const keepWeekBuckets = new Set(); + const keepMonthBuckets = new Set(); + const toDelete: string[] = []; + + for (const entry of entries) { + // Daily tier — keep everything within dailyDays + if (entry.mtimeMs >= dailyCutoff) continue; + + const date = new Date(entry.mtimeMs); + const week = isoWeekKey(date); + const month = monthKey(date); + + // Weekly tier — keep newest per calendar week + if (entry.mtimeMs >= weeklyCutoff) { + if (keepWeekBuckets.has(week)) { + toDelete.push(entry.fullPath); + } else { + keepWeekBuckets.add(week); + } + continue; + } + + // Monthly tier — keep newest per calendar month + if (entry.mtimeMs >= monthlyCutoff) { + if (keepMonthBuckets.has(month)) { + toDelete.push(entry.fullPath); + } else { + keepMonthBuckets.add(month); + } + continue; + } + + // Beyond all retention tiers — delete + toDelete.push(entry.fullPath); + } + + for (const filePath of toDelete) { + unlinkSync(filePath); + } + + return toDelete.length; } function formatBackupSize(sizeBytes: number): string { @@ -287,7 +360,7 @@ export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise { const filenamePrefix = opts.filenamePrefix ?? "paperclip"; - const retentionDays = Math.max(1, Math.trunc(opts.retentionDays)); + const retention = opts.retention; const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); const includeMigrationJournal = opts.includeMigrationJournal === true; const excludedTableNames = normalizeTableNameSet(opts.excludeTables); @@ -678,7 +751,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise unlinkSync(sqlFile); const sizeBytes = statSync(backupFile).size; - const prunedCount = pruneOldBackups(opts.backupDir, retentionDays, filenamePrefix); + const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix); return { backupFile, diff --git a/packages/db/src/backup.ts b/packages/db/src/backup.ts index f07dc646..c11822c9 100644 --- a/packages/db/src/backup.ts +++ b/packages/db/src/backup.ts @@ -85,7 +85,7 @@ function resolveBackupDir(config: PartialConfig | null): string { } function resolveRetentionDays(config: PartialConfig | null): number { - return asPositiveInt(config?.database?.backup?.retentionDays) ?? 30; + return asPositiveInt(config?.database?.backup?.retentionDays) ?? 7; } async function main() { @@ -103,7 +103,7 @@ async function main() { const result = await runDatabaseBackup({ connectionString, backupDir, - retentionDays, + retention: { dailyDays: retentionDays, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: "paperclip", }); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index cf4a2633..200e2981 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -21,6 +21,7 @@ export { runDatabaseBackup, runDatabaseRestore, formatDatabaseBackupResult, + type BackupRetentionPolicy, type RunDatabaseBackupOptions, type RunDatabaseBackupResult, type RunDatabaseRestoreOptions, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ef9ce5ba..e9075356 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -189,7 +189,7 @@ export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, - BackupRetentionDays, + BackupRetentionPolicy, Agent, AgentAccessState, AgentChainOfCommandEntry, @@ -371,8 +371,10 @@ export { } from "./types/feedback.js"; export { - BACKUP_RETENTION_PRESETS, - DEFAULT_BACKUP_RETENTION_DAYS, + DAILY_RETENTION_PRESETS, + WEEKLY_RETENTION_PRESETS, + MONTHLY_RETENTION_PRESETS, + DEFAULT_BACKUP_RETENTION, } from "./types/instance.js"; export { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 20d38a7a..1f0ec2c7 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -11,8 +11,8 @@ export type { FeedbackTraceBundleFile, FeedbackTraceBundle, } from "./feedback.js"; -export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, BackupRetentionDays } from "./instance.js"; -export { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "./instance.js"; +export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, BackupRetentionPolicy } from "./instance.js"; +export { DAILY_RETENTION_PRESETS, WEEKLY_RETENTION_PRESETS, MONTHLY_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION } from "./instance.js"; export type { CompanySkillSourceType, CompanySkillTrustLevel, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 30f07f7d..4e83c925 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -1,14 +1,26 @@ import type { FeedbackDataSharingPreference } from "./feedback.js"; -export const BACKUP_RETENTION_PRESETS = [7, 14, 30] as const; -export type BackupRetentionDays = (typeof BACKUP_RETENTION_PRESETS)[number]; -export const DEFAULT_BACKUP_RETENTION_DAYS: BackupRetentionDays = 7; +export const DAILY_RETENTION_PRESETS = [3, 7, 14] as const; +export const WEEKLY_RETENTION_PRESETS = [1, 2, 4] as const; +export const MONTHLY_RETENTION_PRESETS = [1, 3, 6] as const; + +export interface BackupRetentionPolicy { + dailyDays: (typeof DAILY_RETENTION_PRESETS)[number]; + weeklyWeeks: (typeof WEEKLY_RETENTION_PRESETS)[number]; + monthlyMonths: (typeof MONTHLY_RETENTION_PRESETS)[number]; +} + +export const DEFAULT_BACKUP_RETENTION: BackupRetentionPolicy = { + dailyDays: 7, + weeklyWeeks: 4, + monthlyMonths: 1, +}; export interface InstanceGeneralSettings { censorUsernameInLogs: boolean; keyboardShortcuts: boolean; feedbackDataSharingPreference: FeedbackDataSharingPreference; - backupRetentionDays: BackupRetentionDays; + backupRetention: BackupRetentionPolicy; } export interface InstanceExperimentalSettings { diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index a62f1996..930e183f 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -1,13 +1,25 @@ import { z } from "zod"; import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE } from "../types/feedback.js"; -import { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "../types/instance.js"; +import { + DAILY_RETENTION_PRESETS, + WEEKLY_RETENTION_PRESETS, + MONTHLY_RETENTION_PRESETS, + DEFAULT_BACKUP_RETENTION, +} from "../types/instance.js"; import { feedbackDataSharingPreferenceSchema } from "./feedback.js"; -export const backupRetentionDaysSchema = z.number().refine( - (v): v is (typeof BACKUP_RETENTION_PRESETS)[number] => - (BACKUP_RETENTION_PRESETS as readonly number[]).includes(v), - { message: `Must be one of: ${BACKUP_RETENTION_PRESETS.join(", ")}` }, -); +function presetSchema(presets: T, label: string) { + return z.number().refine( + (v): v is T[number] => (presets as readonly number[]).includes(v), + { message: `${label} must be one of: ${presets.join(", ")}` }, + ); +} + +export const backupRetentionPolicySchema = z.object({ + dailyDays: presetSchema(DAILY_RETENTION_PRESETS, "dailyDays").default(DEFAULT_BACKUP_RETENTION.dailyDays), + weeklyWeeks: presetSchema(WEEKLY_RETENTION_PRESETS, "weeklyWeeks").default(DEFAULT_BACKUP_RETENTION.weeklyWeeks), + monthlyMonths: presetSchema(MONTHLY_RETENTION_PRESETS, "monthlyMonths").default(DEFAULT_BACKUP_RETENTION.monthlyMonths), +}); export const instanceGeneralSettingsSchema = z.object({ censorUsernameInLogs: z.boolean().default(false), @@ -15,7 +27,7 @@ export const instanceGeneralSettingsSchema = z.object({ feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default( DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, ), - backupRetentionDays: backupRetentionDaysSchema.default(DEFAULT_BACKUP_RETENTION_DAYS), + backupRetention: backupRetentionPolicySchema.default(DEFAULT_BACKUP_RETENTION), }).strict(); export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial(); diff --git a/server/src/index.ts b/server/src/index.ts index 768d1fd6..b63789a2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -642,12 +642,12 @@ export async function startServer(): Promise { try { // Read retention from Instance Settings (DB) so changes take effect without restart const generalSettings = await settingsSvc.getGeneral(); - const retentionDays = generalSettings.backupRetentionDays; + const retention = generalSettings.backupRetention; const result = await runDatabaseBackup({ connectionString: activeDatabaseConnectionString, backupDir: config.databaseBackupDir, - retentionDays, + retention, filenamePrefix: "paperclip", }); logger.info( @@ -656,7 +656,7 @@ export async function startServer(): Promise { sizeBytes: result.sizeBytes, prunedCount: result.prunedCount, backupDir: config.databaseBackupDir, - retentionDays, + retention, }, `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`, ); diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 5c7db61d..65a12632 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -2,7 +2,7 @@ import type { Db } from "@paperclipai/db"; import { companies, instanceSettings } from "@paperclipai/db"; import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, - DEFAULT_BACKUP_RETENTION_DAYS, + DEFAULT_BACKUP_RETENTION, instanceGeneralSettingsSchema, type InstanceGeneralSettings, instanceExperimentalSettingsSchema, @@ -23,14 +23,14 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { keyboardShortcuts: parsed.data.keyboardShortcuts ?? false, feedbackDataSharingPreference: parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, - backupRetentionDays: parsed.data.backupRetentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS, + backupRetention: parsed.data.backupRetention ?? DEFAULT_BACKUP_RETENTION, }; } return { censorUsernameInLogs: false, keyboardShortcuts: false, feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, - backupRetentionDays: DEFAULT_BACKUP_RETENTION_DAYS, + backupRetention: DEFAULT_BACKUP_RETENTION, }; } diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx index 9004e418..04c3638d 100644 --- a/ui/src/pages/InstanceGeneralSettings.tsx +++ b/ui/src/pages/InstanceGeneralSettings.tsx @@ -1,8 +1,13 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { PatchInstanceGeneralSettings, BackupRetentionDays } from "@paperclipai/shared"; -import { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "@paperclipai/shared"; -import { Database, LogOut, SlidersHorizontal } from "lucide-react"; +import type { PatchInstanceGeneralSettings, BackupRetentionPolicy } from "@paperclipai/shared"; +import { + DAILY_RETENTION_PRESETS, + WEEKLY_RETENTION_PRESETS, + MONTHLY_RETENTION_PRESETS, + DEFAULT_BACKUP_RETENTION, +} from "@paperclipai/shared"; +import { LogOut, SlidersHorizontal } from "lucide-react"; import { authApi } from "@/api/auth"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { Button } from "../components/ui/button"; @@ -68,7 +73,7 @@ export function InstanceGeneralSettings() { const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true; const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true; const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt"; - const backupRetentionDays: BackupRetentionDays = generalQuery.data?.backupRetentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS; + const backupRetention: BackupRetentionPolicy = generalQuery.data?.backupRetention ?? DEFAULT_BACKUP_RETENTION; return (
@@ -126,44 +131,103 @@ export function InstanceGeneralSettings() {
-
-
- -
-

Backup retention

-

- How long to keep automatic database backups before pruning. Backups are compressed - with gzip to minimize disk usage. -

+
+
+

Backup retention

+

+ Configure how long to keep automatic database backups at each tier. Daily backups + are kept in full, then thinned to one per week and one per month. Backups are + compressed with gzip. +

+
+ +
+

Daily

+
+ {DAILY_RETENTION_PRESETS.map((days) => { + const active = backupRetention.dailyDays === days; + return ( + + ); + })}
-
- {BACKUP_RETENTION_PRESETS.map((days) => { - const active = backupRetentionDays === days; - const label = - days === 7 ? "7 days" : days === 14 ? "2 weeks" : "1 month"; - return ( - - ); - })} + +
+

Weekly

+
+ {WEEKLY_RETENTION_PRESETS.map((weeks) => { + const active = backupRetention.weeklyWeeks === weeks; + const label = weeks === 1 ? "1 week" : `${weeks} weeks`; + return ( + + ); + })} +
+
+ +
+

Monthly

+
+ {MONTHLY_RETENTION_PRESETS.map((months) => { + const active = backupRetention.monthlyMonths === months; + const label = months === 1 ? "1 month" : `${months} months`; + return ( + + ); + })} +
-- 2.52.0 From b1e457365b5ab2df0721b58417df2c70ef3e1168 Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Tue, 7 Apr 2026 10:55:32 +0200 Subject: [PATCH 03/85] fix: clean up orphaned .sql on compression failure and fix stale startup log - backup-lib: delete uncompressed .sql file in catch block when gzip compression fails, preventing silent disk usage accumulation - server: replace stale retentionDays scalar with retentionSource in startup log since retention is now read from DB on each backup tick --- packages/db/src/backup-lib.ts | 3 +++ server/src/index.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 0fe70f13..e1e88724 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -763,6 +763,9 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise if (existsSync(backupFile)) { try { unlinkSync(backupFile); } catch { /* ignore */ } } + if (existsSync(sqlFile)) { + try { unlinkSync(sqlFile); } catch { /* ignore */ } + } throw error; } finally { await sql.end(); diff --git a/server/src/index.ts b/server/src/index.ts index b63789a2..c3f4a8b1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -670,7 +670,7 @@ export async function startServer(): Promise { logger.info( { intervalMinutes: config.databaseBackupIntervalMinutes, - retentionDays: config.databaseBackupRetentionDays, + retentionSource: "instance-settings-db", backupDir: config.databaseBackupDir, }, "Automatic database backups enabled", -- 2.52.0 From 03dff1a29afaebe8705027e17926175aec9ec5bc Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 9 Apr 2026 10:26:17 -0500 Subject: [PATCH 04/85] Refine issue workflow surfaces and live updates --- server/src/__tests__/activity-routes.test.ts | 3 +- .../issue-execution-policy-routes.test.ts | 77 +- ...issue-update-comment-wakeup-routes.test.ts | 202 +++++ server/src/routes/issues.ts | 74 +- server/src/services/activity.ts | 11 +- server/src/services/heartbeat.ts | 12 - ui/src/api/activity.ts | 2 + ui/src/components/ActiveAgentsPanel.tsx | 4 +- ui/src/components/CommentThread.tsx | 425 +++++------ ui/src/components/InstanceSidebar.tsx | 2 + ui/src/components/IssueChatThread.tsx | 99 ++- .../components/IssueDocumentsSection.test.tsx | 47 ++ ui/src/components/IssueDocumentsSection.tsx | 8 +- ui/src/components/IssueFiltersPopover.tsx | 232 ++++++ ui/src/components/IssueRow.tsx | 11 +- ui/src/components/IssuesList.test.tsx | 63 ++ ui/src/components/IssuesList.tsx | 291 ++----- ui/src/components/IssuesQuicklook.tsx | 3 +- ui/src/components/Layout.tsx | 29 +- ui/src/components/LiveRunWidget.tsx | 24 +- ui/src/components/MarkdownEditor.test.tsx | 101 ++- ui/src/components/MarkdownEditor.tsx | 225 ++++-- ui/src/components/MobileBottomNav.tsx | 2 + ui/src/components/NewIssueDialog.test.tsx | 19 + ui/src/components/NewIssueDialog.tsx | 6 +- .../transcript/useLiveRunTranscripts.test.tsx | 73 ++ .../transcript/useLiveRunTranscripts.ts | 58 +- ui/src/context/LiveUpdatesProvider.test.ts | 76 ++ ui/src/context/LiveUpdatesProvider.tsx | 17 +- ui/src/fixtures/issueChatUxFixtures.ts | 17 + ui/src/lib/inbox.test.ts | 32 +- ui/src/lib/inbox.ts | 79 ++ ui/src/lib/issue-chat-messages.test.ts | 64 ++ ui/src/lib/issue-chat-messages.ts | 66 +- ui/src/lib/issue-filters.ts | 89 +++ ui/src/lib/issueChatTranscriptRuns.test.ts | 30 + ui/src/lib/issueChatTranscriptRuns.ts | 42 ++ ui/src/lib/issueDetailBreadcrumb.test.ts | 130 ++++ ui/src/lib/issueDetailBreadcrumb.ts | 73 ++ ui/src/lib/mention-aware-link-node.ts | 2 +- ui/src/lib/navigation-scroll.test.ts | 86 +++ ui/src/lib/navigation-scroll.ts | 45 ++ ui/src/lib/optimistic-issue-runs.test.ts | 3 + ui/src/lib/optimistic-issue-runs.ts | 2 + ui/src/pages/Inbox.tsx | 710 +++++++++++------- ui/src/pages/IssueChatUxLab.tsx | 22 + ui/src/pages/IssueDetail.tsx | 265 ++++--- ui/src/pages/Issues.tsx | 10 +- 48 files changed, 2800 insertions(+), 1163 deletions(-) create mode 100644 server/src/__tests__/issue-update-comment-wakeup-routes.test.ts create mode 100644 ui/src/components/IssueFiltersPopover.tsx create mode 100644 ui/src/lib/issue-filters.ts create mode 100644 ui/src/lib/issueChatTranscriptRuns.test.ts create mode 100644 ui/src/lib/issueChatTranscriptRuns.ts create mode 100644 ui/src/lib/navigation-scroll.test.ts create mode 100644 ui/src/lib/navigation-scroll.ts diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 0235fdbc..86ee374d 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -62,6 +62,7 @@ describe("activity routes", () => { mockActivityService.runsForIssue.mockResolvedValue([ { runId: "run-1", + adapterType: "codex_local", }, ]); @@ -72,6 +73,6 @@ describe("activity routes", () => { expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475"); expect(mockIssueService.getById).not.toHaveBeenCalled(); expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1"); - expect(res.body).toEqual([{ runId: "run-1" }]); + expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]); }); }); diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index 190cb077..4f8d9fc6 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -60,19 +60,17 @@ vi.mock("../services/index.js", () => ({ workProductService: () => ({}), })); -function createApp( - actor: Record = { - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }, -) { +function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = actor; + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; next(); }); app.use("/api", issueRoutes({} as any, {} as any)); @@ -139,63 +137,4 @@ describe("issue execution policy routes", () => { expect(updatePatch.executionState).toBeUndefined(); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); - - it("rejects agent stage advances from non-participants", async () => { - const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const approverAgentId = "44444444-4444-4444-8444-444444444444"; - const executorAgentId = "22222222-2222-4222-8222-222222222222"; - const policy = normalizeIssueExecutionPolicy({ - stages: [ - { - id: "11111111-1111-4111-8111-111111111111", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - { - id: "55555555-5555-4555-8555-555555555555", - type: "approval", - participants: [{ type: "agent", agentId: approverAgentId }], - }, - ], - })!; - const issue = { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - companyId: "company-1", - status: "in_review", - assigneeAgentId: reviewerAgentId, - assigneeUserId: null, - createdByUserId: "local-board", - identifier: "PAP-1000", - title: "Execution policy guard", - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: "11111111-1111-4111-8111-111111111111", - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { type: "agent", agentId: executorAgentId }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; - mockIssueService.getById.mockResolvedValue(issue); - - const res = await request( - createApp({ - type: "agent", - agentId: approverAgentId, - companyId: "company-1", - source: "api_key", - runId: "run-1", - }), - ) - .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") - .send({ status: "done", comment: "Skipping review." }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("active review participant"); - expect(mockIssueService.update).not.toHaveBeenCalled(); - }); }); diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts new file mode 100644 index 00000000..c6bf9177 --- /dev/null +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -0,0 +1,202 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; + +const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), + addComment: vi.fn(), + findMentionedAgents: vi.fn(), + getRelationSummaries: vi.fn(), + listWakeableBlockedDependents: vi.fn(), + getWakeableParentAfterChildCompletion: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), +})); + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(async () => true), + hasPermission: vi.fn(async () => true), + }), + agentService: () => ({ + getById: vi.fn(async () => null), + }), + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: vi.fn(async () => undefined), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), +})); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +function makeIssue(overrides: Record = {}) { + return { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + companyId: "company-1", + status: "todo", + priority: "medium", + projectId: null, + goalId: null, + parentId: null, + assigneeAgentId: null, + assigneeUserId: "local-board", + createdByUserId: "local-board", + identifier: "PAP-999", + title: "Wake test", + executionPolicy: null, + executionState: null, + hiddenAt: null, + ...overrides, + }; +} + +describe("issue update comment wakeups", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIssueService.findMentionedAgents.mockResolvedValue([]); + mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); + mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); + mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); + }); + + it("includes the new comment in assignment wakes from issue updates", async () => { + const existing = makeIssue(); + const updated = makeIssue({ + assigneeAgentId: ASSIGNEE_AGENT_ID, + assigneeUserId: null, + }); + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.update.mockResolvedValue(updated); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-1", + issueId: existing.id, + companyId: existing.companyId, + body: "write the whole thing", + }); + + const res = await request(createApp()) + .patch(`/api/issues/${existing.id}`) + .send({ + assigneeAgentId: ASSIGNEE_AGENT_ID, + assigneeUserId: null, + comment: "write the whole thing", + }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + source: "assignment", + reason: "issue_assigned", + payload: expect.objectContaining({ + issueId: existing.id, + commentId: "comment-1", + mutation: "update", + }), + contextSnapshot: expect.objectContaining({ + issueId: existing.id, + taskId: existing.id, + commentId: "comment-1", + wakeCommentId: "comment-1", + source: "issue.update", + }), + }), + ); + }); + + it("wakes the assignee on comment-only issue updates", async () => { + const existing = makeIssue({ + assigneeAgentId: ASSIGNEE_AGENT_ID, + assigneeUserId: null, + status: "in_progress", + }); + const updated = { ...existing }; + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.update.mockResolvedValue(updated); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-2", + issueId: existing.id, + companyId: existing.companyId, + body: "please revise this", + }); + + const res = await request(createApp()) + .patch(`/api/issues/${existing.id}`) + .send({ + comment: "please revise this", + }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + source: "automation", + reason: "issue_commented", + payload: expect.objectContaining({ + issueId: existing.id, + commentId: "comment-2", + mutation: "comment", + }), + contextSnapshot: expect.objectContaining({ + issueId: existing.id, + taskId: existing.id, + commentId: "comment-2", + wakeCommentId: "comment-2", + wakeReason: "issue_commented", + source: "issue.comment", + }), + }), + ); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 344c262c..6ffd524e 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -96,13 +96,6 @@ function executionPrincipalsEqual( return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId; } -function executionParticipantMatchesAgent( - participant: ParsedExecutionState["currentParticipant"] | null, - agentId: string | null | undefined, -) { - return Boolean(agentId) && participant?.type === "agent" && participant.agentId === agentId; -} - function buildExecutionStageWakeContext(input: { state: ParsedExecutionState; wakeRole: ExecutionStageWakeContext["wakeRole"]; @@ -1386,14 +1379,10 @@ export function issueRoutes( ? (updateFields.executionPolicy as NormalizedExecutionPolicy | null) : previousExecutionPolicy; - const requestedStatus = typeof updateFields.status === "string" ? updateFields.status : undefined; - const requestedAssigneePatchProvided = - req.body.assigneeAgentId !== undefined || req.body.assigneeUserId !== undefined; - const transition = applyIssueExecutionPolicyTransition({ issue: existing, policy: nextExecutionPolicy, - requestedStatus, + requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined, requestedAssigneePatch: { assigneeAgentId: req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null), @@ -1419,27 +1408,6 @@ export function issueRoutes( } Object.assign(updateFields, transition.patch); - const effectiveExecutionState = parseIssueExecutionState( - transition.patch.executionState !== undefined ? transition.patch.executionState : existing.executionState, - ); - const isUnauthorizedAgentStageMutation = - req.actor.type === "agent" && - req.actor.agentId && - existing.status === "in_review" && - transition.workflowControlledAssignment && - !transition.decision && - effectiveExecutionState?.status === "pending" && - ( - (requestedStatus !== undefined && requestedStatus !== "in_review") || - requestedAssigneePatchProvided - ) && - !executionParticipantMatchesAgent(effectiveExecutionState.currentParticipant, req.actor.agentId); - if (isUnauthorizedAgentStageMutation) { - const stageLabel = effectiveExecutionState.currentStageType ?? "execution"; - res.status(403).json({ error: `Only the active ${stageLabel} participant can update this stage` }); - return; - } - const nextAssigneeAgentId = updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null); const nextAssigneeUserId = @@ -1733,6 +1701,7 @@ export function issueRoutes( reason: "issue_assigned", payload: { issueId: issue.id, + ...(comment ? { commentId: comment.id } : {}), mutation: "update", ...(interruptedRunId ? { interruptedRunId } : {}), }, @@ -1740,6 +1709,13 @@ export function issueRoutes( requestedByActorId: actor.actorId, contextSnapshot: { issueId: issue.id, + ...(comment + ? { + taskId: issue.id, + commentId: comment.id, + wakeCommentId: comment.id, + } + : {}), source: "issue.update", ...(interruptedRunId ? { interruptedRunId } : {}), }, @@ -1767,6 +1743,38 @@ export function issueRoutes( } if (commentBody && comment) { + const assigneeId = issue.assigneeAgentId; + const actorIsAgent = actor.actorType === "agent"; + const selfComment = actorIsAgent && actor.actorId === assigneeId; + const skipAssigneeCommentWake = selfComment || isClosed; + + if (assigneeId && !assigneeChanged && !skipAssigneeCommentWake) { + addWakeup(assigneeId, { + source: "automation", + triggerDetail: "system", + reason: reopened ? "issue_reopened_via_comment" : "issue_commented", + payload: { + issueId: id, + commentId: comment.id, + mutation: "comment", + ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), + }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { + issueId: id, + taskId: id, + commentId: comment.id, + wakeCommentId: comment.id, + source: reopened ? "issue.comment.reopen" : "issue.comment", + wakeReason: reopened ? "issue_reopened_via_comment" : "issue_commented", + ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), + }, + }); + } + let mentionedIds: string[] = []; try { mentionedIds = await svc.findMentionedAgents(issue.companyId, commentBody); diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index a86d7f68..62e99530 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -1,6 +1,6 @@ import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { activityLog, heartbeatRuns, issues } from "@paperclipai/db"; +import { activityLog, agents, heartbeatRuns, issues } from "@paperclipai/db"; export interface ActivityFilters { companyId: string; @@ -66,14 +66,23 @@ export function activityService(db: Db) { runId: heartbeatRuns.id, status: heartbeatRuns.status, agentId: heartbeatRuns.agentId, + adapterType: agents.adapterType, startedAt: heartbeatRuns.startedAt, finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, invocationSource: heartbeatRuns.invocationSource, usageJson: heartbeatRuns.usageJson, resultJson: heartbeatRuns.resultJson, + logBytes: heartbeatRuns.logBytes, }) .from(heartbeatRuns) + .innerJoin( + agents, + and( + eq(agents.id, heartbeatRuns.agentId), + eq(agents.companyId, heartbeatRuns.companyId), + ), + ) .where( and( eq(heartbeatRuns.companyId, companyId), diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index d94922a0..954fe51b 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2011,18 +2011,6 @@ export function heartbeatService(db: Db) { return { outcome: "not_applicable" as const, queuedRun: null }; } - const wakeReason = readNonEmptyString(contextSnapshot.wakeReason); - if (wakeReason === "issue_commented" || wakeReason === "issue_comment_mentioned" || wakeReason === "issue_reopened_via_comment") { - if (run.issueCommentStatus !== "not_applicable") { - await patchRunIssueCommentStatus(run.id, { - issueCommentStatus: "not_applicable", - issueCommentSatisfiedByCommentId: null, - issueCommentRetryQueuedAt: null, - }); - } - return { outcome: "not_applicable" as const, queuedRun: null }; - } - const postedComment = await findRunIssueComment(run.id, run.companyId, issueId); if (postedComment) { await patchRunIssueCommentStatus(run.id, { diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts index b1f43d49..46f887ae 100644 --- a/ui/src/api/activity.ts +++ b/ui/src/api/activity.ts @@ -5,12 +5,14 @@ export interface RunForIssue { runId: string; status: string; agentId: string; + adapterType: string; startedAt: string | null; finishedAt: string | null; createdAt: string; invocationSource: string; usageJson: Record | null; resultJson: Record | null; + logBytes?: number | null; } export interface IssueForRun { diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 26c969e3..feca6161 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -30,8 +30,8 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { const runs = liveRuns ?? []; const { data: issues } = useQuery({ - queryKey: queryKeys.issues.list(companyId), - queryFn: () => issuesApi.list(companyId), + queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"], + queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }), enabled: runs.length > 0, }); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 062cbcf7..02e0d4b4 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; +import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { Link, useLocation } from "react-router-dom"; import type { Agent, @@ -631,7 +631,7 @@ const TimelineList = memo(function TimelineList({ ); }); -export const CommentThread = memo(function CommentThread({ +export function CommentThread({ comments, queuedComments = [], linkedApprovals = [], @@ -662,9 +662,17 @@ export const CommentThread = memo(function CommentThread({ interruptingQueuedRunId = null, composerDisabledReason = null, }: CommentThreadProps) { + const [body, setBody] = useState(""); + const [reopen, setReopen] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [attaching, setAttaching] = useState(false); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; + const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue); const [highlightCommentId, setHighlightCommentId] = useState(null); const [votingTargetId, setVotingTargetId] = useState(null); + const editorRef = useRef(null); + const attachInputRef = useRef(null); + const draftTimer = useRef | null>(null); const location = useLocation(); const hasScrolledRef = useRef(false); @@ -730,6 +738,29 @@ export const CommentThread = memo(function CommentThread({ })); }, [agentMap, providedMentions]); + useEffect(() => { + if (!draftKey) return; + setBody(loadDraft(draftKey)); + }, [draftKey]); + + useEffect(() => { + if (!draftKey) return; + if (draftTimer.current) clearTimeout(draftTimer.current); + draftTimer.current = setTimeout(() => { + saveDraft(draftKey, body); + }, DRAFT_DEBOUNCE_MS); + }, [body, draftKey]); + + useEffect(() => { + return () => { + if (draftTimer.current) clearTimeout(draftTimer.current); + }; + }, []); + + useEffect(() => { + setReassignTarget(effectiveSuggestedAssigneeValue); + }, [effectiveSuggestedAssigneeValue]); + // Scroll to comment when URL hash matches #comment-{id} useEffect(() => { const hash = location.hash; @@ -748,25 +779,72 @@ export const CommentThread = memo(function CommentThread({ } }, [location.hash, comments, queuedComments]); - const handleFeedbackVote = useCallback( - async ( - commentId: string, - vote: FeedbackVoteValue, - options?: { allowSharing?: boolean; reason?: string }, - ) => { - if (!onVote) return; - setVotingTargetId(commentId); - try { - await onVote(commentId, vote, options); - } finally { - setVotingTargetId(null); - } - }, - [onVote], - ); + async function handleSubmit() { + const trimmed = body.trim(); + if (!trimmed) return; + const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; + const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null; + const submittedBody = trimmed; + + setSubmitting(true); + setBody(""); + try { + await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined); + if (draftKey) clearDraft(draftKey); + setReopen(true); + setReassignTarget(effectiveSuggestedAssigneeValue); + } catch { + setBody((current) => + restoreSubmittedCommentDraft({ + currentBody: current, + submittedBody, + }), + ); + // Parent mutation handlers surface the failure and the draft is restored for retry. + } finally { + setSubmitting(false); + } + } + + async function handleAttachFile(evt: ChangeEvent) { + const file = evt.target.files?.[0]; + if (!file) return; + setAttaching(true); + try { + if (imageUploadHandler) { + const url = await imageUploadHandler(file); + const safeName = file.name.replace(/[[\]]/g, "\\$&"); + const markdown = `![${safeName}](${url})`; + setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown); + } else if (onAttachImage) { + await onAttachImage(file); + } + } finally { + setAttaching(false); + if (attachInputRef.current) attachInputRef.current.value = ""; + } + } + + async function handleFeedbackVote( + commentId: string, + vote: FeedbackVoteValue, + options?: { allowSharing?: boolean; reason?: string }, + ) { + if (!onVote) return; + setVotingTargetId(commentId); + try { + await onVote(commentId, vote, options); + } finally { + setVotingTargetId(null); + } + } + + const canSubmit = !submitting && !!body.trim(); + + return ( +
+

Timeline ({timeline.length + queuedComments.length})

- const timelineSection = useMemo( - () => ( - ), - [ - timeline, agentMap, currentUserId, companyId, projectId, - onApproveApproval, onRejectApproval, pendingApprovalAction, - feedbackVoteByTargetId, feedbackDataSharingPreference, - onVote, handleFeedbackVote, votingTargetId, highlightCommentId, - feedbackTermsUrl, - ], - ); - - return ( -
-

Timeline ({timeline.length + queuedComments.length})

- - {timelineSection} {liveRunSlot} @@ -840,216 +903,92 @@ export const CommentThread = memo(function CommentThread({ {composerDisabledReason}
) : ( - +
+ +
+ {(imageUploadHandler || onAttachImage) && ( +
+ + +
+ )} + + {enableReassign && reassignOptions.length > 0 && ( + { + if (!option) return Assignee; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + renderOption={(option) => { + if (!option.id) return {option.label}; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + /> + )} + +
+
)}
); -}); - -CommentThread.displayName = "CommentThread"; - -/* ---- Isolated Composer (body state lives here, not in CommentThread) ---- */ - -interface CommentComposerProps { - onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; - mentions: MentionOption[]; - imageUploadHandler?: (file: File) => Promise; - onAttachImage?: (file: File) => Promise; - draftKey?: string; - enableReassign: boolean; - reassignOptions: InlineEntityOption[]; - currentAssigneeValue: string; - suggestedAssigneeValue: string; - agentMap?: Map; } - -const CommentComposer = memo(function CommentComposer({ - onAdd, - mentions, - imageUploadHandler, - onAttachImage, - draftKey, - enableReassign, - reassignOptions, - currentAssigneeValue, - suggestedAssigneeValue, - agentMap, -}: CommentComposerProps) { - const [body, setBody] = useState(""); - const [reopen, setReopen] = useState(true); - const [submitting, setSubmitting] = useState(false); - const [attaching, setAttaching] = useState(false); - const [reassignTarget, setReassignTarget] = useState(suggestedAssigneeValue); - const editorRef = useRef(null); - const attachInputRef = useRef(null); - const draftTimer = useRef | null>(null); - - useEffect(() => { - if (!draftKey) return; - setBody(loadDraft(draftKey)); - }, [draftKey]); - - useEffect(() => { - if (!draftKey) return; - if (draftTimer.current) clearTimeout(draftTimer.current); - draftTimer.current = setTimeout(() => { - saveDraft(draftKey, body); - }, DRAFT_DEBOUNCE_MS); - }, [body, draftKey]); - - useEffect(() => { - return () => { - if (draftTimer.current) clearTimeout(draftTimer.current); - }; - }, []); - - useEffect(() => { - setReassignTarget(suggestedAssigneeValue); - }, [suggestedAssigneeValue]); - - async function handleSubmit() { - const trimmed = body.trim(); - if (!trimmed) return; - const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; - const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null; - const submittedBody = trimmed; - - setSubmitting(true); - setBody(""); - try { - await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined); - if (draftKey) clearDraft(draftKey); - setReopen(true); - setReassignTarget(suggestedAssigneeValue); - } catch { - setBody((current) => - restoreSubmittedCommentDraft({ - currentBody: current, - submittedBody, - }), - ); - } finally { - setSubmitting(false); - } - } - - async function handleAttachFile(evt: ChangeEvent) { - const file = evt.target.files?.[0]; - if (!file) return; - setAttaching(true); - try { - if (imageUploadHandler) { - const url = await imageUploadHandler(file); - const safeName = file.name.replace(/[[\]]/g, "\\$&"); - const markdown = `![${safeName}](${url})`; - setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown); - } else if (onAttachImage) { - await onAttachImage(file); - } - } finally { - setAttaching(false); - if (attachInputRef.current) attachInputRef.current.value = ""; - } - } - - const canSubmit = !submitting && !!body.trim(); - - return ( -
- -
- {(imageUploadHandler || onAttachImage) && ( -
- - -
- )} - - {enableReassign && reassignOptions.length > 0 && ( - { - if (!option) return Assignee; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - renderOption={(option) => { - if (!option.id) return {option.label}; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - /> - )} - -
-
- ); -}); diff --git a/ui/src/components/InstanceSidebar.tsx b/ui/src/components/InstanceSidebar.tsx index 076b9702..b39b959c 100644 --- a/ui/src/components/InstanceSidebar.tsx +++ b/ui/src/components/InstanceSidebar.tsx @@ -3,6 +3,7 @@ import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from " import { NavLink } from "@/lib/router"; import { pluginsApi } from "@/api/plugins"; import { queryKeys } from "@/lib/queryKeys"; +import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll"; import { SidebarNavItem } from "./SidebarNavItem"; export function InstanceSidebar() { @@ -33,6 +34,7 @@ export function InstanceSidebar() { [ "rounded-md px-2 py-1.5 text-xs transition-colors", diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 884517b4..1313b8e7 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -41,6 +41,7 @@ import { type IssueChatTranscriptEntry, type SegmentTiming, } from "../lib/issue-chat-messages"; +import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -907,8 +908,6 @@ function IssueChatUserMessage() { ) : null} ) : null} - {pending ?
Sending...
: null} -
-
- - - - {message.createdAt ? commentDateLabel(message.createdAt) : ""} - - - - {message.createdAt ? formatDateTime(message.createdAt) : ""} - - - -
+ {pending ? ( +
Sending...
+ ) : ( +
+ + + + {message.createdAt ? commentDateLabel(message.createdAt) : ""} + + + + {message.createdAt ? formatDateTime(message.createdAt) : ""} + + + +
+ )} @@ -1820,26 +1823,12 @@ export function IssueChatThread({ return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); }, [activeRun, liveRuns]); const transcriptRuns = useMemo(() => { - const combined = new Map(); - for (const run of displayLiveRuns) { - combined.set(run.id, { - id: run.id, - status: run.status, - adapterType: run.adapterType, - }); - } - for (const run of linkedRuns) { - if (combined.has(run.runId)) continue; - const adapterType = agentMap?.get(run.agentId)?.adapterType; - if (!adapterType) continue; - combined.set(run.runId, { - id: run.runId, - status: run.status, - adapterType, - }); - } - return [...combined.values()]; - }, [agentMap, displayLiveRuns, linkedRuns]); + return resolveIssueChatTranscriptRuns({ + linkedRuns, + liveRuns: displayLiveRuns, + activeRun, + }); + }, [activeRun, displayLiveRuns, linkedRuns]); const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs: enableLiveTranscriptPolling ? transcriptRuns : [], companyId, diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index 117e5b13..99f0cbd1 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -351,4 +351,51 @@ describe("IssueDocumentsSection", () => { }); queryClient.clear(); }); + + it("wraps the documents header actions so mobile layouts do not overflow", async () => { + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + mockIssuesApi.listDocuments.mockResolvedValue([createIssueDocument()]); + + await act(async () => { + root.render( + + + + + + )} + /> + , + ); + }); + + await flush(); + await flush(); + + const heading = container.querySelector("h3"); + expect(heading).toBeTruthy(); + expect(heading?.parentElement?.className).toContain("flex-wrap"); + expect(heading?.nextElementSibling?.className).toContain("flex-wrap"); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); }); diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 26db7266..c3f0a9d7 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -683,7 +683,7 @@ export function IssueDocumentsSection({ return (
{isEmpty && !draft?.isNew ? ( -
+
{extraActions}
) : ( -
-

Documents

-
+
+

Documents

+
{extraActions} + + +
+
+ Filters + {activeFilterCount > 0 ? ( + + ) : null} +
+ +
+ Quick filters +
+ {issueQuickFilterPresets.map((preset) => { + const isActive = issueFilterArraysEqual(state.statuses, preset.statuses); + return ( + + ); + })} +
+
+ +
+ +
+
+ Status +
+ {issueStatusOrder.map((status) => ( + + ))} +
+
+ +
+
+ Priority +
+ {issuePriorityOrder.map((priority) => ( + + ))} +
+
+ +
+ Assignee +
+ + {currentUserId ? ( + + ) : null} + {(agents ?? []).map((agent) => ( + + ))} +
+
+ + {labels && labels.length > 0 ? ( +
+ Labels +
+ {labels.map((label) => ( + + ))} +
+
+ ) : null} + + {projects && projects.length > 0 ? ( +
+ Project +
+ {projects.map((project) => ( + + ))} +
+
+ ) : null} + + {enableRoutineVisibilityFilter ? ( +
+ Visibility + +
+ ) : null} +
+
+
+ + + ); +} diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 09df3f03..13488489 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -2,7 +2,11 @@ import type { ReactNode } from "react"; import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { X } from "lucide-react"; -import { createIssueDetailPath, rememberIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; +import { + createIssueDetailPath, + rememberIssueDetailLocationState, + withIssueDetailHeaderSeed, +} from "../lib/issueDetailBreadcrumb"; import { cn } from "../lib/utils"; import { StatusIcon } from "./StatusIcon"; @@ -48,13 +52,14 @@ export function IssueRow({ const showUnreadSlot = unreadState !== null; const showUnreadDot = unreadState === "visible" || unreadState === "fading"; const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined; + const detailState = withIssueDetailHeaderSeed(issueLinkState, issue); return ( rememberIssueDetailLocationState(issuePathId, issueLinkState)} + onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)} className={cn( "group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1", selected ? "hover:bg-transparent" : "hover:bg-accent/50", diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 16e6faf5..96ea92e8 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -307,4 +307,67 @@ describe("IssuesList", () => { root.unmount(); }); }); + + it("hides routine-backed issues by default and reveals them when the routine filter is enabled", async () => { + const manualIssue = createIssue({ + id: "issue-manual", + identifier: "PAP-10", + title: "Manual issue", + originKind: "manual", + }); + const routineIssue = createIssue({ + id: "issue-routine", + identifier: "PAP-11", + title: "Routine issue", + originKind: "routine_execution", + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Manual issue"); + expect(container.textContent).not.toContain("Routine issue"); + }); + + await act(async () => { + const filterButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Filter"), + ); + filterButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + await waitForAssertion(() => { + const toggle = Array.from(document.body.querySelectorAll("label")).find( + (label) => label.textContent?.includes("Show routine runs"), + ); + expect(toggle).not.toBeUndefined(); + }); + + await act(async () => { + const toggle = Array.from(document.body.querySelectorAll("label")).find( + (label) => label.textContent?.includes("Show routine runs"), + ); + toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Routine issue"); + }); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 198a1a16..576a9ede 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -9,6 +9,15 @@ import { instanceSettingsApi } from "../api/instanceSettings"; import { queryKeys } from "../lib/queryKeys"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { groupBy } from "../lib/groupBy"; +import { + applyIssueFilters, + countActiveIssueFilters, + defaultIssueFilterState, + issueFilterLabel, + issuePriorityOrder, + issueStatusOrder, + type IssueFilterState, +} from "../lib/issue-filters"; import { DEFAULT_INBOX_ISSUE_COLUMNS, getAvailableInboxIssueColumns, @@ -27,39 +36,24 @@ import { issueTrailingColumns, } from "./IssueColumns"; import { StatusIcon } from "./StatusIcon"; -import { PriorityIcon } from "./PriorityIcon"; import { EmptyState } from "./EmptyState"; import { Identity } from "./Identity"; +import { IssueFiltersPopover } from "./IssueFiltersPopover"; import { IssueRow } from "./IssueRow"; import { PageSkeleton } from "./PageSkeleton"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; -import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; -import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react"; +import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, Columns3, User, Search } from "lucide-react"; import { KanbanBoard } from "./KanbanBoard"; import { buildIssueTree, countDescendants } from "../lib/issue-tree"; import type { Issue, Project } from "@paperclipai/shared"; - -/* ── Helpers ── */ - -const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"]; -const priorityOrder = ["critical", "high", "medium", "low"]; const ISSUE_SEARCH_DEBOUNCE_MS = 150; -function statusLabel(status: string): string { - return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); -} - /* ── View state ── */ -export type IssueViewState = { - statuses: string[]; - priorities: string[]; - assignees: string[]; - labels: string[]; - projects: string[]; +export type IssueViewState = IssueFilterState & { sortField: "status" | "priority" | "title" | "created" | "updated"; sortDir: "asc" | "desc"; groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none"; @@ -69,11 +63,7 @@ export type IssueViewState = { }; const defaultViewState: IssueViewState = { - statuses: [], - priorities: [], - assignees: [], - labels: [], - projects: [], + ...defaultIssueFilterState, sortField: "updated", sortDir: "desc", groupBy: "none", @@ -81,13 +71,6 @@ const defaultViewState: IssueViewState = { collapsedGroups: [], collapsedParents: [], }; - -const quickFilterPresets = [ - { label: "All", statuses: [] as string[] }, - { label: "Active", statuses: ["todo", "in_progress", "in_review", "blocked"] }, - { label: "Backlog", statuses: ["backlog"] }, - { label: "Done", statuses: ["done", "cancelled"] }, -]; function getViewState(key: string): IssueViewState { try { const raw = localStorage.getItem(key); @@ -100,45 +83,15 @@ function saveViewState(key: string, state: IssueViewState) { localStorage.setItem(key, JSON.stringify(state)); } -function arraysEqual(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false; - const sa = [...a].sort(); - const sb = [...b].sort(); - return sa.every((v, i) => v === sb[i]); -} - -function toggleInArray(arr: string[], value: string): string[] { - return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]; -} - -function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] { - let result = issues; - if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status)); - if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority)); - if (state.assignees.length > 0) { - result = result.filter((issue) => { - for (const assignee of state.assignees) { - if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true; - if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true; - if (issue.assigneeAgentId === assignee) return true; - } - return false; - }); - } - if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id))); - if (state.projects.length > 0) result = result.filter((i) => i.projectId != null && state.projects.includes(i.projectId)); - return result; -} - function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { const sorted = [...issues]; const dir = state.sortDir === "asc" ? 1 : -1; sorted.sort((a, b) => { switch (state.sortField) { case "status": - return dir * (statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status)); + return dir * (issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status)); case "priority": - return dir * (priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority)); + return dir * (issuePriorityOrder.indexOf(a.priority) - issuePriorityOrder.indexOf(b.priority)); case "title": return dir * a.title.localeCompare(b.title); case "created": @@ -152,16 +105,6 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { return sorted; } -function countActiveFilters(state: IssueViewState): number { - let count = 0; - if (state.statuses.length > 0) count++; - if (state.priorities.length > 0) count++; - if (state.assignees.length > 0) count++; - if (state.labels.length > 0) count++; - if (state.projects.length > 0) count++; - return count; -} - /* ── Component ── */ interface Agent { @@ -186,6 +129,7 @@ interface IssuesListProps { searchFilters?: { participantAgentId?: string; }; + enableRoutineVisibilityFilter?: boolean; onSearchChange?: (search: string) => void; onUpdateIssue: (id: string, data: Record) => void; } @@ -247,6 +191,7 @@ export function IssuesList({ initialAssignees, initialSearch, searchFilters, + enableRoutineVisibilityFilter = false, onSearchChange, onUpdateIssue, }: IssuesListProps) { @@ -319,8 +264,15 @@ export function IssuesList({ queryKey: [ ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), searchFilters ?? {}, + enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions", ], - queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }), + queryFn: () => + issuesApi.list(selectedCompanyId!, { + q: normalizedIssueSearch, + projectId, + ...searchFilters, + ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), + }), enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, placeholderData: (previousData) => previousData, }); @@ -423,9 +375,9 @@ export function IssuesList({ const filtered = useMemo(() => { const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; - const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId); + const filteredByControls = applyIssueFilters(sourceIssues, viewState, currentUserId, enableRoutineVisibilityFilter); return sortIssues(filteredByControls, viewState); - }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]); + }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), @@ -433,7 +385,7 @@ export function IssuesList({ enabled: !!selectedCompanyId, }); - const activeFilterCount = countActiveFilters(viewState); + const activeFilterCount = countActiveIssueFilters(viewState, enableRoutineVisibilityFilter); const groupedContent = useMemo(() => { if (viewState.groupBy === "none") { @@ -441,15 +393,15 @@ export function IssuesList({ } if (viewState.groupBy === "status") { const groups = groupBy(filtered, (i) => i.status); - return statusOrder + return issueStatusOrder .filter((s) => groups[s]?.length) - .map((s) => ({ key: s, label: statusLabel(s), items: groups[s]! })); + .map((s) => ({ key: s, label: issueFilterLabel(s), items: groups[s]! })); } if (viewState.groupBy === "priority") { const groups = groupBy(filtered, (i) => i.priority); - return priorityOrder + return issuePriorityOrder .filter((p) => groups[p]?.length) - .map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! })); + .map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! })); } if (viewState.groupBy === "workspace") { const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace"); @@ -581,175 +533,16 @@ export function IssuesList({ title="Choose which issue columns stay visible" /> - {/* Filter */} - - - - - -
-
- Filters - {activeFilterCount > 0 && ( - - )} -
- - {/* Quick filters */} -
- Quick filters -
- {quickFilterPresets.map((preset) => { - const isActive = arraysEqual(viewState.statuses, preset.statuses); - return ( - - ); - })} -
-
- -
- - {/* Multi-column filter sections */} -
- {/* Status */} -
- Status -
- {statusOrder.map((s) => ( - - ))} -
-
- - {/* Priority + Assignee stacked in right column */} -
- {/* Priority */} -
- Priority -
- {priorityOrder.map((p) => ( - - ))} -
-
- - {/* Assignee */} -
- Assignee -
- - {currentUserId && ( - - )} - {(agents ?? []).map((agent) => ( - - ))} -
-
- - {labels && labels.length > 0 && ( -
- Labels -
- {labels.map((label) => ( - - ))} -
-
- )} - - {projects && projects.length > 0 && ( -
- Project -
- {projects.map((project) => ( - - ))} -
-
- )} -
-
-
- - + ({ id: project.id, name: project.name }))} + labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} + currentUserId={currentUserId} + enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} + /> {/* Sort (list view only) */} {viewState.viewMode === "list" && ( diff --git a/ui/src/components/IssuesQuicklook.tsx b/ui/src/components/IssuesQuicklook.tsx index c4e02e49..ba89d1cd 100644 --- a/ui/src/components/IssuesQuicklook.tsx +++ b/ui/src/components/IssuesQuicklook.tsx @@ -3,7 +3,7 @@ import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { StatusIcon } from "./StatusIcon"; -import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; +import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb"; import { timeAgo } from "../lib/timeAgo"; interface IssuesQuicklookProps { @@ -36,6 +36,7 @@ export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) { {issue.title} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index b5809f46..461d429c 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, Moon, Settings, Sun } from "lucide-react"; -import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; +import { Link, Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router"; import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; import { InstanceSidebar } from "./InstanceSidebar"; @@ -32,6 +32,11 @@ import { DEFAULT_INSTANCE_SETTINGS_PATH, normalizeRememberedInstanceSettingsPath, } from "../lib/instance-settings"; +import { + resetNavigationScroll, + SIDEBAR_SCROLL_RESET_STATE, + shouldResetScrollOnNavigation, +} from "../lib/navigation-scroll"; import { queryKeys } from "../lib/queryKeys"; import { scheduleMainContentFocus } from "../lib/main-content-focus"; import { cn } from "../lib/utils"; @@ -66,9 +71,12 @@ export function Layout() { const { companyPrefix } = useParams<{ companyPrefix: string }>(); const navigate = useNavigate(); const location = useLocation(); + const navigationType = useNavigationType(); const isInstanceSettingsRoute = location.pathname.startsWith("/instance/"); const onboardingTriggered = useRef(false); const lastMainScrollTop = useRef(0); + const previousPathname = useRef(null); + const mainContentRef = useRef(null); const [mobileNavVisible, setMobileNavVisible] = useState(true); const [instanceSettingsTarget, setInstanceSettingsTarget] = useState(() => readRememberedInstanceSettingsPath()); const [shortcutsOpen, setShortcutsOpen] = useState(false); @@ -271,10 +279,24 @@ export function Layout() { useEffect(() => { if (typeof document === "undefined") return; - const mainContent = document.getElementById("main-content"); + const mainContent = mainContentRef.current; return scheduleMainContentFocus(mainContent); }, [location.pathname]); + useEffect(() => { + const shouldResetScroll = shouldResetScrollOnNavigation({ + previousPathname: previousPathname.current, + pathname: location.pathname, + navigationType, + state: location.state, + }); + + previousPathname.current = location.pathname; + + if (!shouldResetScroll) return; + resetNavigationScroll(mainContentRef.current); + }, [location.pathname, navigationType]); + return (
{ @@ -392,6 +415,7 @@ export function Layout() { + ({ id: project.id, name: project.name }))} + labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} + currentUserId={currentUserId} + enableRoutineVisibilityFilter + /> + + + + + +
+ {([ + ["none", "None"], + ["type", "Type"], + ] as const).map(([value, label]) => ( + + ))} +
+
+
{(() => { - // Pre-compute flat nav index for each top-level item and child issue + // Pre-compute flat nav index for each top-level item and child issue. let flatIdx = 0; - const topFlatIndex = new Map(); + const topFlatIndex = new Map(); const childFlatIndex = new Map(); - for (let ti = 0; ti < nestedWorkItems.length; ti++) { - topFlatIndex.set(ti, flatIdx); - flatIdx++; - const topItem = nestedWorkItems[ti]; - if (topItem.kind === "issue") { - const children = childrenByIssueId.get(topItem.issue.id); - const isExp = children?.length && !collapsedInboxParents.has(topItem.issue.id); - if (isExp) { - for (const c of children) { - childFlatIndex.set(c.id, flatIdx); - flatIdx++; + for (const group of groupedSections) { + for (const topItem of group.displayItems) { + const itemKey = `${group.key}:${getWorkItemKey(topItem)}`; + topFlatIndex.set(itemKey, flatIdx); + flatIdx++; + if (topItem.kind === "issue") { + const children = group.childrenByIssueId.get(topItem.issue.id); + const isExpanded = children?.length && !collapsedInboxParents.has(topItem.issue.id); + if (isExpanded) { + for (const child of children) { + childFlatIndex.set(child.id, flatIdx); + flatIdx++; + } } } } } - return nestedWorkItems.flatMap((item, index) => { - const navIdx = topFlatIndex.get(index) ?? index; - const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => ( -
setSelectedIndex(navIdx)} - > - {child} -
- ); - const todayCutoff = Date.now() - 24 * 60 * 60 * 1000; - const showTodayDivider = - index > 0 && - item.timestamp > 0 && - item.timestamp < todayCutoff && - nestedWorkItems[index - 1].timestamp >= todayCutoff; - const elements: ReactNode[] = []; - if (showTodayDivider) { - elements.push( -
-
- - Earlier - -
, - ); - } - const isSelected = selectedIndex === navIdx; - - if (item.kind === "approval") { - const approvalKey = `approval:${item.approval.id}`; - const isArchiving = archivingNonIssueIds.has(approvalKey); - const row = ( - approveMutation.mutate(item.approval.id)} - onReject={() => rejectMutation.mutate(item.approval.id)} - isPending={approveMutation.isPending || rejectMutation.isPending} - unreadState={nonIssueUnreadState(approvalKey)} - onMarkRead={() => handleMarkNonIssueRead(approvalKey)} - onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined} - archiveDisabled={isArchiving} - className={ - isArchiving - ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" - : "transition-all duration-200 ease-out" - } - /> - ); - elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? ( - handleArchiveNonIssue(approvalKey)} - > - {row} - - ) : row)); - return elements; - } - - if (item.kind === "failed_run") { - const runKey = `run:${item.run.id}`; - const isArchiving = archivingNonIssueIds.has(runKey); - const row = ( - dismissInboxItem(runKey)} - onRetry={() => retryRunMutation.mutate(item.run)} - isRetrying={retryingRunIds.has(item.run.id)} - unreadState={nonIssueUnreadState(runKey)} - onMarkRead={() => handleMarkNonIssueRead(runKey)} - onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined} - archiveDisabled={isArchiving} - className={ - isArchiving - ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" - : "transition-all duration-200 ease-out" - } - /> - ); - elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? ( - handleArchiveNonIssue(runKey)} - > - {row} - - ) : row)); - return elements; - } - - if (item.kind === "join_request") { - const joinKey = `join:${item.joinRequest.id}`; - const isArchiving = archivingNonIssueIds.has(joinKey); - const row = ( - approveJoinMutation.mutate(item.joinRequest)} - onReject={() => rejectJoinMutation.mutate(item.joinRequest)} - isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending} - unreadState={nonIssueUnreadState(joinKey)} - onMarkRead={() => handleMarkNonIssueRead(joinKey)} - onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined} - archiveDisabled={isArchiving} - className={ - isArchiving - ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" - : "transition-all duration-200 ease-out" - } - /> - ); - elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? ( - handleArchiveNonIssue(joinKey)} - > - {row} - - ) : row)); - return elements; - } - - const issue = item.issue; - const childIssues = childrenByIssueId.get(issue.id) ?? []; - const hasChildren = childIssues.length > 0; - const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id); - - const renderInboxIssue = (iss: Issue, depth: number, sel: boolean) => { - const isUnread = iss.isUnreadForMe && !fadingOutIssues.has(iss.id); - const isFading = fadingOutIssues.has(iss.id); - const isArch = archivingIssueIds.has(iss.id); - const proj = iss.projectId ? projectById.get(iss.projectId) ?? null : null; + const renderInboxIssue = ({ + issue, + depth, + selected, + hasChildren = false, + isExpanded = false, + childCount = 0, + collapseParentId = null, + }: { + issue: Issue; + depth: number; + selected: boolean; + hasChildren?: boolean; + isExpanded?: boolean; + childCount?: number; + collapseParentId?: string | null; + }) => { + const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); + const isFading = fadingOutIssues.has(issue.id); + const isArchiving = archivingIssueIds.has(issue.id); + const project = issue.projectId ? projectById.get(issue.projectId) ?? null : null; return ( {nestingEnabled ? ( - depth === 0 && hasChildren ? ( + depth === 0 && hasChildren && collapseParentId ? ( ) : undefined } - unreadState={ - isUnread ? "visible" : isFading ? "fading" : "hidden" - } - onMarkRead={() => markReadMutation.mutate(iss.id)} - onArchive={ - canArchiveFromTab - ? () => archiveIssueMutation.mutate(iss.id) - : undefined - } - archiveDisabled={isArch || archiveIssueMutation.isPending} + unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"} + onMarkRead={() => markReadMutation.mutate(issue.id)} + onArchive={canArchiveFromTab ? () => archiveIssueMutation.mutate(issue.id) : undefined} + archiveDisabled={isArchiving || archiveIssueMutation.isPending} desktopTrailing={ visibleTrailingIssueColumns.length > 0 ? ( ) : undefined } @@ -1893,49 +1882,224 @@ export function Inbox() { ); }; - // Render parent issue - const parentRow = renderInboxIssue(issue, 0, isSelected); - elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? ( - archiveIssueMutation.mutate(issue.id)} - > - {parentRow} - - ) : parentRow)); - - // Render children if expanded - if (isExpanded) { - for (const child of childIssues) { - const cNavIdx = childFlatIndex.get(child.id) ?? -1; - const isChildSelected = selectedIndex === cNavIdx; - const childRow = renderInboxIssue(child, 1, isChildSelected); - const isChildArchiving = archivingIssueIds.has(child.id); + let previousTimestamp = Number.POSITIVE_INFINITY; + return groupedSections.flatMap((group, groupIndex) => { + const elements: ReactNode[] = []; + if (group.label) { elements.push(
setSelectedIndex(cNavIdx)} + key={`group-${group.key}`} + className={cn( + "border-b border-border/70 bg-muted/30 px-4 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", + groupIndex > 0 && "border-t border-border", + )} > - {canArchiveFromTab ? ( - archiveIssueMutation.mutate(child.id)} - > - {childRow} - - ) : childRow} + {group.label}
, ); } - } - return elements; - }); + + for (let index = 0; index < group.displayItems.length; index += 1) { + const item = group.displayItems[index]!; + const navIdx = topFlatIndex.get(`${group.key}:${getWorkItemKey(item)}`) ?? 0; + const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => ( +
setSelectedIndex(navIdx)} + > + {child} +
+ ); + const todayCutoff = Date.now() - 24 * 60 * 60 * 1000; + const showTodayDivider = + groupBy === "none" && + item.timestamp > 0 && + item.timestamp < todayCutoff && + previousTimestamp >= todayCutoff; + previousTimestamp = item.timestamp > 0 ? item.timestamp : previousTimestamp; + if (showTodayDivider) { + elements.push( +
+
+ + Earlier + +
, + ); + } + const isSelected = selectedIndex === navIdx; + + if (item.kind === "approval") { + const approvalKey = `approval:${item.approval.id}`; + const isArchiving = archivingNonIssueIds.has(approvalKey); + const row = ( + approveMutation.mutate(item.approval.id)} + onReject={() => rejectMutation.mutate(item.approval.id)} + isPending={approveMutation.isPending || rejectMutation.isPending} + unreadState={nonIssueUnreadState(approvalKey)} + onMarkRead={() => handleMarkNonIssueRead(approvalKey)} + onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined} + archiveDisabled={isArchiving} + className={ + isArchiving + ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" + : "transition-all duration-200 ease-out" + } + /> + ); + elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? ( + handleArchiveNonIssue(approvalKey)} + > + {row} + + ) : row)); + continue; + } + + if (item.kind === "failed_run") { + const runKey = `run:${item.run.id}`; + const isArchiving = archivingNonIssueIds.has(runKey); + const row = ( + dismissInboxItem(runKey)} + onRetry={() => retryRunMutation.mutate(item.run)} + isRetrying={retryingRunIds.has(item.run.id)} + unreadState={nonIssueUnreadState(runKey)} + onMarkRead={() => handleMarkNonIssueRead(runKey)} + onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined} + archiveDisabled={isArchiving} + className={ + isArchiving + ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" + : "transition-all duration-200 ease-out" + } + /> + ); + elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? ( + handleArchiveNonIssue(runKey)} + > + {row} + + ) : row)); + continue; + } + + if (item.kind === "join_request") { + const joinKey = `join:${item.joinRequest.id}`; + const isArchiving = archivingNonIssueIds.has(joinKey); + const row = ( + approveJoinMutation.mutate(item.joinRequest)} + onReject={() => rejectJoinMutation.mutate(item.joinRequest)} + isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending} + unreadState={nonIssueUnreadState(joinKey)} + onMarkRead={() => handleMarkNonIssueRead(joinKey)} + onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined} + archiveDisabled={isArchiving} + className={ + isArchiving + ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" + : "transition-all duration-200 ease-out" + } + /> + ); + elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? ( + handleArchiveNonIssue(joinKey)} + > + {row} + + ) : row)); + continue; + } + + const issue = item.issue; + const childIssues = group.childrenByIssueId.get(issue.id) ?? []; + const hasChildren = childIssues.length > 0; + const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id); + const parentRow = renderInboxIssue({ + issue, + depth: 0, + selected: isSelected, + hasChildren, + isExpanded, + childCount: childIssues.length, + collapseParentId: issue.id, + }); + + elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? ( + archiveIssueMutation.mutate(issue.id)} + > + {parentRow} + + ) : parentRow)); + + if (isExpanded) { + for (const child of childIssues) { + const childNavIdx = childFlatIndex.get(child.id) ?? -1; + const isChildSelected = selectedIndex === childNavIdx; + const childRow = renderInboxIssue({ + issue: child, + depth: 1, + selected: isChildSelected, + }); + const isChildArchiving = archivingIssueIds.has(child.id); + elements.push( +
setSelectedIndex(childNavIdx)} + > + {canArchiveFromTab ? ( + archiveIssueMutation.mutate(child.id)} + > + {childRow} + + ) : childRow} +
, + ); + } + } + } + + return elements; + }); })()}
diff --git a/ui/src/pages/IssueChatUxLab.tsx b/ui/src/pages/IssueChatUxLab.tsx index fa9a889b..1027ff4c 100644 --- a/ui/src/pages/IssueChatUxLab.tsx +++ b/ui/src/pages/IssueChatUxLab.tsx @@ -14,6 +14,7 @@ import { issueChatUxReassignOptions, issueChatUxReviewComments, issueChatUxReviewEvents, + issueChatUxSubmittingComments, issueChatUxTranscriptsByRunId, } from "../fixtures/issueChatUxFixtures"; import { cn } from "../lib/utils"; @@ -25,6 +26,7 @@ const highlights = [ "Running assistant replies with streamed text, reasoning, tool cards, and background status notes", "Historical issue events and linked runs rendered inline with the chat timeline", "Queued user messages, settled assistant comments, and feedback controls", + "Submitting (pending) message bubble with Sending... label and reduced opacity", "Empty and disabled-composer states without relying on live backend data", ]; @@ -285,6 +287,26 @@ export function IssueChatUxLab() { /> + + + +
(previousData: T | undefined) { @@ -284,6 +281,87 @@ function IssueChatSkeleton() { ); } +function IssueDetailLoadingState({ + headerSeed, +}: { + headerSeed: ReturnType; +}) { + const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null; + + return ( +
+
+ + +
+ {headerSeed ? ( + <> + + + {identifier ? ( + {identifier} + ) : null} + {headerSeed.originKind === "routine_execution" && headerSeed.originId ? ( + + + Routine + + ) : null} + {headerSeed.projectId ? ( + + + + {headerSeed.projectName ?? headerSeed.projectId.slice(0, 8)} + + + ) : ( + + + No project + + )} + + ) : ( + <> + + + + + + )} +
+ + {headerSeed ? ( + <> +

{headerSeed.title}

+
+ + +
+ + ) : ( + <> + + + + )} +
+ + + +
+
+ + +
+ +
+ + +
+ ); +} + export function IssueDetail() { const { issueId } = useParams<{ issueId: string }>(); const { selectedCompanyId } = useCompany(); @@ -309,10 +387,15 @@ export function IssueDetail() { const [galleryIndex, setGalleryIndex] = useState(0); const [optimisticComments, setOptimisticComments] = useState([]); const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0); + const [issueChatInitialTranscriptReady, setIssueChatInitialTranscriptReady] = useState(false); const fileInputRef = useRef(null); const lastMarkedReadIssueIdRef = useRef(null); const commentComposerRef = useRef(null); + useEffect(() => { + setIssueChatInitialTranscriptReady(false); + }, [issueId]); + const { data: issue, isLoading, error } = useQuery({ queryKey: queryKeys.issues.detail(issueId!), queryFn: () => issuesApi.get(issueId!), @@ -358,6 +441,14 @@ export function IssueDetail() { placeholderData: keepPreviousData, }); + const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({ + queryKey: queryKeys.issues.runs(issueId!), + queryFn: () => activityApi.runsForIssue(issueId!), + enabled: !!issueId, + refetchInterval: 5000, + placeholderData: keepPreviousData, + }); + const { data: linkedApprovals } = useQuery({ queryKey: queryKeys.issues.approvals(issueId!), queryFn: () => issuesApi.listApprovals(issueId!), @@ -376,12 +467,7 @@ export function IssueDetail() { queryKey: queryKeys.issues.liveRuns(issueId!), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!), enabled: !!issueId, - refetchInterval: (query) => { - const data = query.state.data as Array | undefined; - return data && data.length > 0 - ? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS - : IDLE_ISSUE_RUN_POLL_INTERVAL_MS; - }, + refetchInterval: 3000, placeholderData: keepPreviousData, }); @@ -389,25 +475,11 @@ export function IssueDetail() { queryKey: queryKeys.issues.activeRun(issueId!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"), - refetchInterval: (query) => - (liveRuns?.length ?? 0) > 0 - ? false - : query.state.data - ? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS - : IDLE_ISSUE_RUN_POLL_INTERVAL_MS, + refetchInterval: (liveRuns?.length ?? 0) > 0 ? false : 3000, placeholderData: keepPreviousData, }); const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; - const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({ - queryKey: queryKeys.issues.runs(issueId!), - queryFn: () => activityApi.runsForIssue(issueId!), - enabled: !!issueId, - refetchInterval: hasLiveRuns - ? ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS - : IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS, - placeholderData: keepPreviousData, - }); const runningIssueRun = useMemo( () => ( activeRun?.status === "running" @@ -420,6 +492,10 @@ export function IssueDetail() { () => readIssueDetailLocationState(issueId, location.state, location.search), [issueId, location.state, location.search], ); + const issueHeaderSeed = useMemo( + () => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState), + [location.state, resolvedIssueDetailState], + ); const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" }, [issueId, location.state, location.search], @@ -430,8 +506,14 @@ export function IssueDetail() { const liveIds = new Set(); for (const r of liveRuns ?? []) liveIds.add(r.id); if (activeRun) liveIds.add(activeRun.id); - if (liveIds.size === 0) return linkedRuns ?? []; - return (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId)); + const historicalRuns = liveIds.size === 0 + ? (linkedRuns ?? []) + : (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId)); + return historicalRuns.map((run) => ({ + ...run, + adapterType: run.adapterType, + hasStoredOutput: (run.logBytes ?? 0) > 0, + })); }, [linkedRuns, liveRuns, activeRun]); const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({ @@ -500,6 +582,23 @@ export function IssueDetail() { for (const a of agents ?? []) map.set(a.id, a); return map; }, [agents]); + const transcriptRuns = useMemo( + () => + resolveIssueChatTranscriptRuns({ + linkedRuns: timelineRuns, + liveRuns: liveRuns ?? [], + activeRun, + }), + [activeRun, liveRuns, timelineRuns], + ); + const { + transcriptByRun: issueChatTranscriptByRun, + hasOutputForRun: issueChatHasOutputForRun, + isInitialHydrating: issueChatTranscriptHydrating, + } = useLiveRunTranscripts({ + runs: transcriptRuns, + companyId: issue?.companyId ?? selectedCompanyId, + }); const mentionOptions = useMemo(() => { const options: MentionOption[] = []; @@ -699,6 +798,10 @@ export function IssueDetail() { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); }, [issueId, queryClient]); + const invalidateIssueThreadLazily = useCallback(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!), refetchType: "inactive" }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!), refetchType: "inactive" }); + }, [issueId, queryClient]); const invalidateIssueRunState = useCallback(() => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }); @@ -885,6 +988,10 @@ export function IssueDetail() { current.filter((entry) => entry.clientId !== context.optimisticCommentId), ); } + queryClient.setQueryData( + queryKeys.issues.detail(issueId!), + (current) => current ? { ...current, updatedAt: comment.createdAt } : current, + ); queryClient.setQueryData>( queryKeys.issues.comments(issueId!), (current) => current ? { @@ -912,7 +1019,7 @@ export function IssueDetail() { }); }, onSettled: (_result, _error, variables) => { - invalidateIssueDetail(); + invalidateIssueThreadLazily(); if (variables.interrupt) { invalidateIssueRunState(); } @@ -1011,7 +1118,7 @@ export function IssueDetail() { }); }, onSettled: (_result, _error, variables) => { - invalidateIssueDetail(); + invalidateIssueThreadLazily(); if (variables.interrupt) { invalidateIssueRunState(); } @@ -1213,53 +1320,6 @@ export function IssueDetail() { }, }); - const handleInterruptQueued = useCallback( - async (runId: string) => { - await interruptQueuedComment.mutateAsync(runId); - }, - [interruptQueuedComment.mutateAsync], - ); - - const handleCommentImageUpload = useCallback( - async (file: File) => { - const attachment = await uploadAttachment.mutateAsync(file); - return attachment.contentPath; - }, - [uploadAttachment.mutateAsync], - ); - - const handleCommentAttachImage = useCallback( - async (file: File) => { - await uploadAttachment.mutateAsync(file); - }, - [uploadAttachment.mutateAsync], - ); - - const handleCommentAdd = useCallback( - async (body: string, reopen?: boolean, reassignment?: CommentReassignment) => { - if (reassignment) { - await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); - return; - } - await addComment.mutateAsync({ body, reopen }); - }, - [addComment.mutateAsync, addCommentAndReassign.mutateAsync], - ); - - const handleCommentVote = useCallback( - async (commentId: string, vote: FeedbackVoteValue, options?: { reason?: string; allowSharing?: boolean }) => { - await feedbackVoteMutation.mutateAsync({ - targetType: "issue_comment", - targetId: commentId, - vote, - reason: options?.reason, - allowSharing: options?.allowSharing, - sharingPreferenceAtSubmit: feedbackDataSharingPreference, - }); - }, - [feedbackVoteMutation.mutateAsync, feedbackDataSharingPreference], - ); - useEffect(() => { const titleLabel = issue?.title ?? issueId ?? "Issue"; setBreadcrumbs([ @@ -1480,18 +1540,26 @@ export function IssueDetail() { setTimeout(() => setCopied(false), 2000); }; - const issueChatInitialLoading = + const issueChatCoreInitialLoading = (commentsLoading && commentPages === undefined) || (activityLoading && activity === undefined) || (linkedRunsLoading && linkedRuns === undefined) || (liveRunsLoading && liveRuns === undefined) || (activeRunLoading && activeRun === undefined); + useEffect(() => { + if (issueChatInitialTranscriptReady) return; + if (issueChatCoreInitialLoading || issueChatTranscriptHydrating) return; + setIssueChatInitialTranscriptReady(true); + }, [issueChatCoreInitialLoading, issueChatInitialTranscriptReady, issueChatTranscriptHydrating]); + const issueChatInitialLoading = + issueChatCoreInitialLoading + || (!issueChatInitialTranscriptReady && issueChatTranscriptHydrating); const activityInitialLoading = (activityLoading && activity === undefined) || (linkedRunsLoading && linkedRuns === undefined); const attachmentsInitialLoading = attachmentsLoading && attachments === undefined; - if (isLoading) return ; + if (isLoading) return ; if (error) return

{error.message}

; if (!issue) return null; @@ -2075,19 +2143,44 @@ export function IssueDetail() { issueStatus={issue.status} agentMap={agentMap} currentUserId={currentUserId} + enableLiveTranscriptPolling={false} + transcriptsByRunId={issueChatTranscriptByRun} + hasOutputForRun={issueChatHasOutputForRun} draftKey={`paperclip:issue-comment-draft:${issue.id}`} enableReassign reassignOptions={commentReassignOptions} currentAssigneeValue={actualAssigneeValue} suggestedAssigneeValue={suggestedAssigneeValue} mentions={mentionOptions} - onInterruptQueued={handleInterruptQueued} - interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} composerDisabledReason={commentComposerDisabledReason} - onVote={handleCommentVote} - onAdd={handleCommentAdd} - imageUploadHandler={handleCommentImageUpload} - onAttachImage={handleCommentAttachImage} + onVote={async (commentId, vote, options) => { + await feedbackVoteMutation.mutateAsync({ + targetType: "issue_comment", + targetId: commentId, + vote, + reason: options?.reason, + allowSharing: options?.allowSharing, + sharingPreferenceAtSubmit: feedbackDataSharingPreference, + }); + }} + onAdd={async (body, reopen, reassignment) => { + if (reassignment) { + await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); + return; + } + await addComment.mutateAsync({ body, reopen }); + }} + imageUploadHandler={async (file) => { + const attachment = await uploadAttachment.mutateAsync(file); + return attachment.contentPath; + }} + onAttachImage={async (file) => { + await uploadAttachment.mutateAsync(file); + }} + onInterruptQueued={async (runId) => { + await interruptQueuedComment.mutateAsync(runId); + }} + interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} onCancelRun={runningIssueRun ? async () => { await interruptQueuedComment.mutateAsync(runningIssueRun.id); diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 60719785..e1ecffc4 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -80,8 +80,13 @@ export function Issues() { }, [setBreadcrumbs]); const { data: issues, isLoading, error } = useQuery({ - queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"], - queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }), + queryKey: [ + ...queryKeys.issues.list(selectedCompanyId!), + "participant-agent", + participantAgentId ?? "__all__", + "with-routine-executions", + ], + queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId, includeRoutineExecutions: true }), enabled: !!selectedCompanyId, }); @@ -110,6 +115,7 @@ export function Issues() { initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined} initialSearch={initialSearch} onSearchChange={handleSearchChange} + enableRoutineVisibilityFilter onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })} searchFilters={participantAgentId ? { participantAgentId } : undefined} /> -- 2.52.0 From 44d94d0add78b3390874ab90c3f3a1a303dc8499 Mon Sep 17 00:00:00 2001 From: Asish Kumar Date: Thu, 9 Apr 2026 17:50:14 +0000 Subject: [PATCH 05/85] fix(ui): persist cleared agent env bindings on save Agent configuration edits already had an API path for replacing the full adapterConfig, but the edit form was still sending merge-style patches. That meant clearing the last environment variable serialized as undefined, the key disappeared from JSON, and the server merged the old env bindings back into the saved config. Build adapter config save payloads as full replacement patches, strip undefined keys before send, and reuse the existing replaceAdapterConfig contract so explicit clears persist correctly. Add regression coverage for the cleared-env case and for adapter-type changes that still need to preserve adapter-agnostic fields. Fixes #3179 --- ui/src/components/AgentConfigForm.tsx | 62 ++------------- ui/src/lib/agent-config-patch.test.ts | 108 ++++++++++++++++++++++++++ ui/src/lib/agent-config-patch.ts | 70 +++++++++++++++++ 3 files changed, 185 insertions(+), 55 deletions(-) create mode 100644 ui/src/lib/agent-config-patch.test.ts create mode 100644 ui/src/lib/agent-config-patch.ts diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index dd74c376..065405f9 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -49,6 +49,7 @@ import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-confi import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata"; import { getAdapterLabel } from "../adapters/adapter-display-registry"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; +import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch"; /* ---- Create mode values ---- */ @@ -89,15 +90,7 @@ type AgentConfigFormProps = { /* ---- Edit mode overlay (dirty tracking) ---- */ -interface Overlay { - identity: Record; - adapterType?: string; - adapterConfig: Record; - heartbeat: Record; - runtime: Record; -} - -const emptyOverlay: Overlay = { +const emptyOverlay: AgentConfigOverlay = { identity: {}, adapterConfig: {}, heartbeat: {}, @@ -107,7 +100,7 @@ const emptyOverlay: Overlay = { /** Stable empty object used as fallback for missing env config to avoid new-object-per-render. */ const EMPTY_ENV: Record = {}; -function isOverlayDirty(o: Overlay): boolean { +function isOverlayDirty(o: AgentConfigOverlay): boolean { return ( Object.keys(o.identity).length > 0 || o.adapterType !== undefined || @@ -211,7 +204,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }); // ---- Edit mode: overlay for dirty tracking ---- - const [overlay, setOverlay] = useState(emptyOverlay); + const [overlay, setOverlay] = useState(emptyOverlay); const agentRef = useRef(null); // Clear overlay when agent data refreshes (after save) @@ -227,14 +220,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const isDirty = !isCreate && isOverlayDirty(overlay); /** Read effective value: overlay if dirty, else original */ - function eff(group: keyof Omit, field: string, original: T): T { + function eff(group: keyof Omit, field: string, original: T): T { const o = overlay[group]; if (field in o) return o[field] as T; return original; } /** Mark field dirty in overlay */ - function mark(group: keyof Omit, field: string, value: unknown) { + function mark(group: keyof Omit, field: string, value: unknown) { setOverlay((prev) => ({ ...prev, [group]: { ...prev[group], [field]: value }, @@ -248,48 +241,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const handleSave = useCallback(() => { if (isCreate || !isDirty) return; - const agent = props.agent; - const patch: Record = {}; - - if (Object.keys(overlay.identity).length > 0) { - Object.assign(patch, overlay.identity); - } - if (overlay.adapterType !== undefined) { - patch.adapterType = overlay.adapterType; - // When adapter type changes, replace adapter-specific fields but preserve - // adapter-agnostic fields (env, promptTemplate, etc.) that are shared - // across all adapter types. - const existing = (agent.adapterConfig ?? {}) as Record; - const adapterAgnosticKeys = [ - "env", - "promptTemplate", - "instructionsFilePath", - "cwd", - "timeoutSec", - "graceSec", - "bootstrapPromptTemplate", - ]; - const preserved: Record = {}; - for (const key of adapterAgnosticKeys) { - if (key in existing) { - preserved[key] = existing[key]; - } - } - patch.adapterConfig = { ...preserved, ...overlay.adapterConfig }; - } else if (Object.keys(overlay.adapterConfig).length > 0) { - const existing = (agent.adapterConfig ?? {}) as Record; - patch.adapterConfig = { ...existing, ...overlay.adapterConfig }; - } - if (Object.keys(overlay.heartbeat).length > 0) { - const existingRc = (agent.runtimeConfig ?? {}) as Record; - const existingHb = (existingRc.heartbeat ?? {}) as Record; - patch.runtimeConfig = { ...existingRc, heartbeat: { ...existingHb, ...overlay.heartbeat } }; - } - if (Object.keys(overlay.runtime).length > 0) { - Object.assign(patch, overlay.runtime); - } - - props.onSave(patch); + props.onSave(buildAgentUpdatePatch(props.agent, overlay)); }, [isCreate, isDirty, overlay, props]); useEffect(() => { diff --git a/ui/src/lib/agent-config-patch.test.ts b/ui/src/lib/agent-config-patch.test.ts new file mode 100644 index 00000000..f655e8e5 --- /dev/null +++ b/ui/src/lib/agent-config-patch.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import type { Agent } from "@paperclipai/shared"; +import { buildAgentUpdatePatch, type AgentConfigOverlay } from "./agent-config-patch"; + +function makeAgent(): Agent { + return { + id: "agent-1", + companyId: "company-1", + name: "Agent", + role: "engineer", + title: "Engineer", + icon: null, + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "claude_local", + adapterConfig: { + model: "claude-sonnet-4-6", + env: { + OPENAI_API_KEY: { + type: "plain", + value: "secret", + }, + }, + promptTemplate: "Work the issue.", + }, + runtimeConfig: { + heartbeat: { + enabled: true, + intervalSec: 300, + }, + }, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + lastHeartbeatAt: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + urlKey: "agent", + permissions: { + canCreateAgents: false, + }, + metadata: null, + }; +} + +function makeOverlay(patch?: Partial): AgentConfigOverlay { + return { + identity: {}, + adapterConfig: {}, + heartbeat: {}, + runtime: {}, + ...patch, + }; +} + +describe("buildAgentUpdatePatch", () => { + it("replaces adapter config and drops env when the last env binding is cleared", () => { + const patch = buildAgentUpdatePatch( + makeAgent(), + makeOverlay({ + adapterConfig: { + env: undefined, + }, + }), + ); + + expect(patch).toEqual({ + adapterConfig: { + model: "claude-sonnet-4-6", + promptTemplate: "Work the issue.", + }, + replaceAdapterConfig: true, + }); + }); + + it("preserves adapter-agnostic keys when changing adapter types", () => { + const patch = buildAgentUpdatePatch( + makeAgent(), + makeOverlay({ + adapterType: "codex_local", + adapterConfig: { + model: "gpt-5.4", + dangerouslyBypassApprovalsAndSandbox: true, + }, + }), + ); + + expect(patch).toEqual({ + adapterType: "codex_local", + adapterConfig: { + env: { + OPENAI_API_KEY: { + type: "plain", + value: "secret", + }, + }, + promptTemplate: "Work the issue.", + model: "gpt-5.4", + dangerouslyBypassApprovalsAndSandbox: true, + }, + replaceAdapterConfig: true, + }); + }); +}); diff --git a/ui/src/lib/agent-config-patch.ts b/ui/src/lib/agent-config-patch.ts new file mode 100644 index 00000000..323f9bf5 --- /dev/null +++ b/ui/src/lib/agent-config-patch.ts @@ -0,0 +1,70 @@ +import type { Agent } from "@paperclipai/shared"; + +export interface AgentConfigOverlay { + identity: Record; + adapterType?: string; + adapterConfig: Record; + heartbeat: Record; + runtime: Record; +} + +const ADAPTER_AGNOSTIC_KEYS = [ + "env", + "promptTemplate", + "instructionsFilePath", + "cwd", + "timeoutSec", + "graceSec", + "bootstrapPromptTemplate", +] as const; + +function omitUndefinedEntries(value: Record) { + return Object.fromEntries( + Object.entries(value).filter(([, entryValue]) => entryValue !== undefined), + ); +} + +export function buildAgentUpdatePatch(agent: Agent, overlay: AgentConfigOverlay) { + const patch: Record = {}; + + if (Object.keys(overlay.identity).length > 0) { + Object.assign(patch, overlay.identity); + } + + if (overlay.adapterType !== undefined) { + patch.adapterType = overlay.adapterType; + } + + if (overlay.adapterType !== undefined || Object.keys(overlay.adapterConfig).length > 0) { + const existing = (agent.adapterConfig ?? {}) as Record; + const nextAdapterConfig = + overlay.adapterType !== undefined + ? { + ...Object.fromEntries( + ADAPTER_AGNOSTIC_KEYS + .filter((key) => existing[key] !== undefined) + .map((key) => [key, existing[key]]), + ), + ...overlay.adapterConfig, + } + : { + ...existing, + ...overlay.adapterConfig, + }; + + patch.adapterConfig = omitUndefinedEntries(nextAdapterConfig); + patch.replaceAdapterConfig = true; + } + + if (Object.keys(overlay.heartbeat).length > 0) { + const existingRc = (agent.runtimeConfig ?? {}) as Record; + const existingHb = (existingRc.heartbeat ?? {}) as Record; + patch.runtimeConfig = { ...existingRc, heartbeat: { ...existingHb, ...overlay.heartbeat } }; + } + + if (Object.keys(overlay.runtime).length > 0) { + Object.assign(patch, overlay.runtime); + } + + return patch; +} -- 2.52.0 From fa03b5944ec6c781d93e948faff5543312b944ff Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 25 Mar 2026 10:54:57 -0400 Subject: [PATCH 06/85] Add our tooling to Dockerfile, restore build workflow - Expand base apt: jq, procps, python3, python3-pip, gh - Install kubectl, uv/uvx, kubeseal binaries - Add @google/gemini-cli to production agent installs - Use pnpm-lock.yaml* wildcard + --no-frozen-lockfile (lockfile policy) - Restore build.yml targeting runners-cpfarhood Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build.yml | 53 +++++++++++++++++++++++++++++++++++++ Dockerfile | 20 ++++++++++---- 2 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..ee8d41eb --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,53 @@ +name: Build & Push + +on: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build: + runs-on: runners-cpfarhood + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/cpfarhood/paperclip + tags: | + type=raw,value=latest + type=sha,prefix= + type=semver,pattern={{version}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + no-cache: true diff --git a/Dockerfile b/Dockerfile index 36d5acab..4f61a890 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:lts-trixie-slim AS base ARG USER_UID=1000 ARG USER_GID=1000 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates gosu curl git wget ripgrep python3 \ + && apt-get install -y --no-install-recommends ca-certificates curl git jq procps python3 python3-pip \ && mkdir -p -m 755 /etc/apt/keyrings \ && wget -nv -O/etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \ && echo "20e0125d6f6e077a9ad46f03371bc26d90b04939fb95170f5a1905099cc6bcc0 /etc/apt/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c - \ @@ -12,16 +12,26 @@ RUN apt-get update \ && apt-get update \ && apt-get install -y --no-install-recommends gh \ && rm -rf /var/lib/apt/lists/* \ - && corepack enable + && curl -fsSL "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/$(dpkg --print-architecture)/kubectl" \ + -o /usr/local/bin/kubectl \ + && chmod +x /usr/local/bin/kubectl \ + && curl -LsSf https://astral.sh/uv/install.sh | sh \ + && mv /root/.local/bin/uv /usr/local/bin/uv \ + && mv /root/.local/bin/uvx /usr/local/bin/uvx \ + && curl -fsSL "https://github.com/bitnami-labs/sealed-secrets/releases/latest/download/kubeseal-$(uname -s | tr '[:upper:]' '[:lower:]')-$(dpkg --print-architecture)" \ + -o /usr/local/bin/kubeseal \ + && chmod +x /usr/local/bin/kubeseal # Modify the existing node user/group to have the specified UID/GID to match host user RUN usermod -u $USER_UID --non-unique node \ && groupmod -g $USER_GID --non-unique node \ && usermod -g $USER_GID -d /paperclip node +RUN corepack enable + FROM base AS deps WORKDIR /app -COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./ +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* .npmrc ./ COPY cli/package.json cli/ COPY server/package.json server/ COPY ui/package.json ui/ @@ -39,7 +49,7 @@ COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ COPY packages/plugins/sdk/package.json packages/plugins/sdk/ COPY patches/ patches/ -RUN pnpm install --frozen-lockfile +RUN pnpm install --no-frozen-lockfile FROM base AS build WORKDIR /app @@ -55,7 +65,7 @@ ARG USER_UID=1000 ARG USER_GID=1000 WORKDIR /app COPY --chown=node:node --from=build /app /app -RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \ +RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai @google/gemini-cli \ && mkdir -p /paperclip \ && chown node:node /paperclip -- 2.52.0 From 857e9e4f01b370f4f4b2b1ce0d760ec1a2a67fa8 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 25 Mar 2026 11:02:04 -0400 Subject: [PATCH 07/85] Remove upstream workflows not relevant to our fork Keep only build.yml (Docker build + push to GHCR). Removed: docker.yml, e2e.yml, pr.yml, refresh-lockfile.yml, release.yml, release-smoke.yml. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/docker.yml | 55 ------ .github/workflows/e2e.yml | 44 ----- .github/workflows/release-smoke.yml | 118 ------------- .github/workflows/release.yml | 261 ---------------------------- 4 files changed, 478 deletions(-) delete mode 100644 .github/workflows/docker.yml delete mode 100644 .github/workflows/e2e.yml delete mode 100644 .github/workflows/release-smoke.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 490290c2..00000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Docker - -on: - push: - branches: - - "master" - tags: - - "v*" - -permissions: - contents: read - packages: write - -jobs: - build-and-push: - runs-on: ubuntu-latest - timeout-minutes: 30 - concurrency: - group: docker-${{ github.ref }} - cancel-in-progress: true - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/${{ github.repository }} - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - cache-from: type=gha - cache-to: type=gha,mode=max - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 8d154627..00000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: E2E Tests - -on: - workflow_dispatch: - inputs: - skip_llm: - description: "Skip LLM-dependent assertions (default: true)" - type: boolean - default: true - -jobs: - e2e: - runs-on: ubuntu-latest - timeout-minutes: 30 - env: - PAPERCLIP_E2E_SKIP_LLM: ${{ inputs.skip_llm && 'true' || 'false' }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - version: 9 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - - run: pnpm install --frozen-lockfile - - run: pnpm build - - run: npx playwright install --with-deps chromium - - - name: Run e2e tests - run: pnpm run test:e2e - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report - path: | - tests/e2e/playwright-report/ - tests/e2e/test-results/ - retention-days: 14 diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml deleted file mode 100644 index 823a578c..00000000 --- a/.github/workflows/release-smoke.yml +++ /dev/null @@ -1,118 +0,0 @@ -name: Release Smoke - -on: - workflow_dispatch: - inputs: - paperclip_version: - description: Published Paperclip dist-tag to test - required: true - default: canary - type: choice - options: - - canary - - latest - host_port: - description: Host port for the Docker smoke container - required: false - default: "3232" - type: string - artifact_name: - description: Artifact name for uploaded diagnostics - required: false - default: release-smoke - type: string - workflow_call: - inputs: - paperclip_version: - required: true - type: string - host_port: - required: false - default: "3232" - type: string - artifact_name: - required: false - default: release-smoke - type: string - -jobs: - smoke: - runs-on: ubuntu-latest - timeout-minutes: 45 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.15.4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - - - name: Install dependencies - run: pnpm install --no-frozen-lockfile - - - name: Install Playwright browser - run: npx playwright install --with-deps chromium - - - name: Launch Docker smoke harness - run: | - metadata_file="$RUNNER_TEMP/release-smoke.env" - HOST_PORT="${{ inputs.host_port }}" \ - DATA_DIR="$RUNNER_TEMP/release-smoke-data" \ - PAPERCLIPAI_VERSION="${{ inputs.paperclip_version }}" \ - SMOKE_DETACH=true \ - SMOKE_METADATA_FILE="$metadata_file" \ - ./scripts/docker-onboard-smoke.sh - set -a - source "$metadata_file" - set +a - { - echo "SMOKE_BASE_URL=$SMOKE_BASE_URL" - echo "SMOKE_ADMIN_EMAIL=$SMOKE_ADMIN_EMAIL" - echo "SMOKE_ADMIN_PASSWORD=$SMOKE_ADMIN_PASSWORD" - echo "SMOKE_CONTAINER_NAME=$SMOKE_CONTAINER_NAME" - echo "SMOKE_DATA_DIR=$SMOKE_DATA_DIR" - echo "SMOKE_IMAGE_NAME=$SMOKE_IMAGE_NAME" - echo "SMOKE_PAPERCLIPAI_VERSION=$SMOKE_PAPERCLIPAI_VERSION" - echo "SMOKE_METADATA_FILE=$metadata_file" - } >> "$GITHUB_ENV" - - - name: Run release smoke Playwright suite - env: - PAPERCLIP_RELEASE_SMOKE_BASE_URL: ${{ env.SMOKE_BASE_URL }} - PAPERCLIP_RELEASE_SMOKE_EMAIL: ${{ env.SMOKE_ADMIN_EMAIL }} - PAPERCLIP_RELEASE_SMOKE_PASSWORD: ${{ env.SMOKE_ADMIN_PASSWORD }} - run: pnpm run test:release-smoke - - - name: Capture Docker logs - if: always() - run: | - if [[ -n "${SMOKE_CONTAINER_NAME:-}" ]]; then - docker logs "$SMOKE_CONTAINER_NAME" >"$RUNNER_TEMP/docker-onboard-smoke.log" 2>&1 || true - fi - - - name: Upload diagnostics - if: always() - uses: actions/upload-artifact@v4 - with: - name: ${{ inputs.artifact_name }} - path: | - ${{ runner.temp }}/docker-onboard-smoke.log - ${{ env.SMOKE_METADATA_FILE }} - tests/release-smoke/playwright-report/ - tests/release-smoke/test-results/ - retention-days: 14 - - - name: Stop Docker smoke container - if: always() - run: | - if [[ -n "${SMOKE_CONTAINER_NAME:-}" ]]; then - docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true - fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 0b5983cc..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,261 +0,0 @@ -name: Release - -on: - push: - branches: - - master - workflow_dispatch: - inputs: - source_ref: - description: Commit SHA, branch, or tag to publish as stable - required: true - type: string - default: master - stable_date: - description: Enter a UTC date in YYYY-MM-DD format, for example 2026-03-18. Do not enter a version string. The workflow will resolve that date to a stable version such as 2026.318.0, then 2026.318.1 for the next same-day stable. - required: false - type: string - dry_run: - description: Preview the stable release without publishing - required: true - type: boolean - default: false - -concurrency: - group: release-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: false - -jobs: - verify_canary: - if: github.event_name == 'push' - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.15.4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - - - name: Install dependencies - run: pnpm install --no-frozen-lockfile - - - name: Typecheck - run: pnpm -r typecheck - - - name: Run tests - run: pnpm test:run - - - name: Build - run: pnpm build - - publish_canary: - if: github.event_name == 'push' - needs: verify_canary - runs-on: ubuntu-latest - timeout-minutes: 45 - environment: npm-canary - permissions: - contents: write - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.15.4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - - - name: Install dependencies - run: pnpm install --no-frozen-lockfile - - - name: Restore tracked install-time changes - run: git checkout -- pnpm-lock.yaml - - - name: Configure git author - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Publish canary - env: - GITHUB_ACTIONS: "true" - run: ./scripts/release.sh canary --skip-verify - - - name: Push canary tag - run: | - tag="$(git tag --points-at HEAD | grep '^canary/v' | head -1)" - if [ -z "$tag" ]; then - echo "Error: no canary tag points at HEAD after release." >&2 - exit 1 - fi - git push origin "refs/tags/${tag}" - - verify_stable: - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ inputs.source_ref }} - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.15.4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - - - name: Install dependencies - run: pnpm install --no-frozen-lockfile - - - name: Typecheck - run: pnpm -r typecheck - - - name: Run tests - run: pnpm test:run - - - name: Build - run: pnpm build - - preview_stable: - if: github.event_name == 'workflow_dispatch' && inputs.dry_run - needs: verify_stable - runs-on: ubuntu-latest - timeout-minutes: 45 - permissions: - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ inputs.source_ref }} - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.15.4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - - - name: Install dependencies - run: pnpm install --no-frozen-lockfile - - - name: Dry-run stable release - env: - GITHUB_ACTIONS: "true" - run: | - args=(stable --skip-verify --dry-run) - if [ -n "${{ inputs.stable_date }}" ]; then - args+=(--date "${{ inputs.stable_date }}") - fi - ./scripts/release.sh "${args[@]}" - - publish_stable: - if: github.event_name == 'workflow_dispatch' && !inputs.dry_run - needs: verify_stable - runs-on: ubuntu-latest - timeout-minutes: 45 - environment: npm-stable - permissions: - contents: write - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ inputs.source_ref }} - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.15.4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - - - name: Install dependencies - run: pnpm install --no-frozen-lockfile - - - name: Restore tracked install-time changes - run: git checkout -- pnpm-lock.yaml - - - name: Configure git author - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Publish stable - env: - GITHUB_ACTIONS: "true" - run: | - args=(stable --skip-verify) - if [ -n "${{ inputs.stable_date }}" ]; then - args+=(--date "${{ inputs.stable_date }}") - fi - ./scripts/release.sh "${args[@]}" - - - name: Push stable tag - run: | - tag="$(git tag --points-at HEAD | grep '^v' | head -1)" - if [ -z "$tag" ]; then - echo "Error: no stable tag points at HEAD after release." >&2 - exit 1 - fi - git push origin "refs/tags/${tag}" - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ github.token }} - PUBLISH_REMOTE: origin - run: | - version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')" - if [ -z "$version" ]; then - echo "Error: no v* tag points at HEAD after stable release." >&2 - exit 1 - fi - ./scripts/create-github-release.sh "$version" -- 2.52.0 From 45892739a5adbc6300ea89d138b44d628573c6eb Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 25 Mar 2026 14:23:09 -0400 Subject: [PATCH 08/85] chore(docker): add vim and nano to base image Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 4f61a890..cc2a3e6b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:lts-trixie-slim AS base ARG USER_UID=1000 ARG USER_GID=1000 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl git jq procps python3 python3-pip \ + && apt-get install -y --no-install-recommends ca-certificates curl git jq nano procps python3 python3-pip vim \ && mkdir -p -m 755 /etc/apt/keyrings \ && wget -nv -O/etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \ && echo "20e0125d6f6e077a9ad46f03371bc26d90b04939fb95170f5a1905099cc6bcc0 /etc/apt/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c - \ -- 2.52.0 From 296d051bd581e60a3d612f5adc12c9062ed561c9 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 25 Mar 2026 14:54:34 -0400 Subject: [PATCH 09/85] chore(docker): pre-install @ai-sdk/anthropic in opencode config dir Required by the custom minimax provider in opencode.json which uses @ai-sdk/anthropic to hit minimax's Anthropic-compatible API endpoint. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index cc2a3e6b..e0a6923f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,8 +66,10 @@ ARG USER_GID=1000 WORKDIR /app COPY --chown=node:node --from=build /app /app RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai @google/gemini-cli \ - && mkdir -p /paperclip \ - && chown node:node /paperclip + && mkdir -p /paperclip/.config/opencode \ + && cd /paperclip/.config/opencode \ + && npm install @ai-sdk/anthropic \ + && chown -R node:node /paperclip COPY scripts/docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh -- 2.52.0 From 3674cef6457a8e1fe2d26ab15c115a2b3bfddc57 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Mon, 30 Mar 2026 18:21:01 +0000 Subject: [PATCH 10/85] fix(ci): update org references from cpfarhood to farhoodliquor Update runner name and GHCR image path in build workflow to reflect the repo transfer from cpfarhood/paperclip to farhoodliquor/paperclip. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ee8d41eb..00c83a56 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,7 @@ permissions: jobs: build: - runs-on: runners-cpfarhood + runs-on: runners-farhoodliquor timeout-minutes: 30 steps: - name: Checkout @@ -37,7 +37,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/cpfarhood/paperclip + images: ghcr.io/farhoodliquor/paperclip tags: | type=raw,value=latest type=sha,prefix= -- 2.52.0 From ef7e6be8bb2057de30e4bc76dadb5930dd3ed9d1 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 15:13:13 -0400 Subject: [PATCH 11/85] Restore e2e and release-smoke workflows --- .github/workflows/e2e.yml | 44 +++++++++++ .github/workflows/release-smoke.yml | 118 ++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 .github/workflows/e2e.yml create mode 100644 .github/workflows/release-smoke.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..8d154627 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,44 @@ +name: E2E Tests + +on: + workflow_dispatch: + inputs: + skip_llm: + description: "Skip LLM-dependent assertions (default: true)" + type: boolean + default: true + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + PAPERCLIP_E2E_SKIP_LLM: ${{ inputs.skip_llm && 'true' || 'false' }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: npx playwright install --with-deps chromium + + - name: Run e2e tests + run: pnpm run test:e2e + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: | + tests/e2e/playwright-report/ + tests/e2e/test-results/ + retention-days: 14 diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml new file mode 100644 index 00000000..823a578c --- /dev/null +++ b/.github/workflows/release-smoke.yml @@ -0,0 +1,118 @@ +name: Release Smoke + +on: + workflow_dispatch: + inputs: + paperclip_version: + description: Published Paperclip dist-tag to test + required: true + default: canary + type: choice + options: + - canary + - latest + host_port: + description: Host port for the Docker smoke container + required: false + default: "3232" + type: string + artifact_name: + description: Artifact name for uploaded diagnostics + required: false + default: release-smoke + type: string + workflow_call: + inputs: + paperclip_version: + required: true + type: string + host_port: + required: false + default: "3232" + type: string + artifact_name: + required: false + default: release-smoke + type: string + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Launch Docker smoke harness + run: | + metadata_file="$RUNNER_TEMP/release-smoke.env" + HOST_PORT="${{ inputs.host_port }}" \ + DATA_DIR="$RUNNER_TEMP/release-smoke-data" \ + PAPERCLIPAI_VERSION="${{ inputs.paperclip_version }}" \ + SMOKE_DETACH=true \ + SMOKE_METADATA_FILE="$metadata_file" \ + ./scripts/docker-onboard-smoke.sh + set -a + source "$metadata_file" + set +a + { + echo "SMOKE_BASE_URL=$SMOKE_BASE_URL" + echo "SMOKE_ADMIN_EMAIL=$SMOKE_ADMIN_EMAIL" + echo "SMOKE_ADMIN_PASSWORD=$SMOKE_ADMIN_PASSWORD" + echo "SMOKE_CONTAINER_NAME=$SMOKE_CONTAINER_NAME" + echo "SMOKE_DATA_DIR=$SMOKE_DATA_DIR" + echo "SMOKE_IMAGE_NAME=$SMOKE_IMAGE_NAME" + echo "SMOKE_PAPERCLIPAI_VERSION=$SMOKE_PAPERCLIPAI_VERSION" + echo "SMOKE_METADATA_FILE=$metadata_file" + } >> "$GITHUB_ENV" + + - name: Run release smoke Playwright suite + env: + PAPERCLIP_RELEASE_SMOKE_BASE_URL: ${{ env.SMOKE_BASE_URL }} + PAPERCLIP_RELEASE_SMOKE_EMAIL: ${{ env.SMOKE_ADMIN_EMAIL }} + PAPERCLIP_RELEASE_SMOKE_PASSWORD: ${{ env.SMOKE_ADMIN_PASSWORD }} + run: pnpm run test:release-smoke + + - name: Capture Docker logs + if: always() + run: | + if [[ -n "${SMOKE_CONTAINER_NAME:-}" ]]; then + docker logs "$SMOKE_CONTAINER_NAME" >"$RUNNER_TEMP/docker-onboard-smoke.log" 2>&1 || true + fi + + - name: Upload diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact_name }} + path: | + ${{ runner.temp }}/docker-onboard-smoke.log + ${{ env.SMOKE_METADATA_FILE }} + tests/release-smoke/playwright-report/ + tests/release-smoke/test-results/ + retention-days: 14 + + - name: Stop Docker smoke container + if: always() + run: | + if [[ -n "${SMOKE_CONTAINER_NAME:-}" ]]; then + docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true + fi -- 2.52.0 From 4077ccd343464d651469e80164b9a2b522d422f1 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 9 Apr 2026 14:48:12 -0500 Subject: [PATCH 12/85] Fix signoff stage access and comment wake retries --- .../__tests__/issue-execution-policy.test.ts | 64 ++++++++----------- server/src/services/heartbeat.ts | 23 +++++++ server/src/services/issue-execution-policy.ts | 16 +++-- 3 files changed, 60 insertions(+), 43 deletions(-) diff --git a/server/src/__tests__/issue-execution-policy.test.ts b/server/src/__tests__/issue-execution-policy.test.ts index 7271b499..3d8a649b 100644 --- a/server/src/__tests__/issue-execution-policy.test.ts +++ b/server/src/__tests__/issue-execution-policy.test.ts @@ -413,45 +413,33 @@ describe("issue execution policy transitions", () => { const policy = twoStagePolicy(); const reviewStageId = policy.stages[0].id; - it("non-participant stage updates are coerced back to the active stage", () => { - const result = applyIssueExecutionPolicyTransition({ - issue: { - status: "in_review", - assigneeAgentId: qaAgentId, - assigneeUserId: null, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: reviewStageId, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: qaAgentId }, - returnAssignee: { type: "agent", agentId: coderAgentId }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, + it("non-participant cannot advance the active stage", () => { + expect(() => + applyIssueExecutionPolicyTransition({ + issue: { + status: "in_review", + assigneeAgentId: qaAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: { + status: "pending", + currentStageId: reviewStageId, + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: qaAgentId }, + returnAssignee: { type: "agent", agentId: coderAgentId }, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, }, - }, - policy, - requestedStatus: "done", - requestedAssigneePatch: { assigneeUserId: boardUserId }, - actor: { agentId: coderAgentId }, - commentBody: "Trying to bypass review", - }); - - expect(result.patch).toMatchObject({ - status: "in_review", - assigneeAgentId: qaAgentId, - assigneeUserId: null, - executionState: { - status: "pending", - currentStageId: reviewStageId, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: qaAgentId }, - returnAssignee: { type: "agent", agentId: coderAgentId }, - }, - }); - expect(result.decision).toBeUndefined(); + policy, + requestedStatus: "done", + requestedAssigneePatch: { assigneeUserId: boardUserId }, + actor: { agentId: coderAgentId }, + commentBody: "Trying to bypass review", + }), + ).toThrow("Only the active reviewer or approver can advance"); }); it("non-participant can still post non-advancing updates", () => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 954fe51b..45c5efd2 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -707,6 +707,18 @@ export function shouldResetTaskSessionForWake( return false; } +function shouldRequireIssueCommentForWake( + contextSnapshot: Record | null | undefined, +) { + const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); + return ( + wakeReason === "issue_assigned" || + wakeReason === "execution_review_requested" || + wakeReason === "execution_approval_requested" || + wakeReason === "execution_changes_requested" + ); +} + export function formatRuntimeWorkspaceWarningLog(warning: string) { return { stream: "stdout" as const, @@ -2035,6 +2047,17 @@ export function heartbeatService(db: Db) { return { outcome: "retry_exhausted" as const, queuedRun: null }; } + if (!shouldRequireIssueCommentForWake(contextSnapshot)) { + if (run.issueCommentStatus !== "not_applicable") { + await patchRunIssueCommentStatus(run.id, { + issueCommentStatus: "not_applicable", + issueCommentSatisfiedByCommentId: null, + issueCommentRetryQueuedAt: null, + }); + } + return { outcome: "not_applicable" as const, queuedRun: null }; + } + const queuedRun = await enqueueMissingIssueCommentRetry(run, agent, issueId); if (queuedRun) { await appendRunEvent(run, await nextRunEventSeq(run.id), { diff --git a/server/src/services/issue-execution-policy.ts b/server/src/services/issue-execution-policy.ts index 6f4ba7b5..6a3045a1 100644 --- a/server/src/services/issue-execution-policy.ts +++ b/server/src/services/issue-execution-policy.ts @@ -393,13 +393,19 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra } } - if ( + const attemptedStageAdvance = + (requestedStatus !== undefined && requestedStatus !== "in_review") || + (requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant)); + const stageStateDrifted = input.issue.status !== "in_review" || !principalsEqual(currentAssignee, currentParticipant) || - !principalsEqual(existingState?.currentParticipant ?? null, currentParticipant) || - (requestedStatus !== undefined && requestedStatus !== "in_review") || - (requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant)) - ) { + !principalsEqual(existingState?.currentParticipant ?? null, currentParticipant); + + if (attemptedStageAdvance && !stageStateDrifted) { + throw unprocessable("Only the active reviewer or approver can advance the current execution stage"); + } + + if (stageStateDrifted) { buildPendingStagePatch({ patch, previous: existingState, -- 2.52.0 From d9341795b0a29cfaa77207886d01cf6dad084523 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 15:35:47 -0400 Subject: [PATCH 13/85] feat(skills): GitHub PAT support for private skill repos + delete by source - Add optional authToken to skill import for GitHub private repos - Store PAT as encrypted company secret (skill-pat:{skillId}) - Thread auth token through ghFetch, fetchText, fetchJson, and all GitHub resolution functions - Add PATCH /companies/:companyId/skills/:skillId/auth for managing PAT per skill - Add DELETE /companies/:companyId/skills/by-source for bulk deleting skills from a repo - Preserve sourceAuthSecretId across skill re-imports/updates - UI: Add PAT input field in import form for GitHub URLs - UI: Add SkillAuthSection with ShieldCheck icon for viewing/updating/removing PAT - UI: Add trash icon next to source label for delete-by-source Co-Authored-By: Claude Opus 4.6 --- packages/shared/src/index.ts | 1 + .../shared/src/validators/company-skill.ts | 5 + packages/shared/src/validators/index.ts | 1 + .../__tests__/company-skills-routes.test.ts | 2 + server/src/routes/company-skills.ts | 67 ++++++- server/src/services/company-skills.ts | 154 +++++++++++++++-- server/src/services/github-fetch.ts | 8 +- ui/src/api/companySkills.ts | 17 +- ui/src/pages/CompanySkills.tsx | 163 ++++++++++++++++-- 9 files changed, 386 insertions(+), 32 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9b125165..83e5ff79 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -557,6 +557,7 @@ export { companySkillDetailSchema, companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillUpdateAuthSchema, companySkillProjectScanRequestSchema, companySkillProjectScanSkippedSchema, companySkillProjectScanConflictSchema, diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index 7f1df34b..26dcea66 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -66,6 +66,11 @@ export const companySkillUpdateStatusSchema = z.object({ export const companySkillImportSchema = z.object({ source: z.string().min(1), + authToken: z.string().min(1).optional(), +}); + +export const companySkillUpdateAuthSchema = z.object({ + authToken: z.string().min(1).nullable(), }); export const companySkillProjectScanRequestSchema = z.object({ diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index aca19625..2019f35c 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -44,6 +44,7 @@ export { companySkillDetailSchema, companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillUpdateAuthSchema, companySkillProjectScanRequestSchema, companySkillProjectScanSkippedSchema, companySkillProjectScanConflictSchema, diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 6dbad659..16b4f692 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -89,6 +89,7 @@ describe("company skill mutation permissions", () => { expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( "company-1", "https://github.com/vercel-labs/agent-browser", + undefined, ); }); @@ -266,6 +267,7 @@ describe("company skill mutation permissions", () => { expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( "company-1", "https://github.com/vercel-labs/agent-browser", + undefined, ); }); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 9e91bf26..b1a5f7cf 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -4,6 +4,7 @@ import { companySkillCreateSchema, companySkillFileUpdateSchema, companySkillImportSchema, + companySkillUpdateAuthSchema, companySkillProjectScanRequestSchema, } from "@paperclipai/shared"; import { trackSkillImported } from "@paperclipai/shared/telemetry"; @@ -194,7 +195,8 @@ export function companySkillRoutes(db: Db) { const companyId = req.params.companyId as string; await assertCanMutateCompanySkills(req, companyId); const source = String(req.body.source ?? ""); - const result = await svc.importFromSource(companyId, source); + const authToken = typeof req.body.authToken === "string" ? req.body.authToken.trim() : undefined; + const result = await svc.importFromSource(companyId, source, authToken || undefined); const actor = getActorInfo(req); await logActivity(db, { @@ -260,6 +262,36 @@ export function companySkillRoutes(db: Db) { }, ); + router.delete("/companies/:companyId/skills/by-source", async (req, res) => { + const companyId = req.params.companyId as string; + const sourceLocator = String(req.query.source ?? "").trim(); + if (!sourceLocator) { + res.status(400).json({ error: "source query parameter is required" }); + return; + } + await assertCanMutateCompanySkills(req, companyId); + const deleted = await svc.deleteBySource(companyId, sourceLocator); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skills_source_deleted", + entityType: "company", + entityId: companyId, + details: { + sourceLocator, + deletedCount: deleted.length, + deletedSlugs: deleted.map((s) => s.slug), + }, + }); + + res.json({ deleted }); + }); + router.delete("/companies/:companyId/skills/:skillId", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; @@ -318,5 +350,38 @@ export function companySkillRoutes(db: Db) { res.json(result); }); + router.patch( + "/companies/:companyId/skills/:skillId/auth", + validate(companySkillUpdateAuthSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + await assertCanMutateCompanySkills(req, companyId); + const authToken = req.body.authToken as string | null; + const result = await svc.updateSkillAuth(companyId, skillId, authToken); + if (!result) { + res.status(404).json({ error: "Skill not found" }); + return; + } + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: authToken ? "company.skill_auth_updated" : "company.skill_auth_removed", + entityType: "company_skill", + entityId: result.id, + details: { + slug: result.slug, + }, + }); + + res.json(result); + }, + ); + return router; } diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 60fc06b4..c94b9ffa 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -471,20 +471,20 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record(url: string): Promise { +async function fetchJson(url: string, authToken?: string): Promise { const response = await ghFetch(url, { headers: { accept: "application/vnd.github+json", }, - }); + }, authToken); if (!response.ok) { throw unprocessable(`Failed to fetch ${url}: ${response.status}`); } @@ -492,16 +492,18 @@ async function fetchJson(url: string): Promise { } -async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string) { +async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string, authToken?: string) { const response = await fetchJson<{ default_branch?: string }>( `${apiBase}/repos/${owner}/${repo}`, + authToken, ); return asString(response.default_branch) ?? "main"; } -async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string) { +async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string, authToken?: string) { const response = await fetchJson<{ sha?: string }>( `${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`, + authToken, ); const sha = asString(response.sha); if (!sha) { @@ -538,7 +540,7 @@ function parseGitHubSourceUrl(rawUrl: string) { return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef }; } -async function resolveGitHubPinnedRef(parsed: ReturnType) { +async function resolveGitHubPinnedRef(parsed: ReturnType, authToken?: string) { const apiBase = gitHubApiBase(parsed.hostname); if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) { return { @@ -549,8 +551,8 @@ async function resolveGitHubPinnedRef(parsed: ReturnType { const url = sourceUrl.trim(); const warnings: string[] = []; @@ -995,10 +998,11 @@ async function readUrlSkillImports( if (looksLikeRepoUrl) { const parsed = parseGitHubSourceUrl(url); const apiBase = gitHubApiBase(parsed.hostname); - const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed); + const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed, authToken); let ref = pinnedRef; const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>( `${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`, + authToken, ).catch(() => { throw unprocessable(`Failed to read GitHub tree for ${url}`); }); @@ -1025,7 +1029,7 @@ async function readUrlSkillImports( const skills: ImportedSkill[] = []; for (const relativeSkillPath of skillPaths) { const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath; - const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath)); + const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath), authToken); const parsedMarkdown = parseFrontmatterMarkdown(markdown); const skillDir = path.posix.dirname(relativeSkillPath); const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir)); @@ -1087,7 +1091,7 @@ async function readUrlSkillImports( } if (url.startsWith("http://") || url.startsWith("https://")) { - const markdown = await fetchText(url); + const markdown = await fetchText(url, authToken); const parsedMarkdown = parseFrontmatterMarkdown(markdown); const urlObj = new URL(url); const fileName = path.posix.basename(urlObj.pathname); @@ -1459,6 +1463,22 @@ export function companySkillService(db: Db) { const projects = projectService(db); const secretsSvc = secretService(db); + /** Resolve the GitHub auth token from a skill's metadata, if stored. */ + async function resolveSkillAuthToken( + companyId: string, + skill: { metadata: Record | null }, + ): Promise { + const meta = skill.metadata; + if (!meta) return undefined; + const secretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId.trim() : ""; + if (!secretId) return undefined; + try { + return await secretsSvc.resolveSecretValue(companyId, secretId, "latest"); + } catch { + return undefined; + } + } + async function ensureBundledSkills(companyId: string) { for (const skillsRoot of resolveBundledSkillsRoot()) { const stats = await fs.stat(skillsRoot).catch(() => null); @@ -1656,7 +1676,8 @@ export function companySkillService(db: Db) { const hostname = asString(metadata.hostname) || "github.com"; const apiBase = gitHubApiBase(hostname); - const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase); + const authToken = await resolveSkillAuthToken(companyId, skill); + const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase, authToken); return { supported: true, reason: null, @@ -1700,8 +1721,9 @@ export function companySkillService(db: Db) { if (!owner || !repo) { throw unprocessable("Skill source metadata is incomplete."); } + const authToken = await resolveSkillAuthToken(companyId, skill); const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath)); - content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath)); + content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath), authToken); } else if (skill.sourceType === "url") { if (normalizedPath !== "SKILL.md") { throw notFound("This skill source only exposes SKILL.md"); @@ -1818,7 +1840,8 @@ export function companySkillService(db: Db) { throw unprocessable("Skill source locator is missing."); } - const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug); + const authToken = await resolveSkillAuthToken(companyId, skill); + const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug, authToken); const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null; if (!matching) { throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`); @@ -2230,6 +2253,10 @@ export function companySkillService(db: Db) { const metadata = { ...(skill.metadata ?? {}), skillKey: skill.key, + // Preserve auth secret reference across re-imports/updates + ...(existing?.metadata && typeof (existing.metadata as Record).sourceAuthSecretId === "string" + ? { sourceAuthSecretId: (existing.metadata as Record).sourceAuthSecretId } + : {}), }; const values = { companyId, @@ -2265,7 +2292,7 @@ export function companySkillService(db: Db) { return out; } - async function importFromSource(companyId: string, source: string): Promise { + async function importFromSource(companyId: string, source: string, authToken?: string): Promise { await ensureSkillInventoryCurrent(companyId); const parsed = parseSkillImportSourceInput(source); const local = !/^https?:\/\//i.test(parsed.resolvedSource); @@ -2275,7 +2302,7 @@ export function companySkillService(db: Db) { .filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug), warnings: parsed.warnings, } - : await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug) + : await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug, authToken) .then((result) => ({ skills: result.skills, warnings: [...parsed.warnings, ...result.warnings], @@ -2302,6 +2329,35 @@ export function companySkillService(db: Db) { } } const imported = await upsertImportedSkills(companyId, filteredSkills); + + // Store the auth token as an encrypted company secret and link to imported skills + if (authToken && imported.length > 0) { + for (const skill of imported) { + const secretName = `skill-pat:${skill.id}`; + let secretId: string; + const existing = await secretsSvc.getByName(companyId, secretName); + if (existing) { + await secretsSvc.rotate(existing.id, { value: authToken }); + secretId = existing.id; + } else { + const created = await secretsSvc.create(companyId, { + name: secretName, + provider: "local_encrypted", + value: authToken, + description: `GitHub PAT for skill ${skill.slug}`, + }); + secretId = created.id; + } + // Store the secret ID in skill metadata + const meta = (skill.metadata ?? {}) as Record; + meta.sourceAuthSecretId = secretId; + await db + .update(companySkills) + .set({ metadata: meta, updatedAt: new Date() }) + .where(eq(companySkills.id, skill.id)); + } + } + return { imported, warnings }; } @@ -2344,6 +2400,68 @@ export function companySkillService(db: Db) { return skill; } + async function updateSkillAuth( + companyId: string, + skillId: string, + authToken: string | null, + ): Promise { + const skill = await getById(skillId); + if (!skill || skill.companyId !== companyId) return null; + + const meta = (skill.metadata ?? {}) as Record; + const existingSecretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null; + + if (authToken) { + // Set or update the PAT + const secretName = `skill-pat:${skill.id}`; + let secretId: string; + // Check metadata reference first, then fall back to name lookup + // (metadata ref may have been lost during a skill update/re-import) + const existingSecret = existingSecretId + ? await secretsSvc.getById(existingSecretId) + : await secretsSvc.getByName(companyId, secretName); + if (existingSecret) { + await secretsSvc.rotate(existingSecret.id, { value: authToken }); + secretId = existingSecret.id; + } else { + const created = await secretsSvc.create(companyId, { + name: secretName, + provider: "local_encrypted", + value: authToken, + description: `GitHub PAT for skill ${skill.slug}`, + }); + secretId = created.id; + } + meta.sourceAuthSecretId = secretId; + } else { + // Clear the PAT + delete meta.sourceAuthSecretId; + // Note: we don't delete the secret itself — it may be referenced in audit logs + } + + const [updated] = await db + .update(companySkills) + .set({ metadata: meta, updatedAt: new Date() }) + .where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId))) + .returning(); + return updated ? toCompanySkill(updated) : null; + } + + async function deleteBySource(companyId: string, sourceLocator: string): Promise { + const rows = await db + .select() + .from(companySkills) + .where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceLocator, sourceLocator))); + if (rows.length === 0) return []; + + const deleted: CompanySkill[] = []; + for (const row of rows) { + const result = await deleteSkill(companyId, row.id); + if (result) deleted.push(result); + } + return deleted; + } + return { list, listFull, @@ -2359,7 +2477,9 @@ export function companySkillService(db: Db) { updateFile, createLocalSkill, deleteSkill, + deleteBySource, importFromSource, + updateSkillAuth, scanProjectWorkspaces, importPackageFiles, installUpdate, diff --git a/server/src/services/github-fetch.ts b/server/src/services/github-fetch.ts index 787ae0ef..e8f8aee5 100644 --- a/server/src/services/github-fetch.ts +++ b/server/src/services/github-fetch.ts @@ -16,9 +16,13 @@ export function resolveRawGitHubUrl(hostname: string, owner: string, repo: strin : `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`; } -export async function ghFetch(url: string, init?: RequestInit): Promise { +export async function ghFetch(url: string, init?: RequestInit, authToken?: string): Promise { + const headers = new Headers(init?.headers); + if (authToken) { + headers.set("Authorization", `Bearer ${authToken}`); + } try { - return await fetch(url, init); + return await fetch(url, { ...init, headers }); } catch { throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`); } diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index 7377b2fa..bbdb2e60 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -36,10 +36,23 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills`, payload, ), - importFromSource: (companyId: string, source: string) => + importFromSource: (companyId: string, source: string, authToken?: string) => api.post( `/companies/${encodeURIComponent(companyId)}/skills/import`, - { source }, + { source, ...(authToken ? { authToken } : {}) }, + ), + updateAuth: (companyId: string, skillId: string, authToken: string | null) => + api.patch( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/auth`, + { authToken }, + ), + remove: (companyId: string, skillId: string) => + api.delete( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, + ), + removeBySource: (companyId: string, sourceLocator: string) => + api.delete<{ deleted: CompanySkill[] }>( + `/companies/${encodeURIComponent(companyId)}/skills/by-source?source=${encodeURIComponent(sourceLocator)}`, ), scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) => api.post( diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index cc2d5605..5b1530ec 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type SVGProps } from "react"; import { Link, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { + CompanySkill, CompanySkillCreateRequest, CompanySkillDetail, CompanySkillFileDetail, @@ -52,6 +53,7 @@ import { RefreshCw, Save, Search, + ShieldCheck, Trash2, } from "lucide-react"; @@ -487,6 +489,103 @@ function SkillList({ ); } +function SkillAuthSection({ + companyId, + skillId, + hasAuth, +}: { + companyId: string; + skillId: string; + hasAuth: boolean; +}) { + const queryClient = useQueryClient(); + const { pushToast } = useToast(); + const [editing, setEditing] = useState(false); + const [token, setToken] = useState(""); + + const updateAuth = useMutation({ + mutationFn: (authToken: string | null) => + companySkillsApi.updateAuth(companyId, skillId, authToken), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(companyId, skillId) }); + setEditing(false); + setToken(""); + pushToast({ tone: "success", title: "Auth updated" }); + }, + onError: (error) => { + pushToast({ + tone: "error", + title: "Failed to update auth", + body: error instanceof Error ? error.message : "Unknown error", + }); + }, + }); + + return ( +
+ Auth + {!editing ? ( + <> + {hasAuth ? ( + <> + + + + ) : ( + + )} + + ) : ( + <> + setToken(e.target.value)} + placeholder="GitHub Personal Access Token" + className="flex-1 min-w-[200px] rounded-md border border-border px-2 py-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50" + autoComplete="off" + autoFocus + /> + + + + )} +
+ ); +} + function SkillPane({ loading, detail, @@ -525,7 +624,7 @@ function SkillPane({ checkUpdatesPending: boolean; onInstallUpdate: () => void; installUpdatePending: boolean; - onDelete: () => void; + onDelete: (sourceLocator?: string | null) => void; deletePending: boolean; onSave: () => void; savePending: boolean; @@ -572,7 +671,7 @@ function SkillPane({
+ {(detail.sourceType === "github" || detail.sourceType === "skills_sh") && ( + | null)?.sourceAuthSecretId)} + /> + )} {detail.sourceType === "github" && (
Pin @@ -762,6 +876,7 @@ export function CompanySkills() { const { pushToast } = useToast(); const [skillFilter, setSkillFilter] = useState(""); const [source, setSource] = useState(""); + const [importAuthToken, setImportAuthToken] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false); const [expandedSkillId, setExpandedSkillId] = useState(null); @@ -886,8 +1001,17 @@ export function CompanySkills() { } } + function handleDeleteSkill(sourceLocator?: string | null) { + if (sourceLocator) { + deleteSkill.mutate(sourceLocator); + } else { + openDeleteDialog(); + } + } + const importSkill = useMutation({ - mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource), + mutationFn: ({ importSource, authToken }: { importSource: string; authToken?: string }) => + companySkillsApi.importFromSource(selectedCompanyId!, importSource, authToken), onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }); if (result.imported[0]) navigate(skillRoute(result.imported[0].id)); @@ -900,6 +1024,7 @@ export function CompanySkills() { pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] }); } setSource(""); + setImportAuthToken(""); }, onError: (error) => { pushToast({ @@ -1026,8 +1151,13 @@ export function CompanySkills() { }); const deleteSkill = useMutation({ - mutationFn: () => companySkillsApi.delete(selectedCompanyId!, deleteTargetSkillId!), - onSuccess: async (skill) => { + mutationFn: (sourceLocator?: string | null): Promise<{ deleted: CompanySkill[] }> => { + if (sourceLocator) { + return companySkillsApi.removeBySource(selectedCompanyId!, sourceLocator); + } + return companySkillsApi.remove(selectedCompanyId!, deleteTargetSkillId!).then((skill) => ({ deleted: [skill] })); + }, + onSuccess: async (result) => { closeDeleteDialog(false); setDisplayedDetail(null); setDisplayedFile(null); @@ -1048,10 +1178,10 @@ export function CompanySkills() { type: "active", }); navigate("/skills", { replace: true }); + const count = result.deleted.length; pushToast({ tone: "success", - title: "Skill removed", - body: `${skill.name} was removed from the company skill library.`, + title: `${count} skill${count === 1 ? "" : "s"} removed`, }); }, onError: (error) => { @@ -1073,7 +1203,8 @@ export function CompanySkills() { setEmptySourceHelpOpen(true); return; } - importSkill.mutate(trimmedSource); + const token = importAuthToken.trim() || undefined; + importSkill.mutate({ importSource: trimmedSource, authToken: token }); } return ( @@ -1115,7 +1246,7 @@ export function CompanySkills() {
+ {source.trim().length > 0 && /github\.com|^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+/.test(source.trim()) && ( +
+ setImportAuthToken(event.target.value)} + placeholder="GitHub PAT (optional, for private repos)" + className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" + autoComplete="off" + /> +
+ )} {scanStatusMessage && (

{scanStatusMessage} @@ -1284,7 +1427,7 @@ export function CompanySkills() { checkUpdatesPending={updateStatusQuery.isFetching} onInstallUpdate={() => installUpdate.mutate()} installUpdatePending={installUpdate.isPending} - onDelete={openDeleteDialog} + onDelete={handleDeleteSkill} deletePending={deleteSkill.isPending} onSave={() => saveFile.mutate()} savePending={saveFile.isPending} -- 2.52.0 From 89909db27c429b1a31b4e6ea2e0aa1cb11a40a86 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 15:57:36 -0400 Subject: [PATCH 14/85] fix(skills): atomic deleteBySource + PAT secret cleanup on skill deletion - Pre-check all skills for agent usage before deleting any in deleteBySource to prevent partial/failed deletions - Delete (rotate to empty) the skill-pat: secret when a skill is deleted to prevent orphaned PAT secrets Co-Authored-By: Claude Opus 4.6 --- server/src/services/company-skills.ts | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index c94b9ffa..587adc68 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -2397,6 +2397,17 @@ export function companySkillService(db: Db) { // Clean up materialized runtime files await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true }); + // Delete associated PAT secret if present + const meta = skill.metadata as Record | null; + const secretId = typeof meta?.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null; + if (secretId) { + try { + await secretsSvc.remove(secretId); + } catch { + // Best-effort: don't fail the skill deletion if secret cleanup fails + } + } + return skill; } @@ -2454,6 +2465,28 @@ export function companySkillService(db: Db) { .where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceLocator, sourceLocator))); if (rows.length === 0) return []; + // Pre-check all skills for agent usage before deleting any (atomicity) + const skills = rows.map(toCompanySkill); + for (const skill of skills) { + const usedByAgents = await usage(companyId, skill.key); + if (usedByAgents.length > 0) { + const agentNames = usedByAgents.map((agent) => agent.name).sort((left, right) => left.localeCompare(right)); + throw unprocessable( + `Cannot delete skills from "${sourceLocator}" because skill "${skill.name}" is still used by ${agentNames.join(", ")}. Detach it from those agents first.`, + { + skillId: skill.id, + skillKey: skill.key, + usedByAgents: usedByAgents.map((agent) => ({ + id: agent.id, + name: agent.name, + urlKey: agent.urlKey, + adapterType: agent.adapterType, + })), + }, + ); + } + } + const deleted: CompanySkill[] = []; for (const row of rows) { const result = await deleteSkill(companyId, row.id); -- 2.52.0 From e3c172a06f1506c81ffe7a144eb3e41c3bbed96f Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 16:02:05 -0400 Subject: [PATCH 15/85] fix(ui): remove dead delete API method and add confirmation for delete-by-source - Remove duplicate `delete` method (identical to `remove`) - Route delete-by-source through confirmation dialog with source locator displayed and "Remove all from source" button Co-Authored-By: Claude Opus 4.6 --- ui/src/api/companySkills.ts | 4 --- ui/src/pages/CompanySkills.tsx | 57 +++++++++++++++++++++------------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index bbdb2e60..f72c64c3 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -64,8 +64,4 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`, {}, ), - delete: (companyId: string, skillId: string) => - api.delete( - `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, - ), }; diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 5b1530ec..d2124b03 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -890,6 +890,7 @@ export function CompanySkills() { const [deleteOpen, setDeleteOpen] = useState(false); const [deleteTargetSkillId, setDeleteTargetSkillId] = useState(null); const [deleteTargetDetail, setDeleteTargetDetail] = useState(null); + const [deleteTargetSourceLocator, setDeleteTargetSourceLocator] = useState(null); const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]); const routeSkillId = parsedRoute.skillId; const selectedPath = parsedRoute.filePath; @@ -998,12 +999,16 @@ export function CompanySkills() { if (!open) { setDeleteTargetSkillId(null); setDeleteTargetDetail(null); + setDeleteTargetSourceLocator(null); } } function handleDeleteSkill(sourceLocator?: string | null) { if (sourceLocator) { - deleteSkill.mutate(sourceLocator); + setDeleteTargetSourceLocator(sourceLocator); + setDeleteTargetSkillId(null); + setDeleteTargetDetail(null); + setDeleteOpen(true); } else { openDeleteDialog(); } @@ -1212,30 +1217,40 @@ export function CompanySkills() {

- Remove skill + {deleteTargetSourceLocator ? "Remove skills from source" : "Remove skill"} - Remove this skill from the company library. If any agents still use it, removal will be blocked until it is detached. + {deleteTargetSourceLocator + ? `All skills imported from this source will be permanently removed from the company library.` + : "Remove this skill from the company library. If any agents still use it, removal will be blocked until it is detached."}
-

- {deleteTargetDetail - ? `You are about to remove ${deleteTargetDetail.name}.` - : "You are about to remove this skill."} -

- {deleteTargetDetail?.usedByAgents?.length ? ( -
- Currently used by {deleteTargetDetail.usedByAgents.map((agent) => agent.name).join(", ")}. -
- ) : null} - {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? ( -

- Detach this skill from all agents to enable removal. + {deleteTargetSourceLocator ? ( +

+ {deleteTargetSourceLocator}

- ) : null} + ) : ( + <> +

+ {deleteTargetDetail + ? `You are about to remove ${deleteTargetDetail.name}.` + : "You are about to remove this skill."} +

+ {deleteTargetDetail?.usedByAgents?.length ? ( +
+ Currently used by {deleteTargetDetail.usedByAgents.map((agent) => agent.name).join(", ")}. +
+ ) : null} + {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? ( +

+ Detach this skill from all agents to enable removal. +

+ ) : null} + + )}
- {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? ( + {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 && !deleteTargetSourceLocator ? ( @@ -1246,10 +1261,10 @@ export function CompanySkills() { )} -- 2.52.0 From 1956ccd7b5b7eb149de26f67d1db701b90d7f4ab Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 16:03:33 -0400 Subject: [PATCH 16/85] fix: add companyId filter to metadata update + export CompanySkillUpdateAuth type - Scope metadata update WHERE clause to companyId for defence-in-depth - Add CompanySkillUpdateAuth inferred type export to match other schemas Co-Authored-By: Claude Opus 4.6 --- packages/shared/src/validators/company-skill.ts | 1 + packages/shared/src/validators/index.ts | 1 + server/src/services/company-skills.ts | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index 26dcea66..58b9165d 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -138,3 +138,4 @@ export type CompanySkillImport = z.infer; export type CompanySkillProjectScan = z.infer; export type CompanySkillCreate = z.infer; export type CompanySkillFileUpdate = z.infer; +export type CompanySkillUpdateAuth = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 2019f35c..d1bc3929 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -56,6 +56,7 @@ export { type CompanySkillProjectScan, type CompanySkillCreate, type CompanySkillFileUpdate, + type CompanySkillUpdateAuth, } from "./company-skill.js"; export { agentSkillStateSchema, diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 587adc68..6549fda8 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -2354,7 +2354,7 @@ export function companySkillService(db: Db) { await db .update(companySkills) .set({ metadata: meta, updatedAt: new Date() }) - .where(eq(companySkills.id, skill.id)); + .where(and(eq(companySkills.id, skill.id), eq(companySkills.companyId, companyId))); } } -- 2.52.0 From b8133d6a3578646d6a4f87377c3401d6eb4da9d2 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 17:07:25 -0400 Subject: [PATCH 17/85] fix(docker): add wget to apt-get install wget is called immediately after apt-get install but was not included in the package list, causing the build to fail. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e0a6923f..ef6e4de4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:lts-trixie-slim AS base ARG USER_UID=1000 ARG USER_GID=1000 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl git jq nano procps python3 python3-pip vim \ + && apt-get install -y --no-install-recommends ca-certificates curl git jq nano procps python3 python3-pip vim wget \ && mkdir -p -m 755 /etc/apt/keyrings \ && wget -nv -O/etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \ && echo "20e0125d6f6e077a9ad46f03371bc26d90b04939fb95170f5a1905099cc6bcc0 /etc/apt/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c - \ -- 2.52.0 From 7d55b8d9d0d1627b0aa7c1e840225544d7fdfe15 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 17:11:02 -0400 Subject: [PATCH 18/85] fix(docker): update GitHub CLI keyring SHA256 checksum The hardcoded checksum was out of date, causing sha256sum verification to fail and abort the build. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ef6e4de4..4b0e0d57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl git jq nano procps python3 python3-pip vim wget \ && mkdir -p -m 755 /etc/apt/keyrings \ && wget -nv -O/etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - && echo "20e0125d6f6e077a9ad46f03371bc26d90b04939fb95170f5a1905099cc6bcc0 /etc/apt/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c - \ + && echo "6084d5d7bd8e288441e0e94fc6275570895da18e6751f70f057485dc2d1a811b /etc/apt/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c - \ && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ && mkdir -p -m 755 /etc/apt/sources.list.d \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \ -- 2.52.0 From 21411b80b2c76491e6fb039a97bd9f80ae841acc Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 17:14:51 -0400 Subject: [PATCH 19/85] fix(docker): install gh via direct binary instead of keyring/apt The GitHub CLI keyring approach requires a hardcoded SHA256 checksum that drifts as the keyring file is updated upstream, causing build failures. Replace with direct binary tarball download which is simpler and has no checksum drift issue. Also removed wget (only needed for keyring download). Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4b0e0d57..bcba132a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,16 +2,11 @@ FROM node:lts-trixie-slim AS base ARG USER_UID=1000 ARG USER_GID=1000 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl git jq nano procps python3 python3-pip vim wget \ - && mkdir -p -m 755 /etc/apt/keyrings \ - && wget -nv -O/etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - && echo "6084d5d7bd8e288441e0e94fc6275570895da18e6751f70f057485dc2d1a811b /etc/apt/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c - \ - && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ - && mkdir -p -m 755 /etc/apt/sources.list.d \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends gh \ + && apt-get install -y --no-install-recommends ca-certificates curl git jq nano procps python3 python3-pip vim \ && rm -rf /var/lib/apt/lists/* \ + && curl -fsSL https://github.com/cli/cli/releases/download/v2.67.2/gh_2.67.2_linux_amd64.tar.gz | tar -xzf - -C /tmp \ + && mv /tmp/gh_2.67.2_linux_amd64/bin/gh /usr/local/bin/ \ + && rm -rf /tmp/gh_* \ && curl -fsSL "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/$(dpkg --print-architecture)/kubectl" \ -o /usr/local/bin/kubectl \ && chmod +x /usr/local/bin/kubectl \ -- 2.52.0 From 002c470ee77b8f9caebb2940771ca14fe623098c Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 17:14:51 -0400 Subject: [PATCH 20/85] fix(docker): install gh via direct binary instead of keyring/apt The GitHub CLI keyring approach requires a hardcoded SHA256 checksum that drifts as the keyring file is updated upstream, causing build failures. Replace with direct binary tarball download which is simpler and has no checksum drift issue. Also removed wget (only needed for keyring download). Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index bcba132a..893ac378 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,8 @@ ARG USER_GID=1000 RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl git jq nano procps python3 python3-pip vim \ && rm -rf /var/lib/apt/lists/* \ - && curl -fsSL https://github.com/cli/cli/releases/download/v2.67.2/gh_2.67.2_linux_amd64.tar.gz | tar -xzf - -C /tmp \ - && mv /tmp/gh_2.67.2_linux_amd64/bin/gh /usr/local/bin/ \ + && curl -fsSL https://github.com/cli/cli/releases/download/v2.89.0/gh_2.89.0_linux_amd64.tar.gz | tar -xzf - -C /tmp \ + && mv /tmp/gh_2.89.0_linux_amd64/bin/gh /usr/local/bin/ \ && rm -rf /tmp/gh_* \ && curl -fsSL "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/$(dpkg --print-architecture)/kubectl" \ -o /usr/local/bin/kubectl \ -- 2.52.0 From 8dff385086b597e2e0445accb870b47db329694f Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 17:22:00 -0400 Subject: [PATCH 21/85] fix(docker): pin kubectl and kubeseal versions, use correct kubeseal URL - kubectl: pin to v1.32.0 instead of dynamic stable.txt (which was returning a version with no matching binary, causing 404) - kubeseal: fix URL to use versioned tarball (v0.36.6) instead of /latest which had no unversioned asset, causing 404 - also removed wget (no longer needed after removing keyring/apt) Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 893ac378..c4d5c1b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,15 +7,15 @@ RUN apt-get update \ && curl -fsSL https://github.com/cli/cli/releases/download/v2.89.0/gh_2.89.0_linux_amd64.tar.gz | tar -xzf - -C /tmp \ && mv /tmp/gh_2.89.0_linux_amd64/bin/gh /usr/local/bin/ \ && rm -rf /tmp/gh_* \ - && curl -fsSL "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/$(dpkg --print-architecture)/kubectl" \ + && curl -fsSL "https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl" \ -o /usr/local/bin/kubectl \ && chmod +x /usr/local/bin/kubectl \ && curl -LsSf https://astral.sh/uv/install.sh | sh \ && mv /root/.local/bin/uv /usr/local/bin/uv \ && mv /root/.local/bin/uvx /usr/local/bin/uvx \ - && curl -fsSL "https://github.com/bitnami-labs/sealed-secrets/releases/latest/download/kubeseal-$(uname -s | tr '[:upper:]' '[:lower:]')-$(dpkg --print-architecture)" \ - -o /usr/local/bin/kubeseal \ - && chmod +x /usr/local/bin/kubeseal + && curl -fsSL "https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.36.6/kubeseal-0.36.6-linux-amd64.tar.gz" | tar -xzf - -C /tmp \ + && mv /tmp/kubeseal /usr/local/bin/kubeseal \ + && rm -rf /tmp/kubeseal /tmp/LICENSE /tmp/README.md \ # Modify the existing node user/group to have the specified UID/GID to match host user RUN usermod -u $USER_UID --non-unique node \ -- 2.52.0 From 99c3289d8e590420955f8d1a873f022c570421c4 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 17:22:00 -0400 Subject: [PATCH 22/85] fix(docker): pin kubectl and kubeseal versions, use correct kubeseal URL - kubectl: pin to v1.32.0 instead of dynamic stable.txt (which was returning a version with no matching binary, causing 404) - kubeseal: fix URL to use versioned tarball (v0.36.6) instead of /latest which had no unversioned asset, causing 404 - also removed wget (no longer needed after removing keyring/apt) Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c4d5c1b6..cf0b9d05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:lts-trixie-slim AS base ARG USER_UID=1000 ARG USER_GID=1000 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl git jq nano procps python3 python3-pip vim \ + && apt-get install -y --no-install-recommends ca-certificates curl git jq nano procps python3 python3-pip vim passwd \ && rm -rf /var/lib/apt/lists/* \ && curl -fsSL https://github.com/cli/cli/releases/download/v2.89.0/gh_2.89.0_linux_amd64.tar.gz | tar -xzf - -C /tmp \ && mv /tmp/gh_2.89.0_linux_amd64/bin/gh /usr/local/bin/ \ -- 2.52.0 From 26155c2b9044ecf5daa841053bc3187ef6b44ae2 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 17:27:14 -0400 Subject: [PATCH 23/85] chore(docker): revert to upstream Dockerfile The fork added build-time tooling (kubectl, kubeseal, uv, nano, vim) that is not needed inside the container build and was causing repeated build failures due to URL/checksum drift. These tools belong in the runtime environment, not the image build. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 37 +++++++++++++++---------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/Dockerfile b/Dockerfile index cf0b9d05..36d5acab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,31 +2,26 @@ FROM node:lts-trixie-slim AS base ARG USER_UID=1000 ARG USER_GID=1000 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl git jq nano procps python3 python3-pip vim passwd \ + && apt-get install -y --no-install-recommends ca-certificates gosu curl git wget ripgrep python3 \ + && mkdir -p -m 755 /etc/apt/keyrings \ + && wget -nv -O/etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + && echo "20e0125d6f6e077a9ad46f03371bc26d90b04939fb95170f5a1905099cc6bcc0 /etc/apt/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c - \ + && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && mkdir -p -m 755 /etc/apt/sources.list.d \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ && rm -rf /var/lib/apt/lists/* \ - && curl -fsSL https://github.com/cli/cli/releases/download/v2.89.0/gh_2.89.0_linux_amd64.tar.gz | tar -xzf - -C /tmp \ - && mv /tmp/gh_2.89.0_linux_amd64/bin/gh /usr/local/bin/ \ - && rm -rf /tmp/gh_* \ - && curl -fsSL "https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl" \ - -o /usr/local/bin/kubectl \ - && chmod +x /usr/local/bin/kubectl \ - && curl -LsSf https://astral.sh/uv/install.sh | sh \ - && mv /root/.local/bin/uv /usr/local/bin/uv \ - && mv /root/.local/bin/uvx /usr/local/bin/uvx \ - && curl -fsSL "https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.36.6/kubeseal-0.36.6-linux-amd64.tar.gz" | tar -xzf - -C /tmp \ - && mv /tmp/kubeseal /usr/local/bin/kubeseal \ - && rm -rf /tmp/kubeseal /tmp/LICENSE /tmp/README.md \ + && corepack enable # Modify the existing node user/group to have the specified UID/GID to match host user RUN usermod -u $USER_UID --non-unique node \ && groupmod -g $USER_GID --non-unique node \ && usermod -g $USER_GID -d /paperclip node -RUN corepack enable - FROM base AS deps WORKDIR /app -COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* .npmrc ./ +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./ COPY cli/package.json cli/ COPY server/package.json server/ COPY ui/package.json ui/ @@ -44,7 +39,7 @@ COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ COPY packages/plugins/sdk/package.json packages/plugins/sdk/ COPY patches/ patches/ -RUN pnpm install --no-frozen-lockfile +RUN pnpm install --frozen-lockfile FROM base AS build WORKDIR /app @@ -60,11 +55,9 @@ ARG USER_UID=1000 ARG USER_GID=1000 WORKDIR /app COPY --chown=node:node --from=build /app /app -RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai @google/gemini-cli \ - && mkdir -p /paperclip/.config/opencode \ - && cd /paperclip/.config/opencode \ - && npm install @ai-sdk/anthropic \ - && chown -R node:node /paperclip +RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \ + && mkdir -p /paperclip \ + && chown node:node /paperclip COPY scripts/docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh -- 2.52.0 From ae0b3449158dc477bf30273f6fac40f618669677 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 17:31:29 -0400 Subject: [PATCH 24/85] fix(docker): install gh via direct binary to fix keyring checksum issue --- Dockerfile | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 36d5acab..5c3b3bef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,15 +3,10 @@ ARG USER_UID=1000 ARG USER_GID=1000 RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates gosu curl git wget ripgrep python3 \ - && mkdir -p -m 755 /etc/apt/keyrings \ - && wget -nv -O/etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - && echo "20e0125d6f6e077a9ad46f03371bc26d90b04939fb95170f5a1905099cc6bcc0 /etc/apt/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c - \ - && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ - && mkdir -p -m 755 /etc/apt/sources.list.d \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends gh \ && rm -rf /var/lib/apt/lists/* \ + && curl -fsSL https://github.com/cli/cli/releases/download/v2.89.0/gh_2.89.0_linux_amd64.tar.gz | tar -xzf - -C /tmp \ + && mv /tmp/gh_2.89.0_linux_amd64/bin/gh /usr/local/bin/ \ + && rm -rf /tmp/gh_* \ && corepack enable # Modify the existing node user/group to have the specified UID/GID to match host user -- 2.52.0 From 4640417166b7c8e50008449d2f07d33e6adff2d3 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 17:37:58 -0400 Subject: [PATCH 25/85] feat(docker): add kubectl, kubeseal, uv, nano, vim to production stage Install custom tooling in the production stage via direct binaries and apt so it doesn't break the base stage build. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5c3b3bef..0550b410 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,7 +50,18 @@ ARG USER_UID=1000 ARG USER_GID=1000 WORKDIR /app COPY --chown=node:node --from=build /app /app -RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \ +RUN apt-get update \ + && apt-get install -y --no-install-recommends nano vim \ + && rm -rf /var/lib/apt/lists/* \ + && curl -fsSL https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \ + && chmod +x /usr/local/bin/kubectl \ + && curl -fsSL https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.36.6/kubeseal-0.36.6-linux-amd64.tar.gz | tar -xzf - -C /tmp \ + && mv /tmp/kubeseal /usr/local/bin/kubeseal \ + && rm -rf /tmp/kubeseal /tmp/LICENSE /tmp/README.md \ + && curl -LsSf https://astral.sh/uv/install.sh | sh \ + && mv /root/.local/bin/uv /usr/local/bin/uv \ + && mv /root/.local/bin/uvx /usr/local/bin/uvx \ + && npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \ && mkdir -p /paperclip \ && chown node:node /paperclip -- 2.52.0 From 7c42345177567882ca643f8d7d8caf5c89738d5e Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Fri, 10 Apr 2026 12:16:25 +0200 Subject: [PATCH 26/85] chore: re-trigger CI to refresh PR base SHA -- 2.52.0 From 724893ad5b5dc5164a2538881fdde52508230be8 Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Fri, 10 Apr 2026 14:22:48 +0200 Subject: [PATCH 27/85] fix claude instruction sibling path hint --- packages/adapters/claude-local/src/server/execute.ts | 6 +++++- server/src/__tests__/claude-local-execute.test.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index ae5ea3ab..7116be44 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -387,7 +387,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(captured[1]?.appendedSystemPromptFilePath).not.toBe(instructionsFile); expect(captured[1]?.appendedSystemPromptFileContents).toContain("# Agent instructions"); expect(captured[1]?.appendedSystemPromptFileContents).toContain( - `The above agent instructions were loaded from ${instructionsFile}. Resolve any relative file references from ${path.dirname(instructionsFile)}/.`, + `The above agent instructions were loaded from ${instructionsFile}. ` + + `Resolve any relative file references from ${path.dirname(instructionsFile)}/. ` + + `This base directory is authoritative for sibling instruction files such as ` + + `./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`, ); expect(metaEvents).toHaveLength(2); expect(metaEvents[0]?.commandNotes).toHaveLength(0); -- 2.52.0 From ac664df8e48326135a913e97ee7ed937d913586b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:55:27 -0500 Subject: [PATCH 28/85] fix(authz): scope import, approvals, activity, and heartbeat routes (#3315) ## Thinking Path > - Paperclip orchestrates AI agents and company-scoped control-plane actions for zero-human companies. > - This change touches the server authz boundary around company portability, approvals, activity, and heartbeat-run operations. > - The vulnerability was that board-authenticated callers could cross company boundaries or create new companies through import paths without the same authorization checks enforced elsewhere. > - Once that gap existed, an attacker could chain it into higher-impact behavior through agent execution paths. > - The fix needed to harden every confirmed authorization gap in the reported chain, not just the first route that exposed it. > - This pull request adds the missing instance-admin and company-access checks and adds regression tests for each affected route. > - The benefit is that cross-company actions and new-company import flows now follow the same control-plane authorization rules as the rest of the product. ## What Changed - Required instance-admin access for `new_company` import preview/apply flows in `server/src/routes/companies.ts`. - Required company access before approval decision routes in `server/src/routes/approvals.ts`. - Required company access for activity creation and heartbeat-run issue listing in `server/src/routes/activity.ts`. - Required company access before heartbeat cancellation in `server/src/routes/agents.ts`. - Added regression coverage in the corresponding server route tests. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/company-portability-routes.test.ts src/__tests__/approval-routes-idempotency.test.ts src/__tests__/activity-routes.test.ts src/__tests__/agent-permissions-routes.test.ts` - `pnpm --filter @paperclipai/server typecheck` - Prior verification on the original security patch branch also included `pnpm build`. ## Risks - Low code risk: the change is narrow and only adds missing authorization gates to existing routes. - Operational risk: the advisory is already public, so this PR should be merged quickly to minimize the public unpatched window. - Residual product risk remains around open signup / bootstrap defaults, which was intentionally left out of this patch because the current first-user onboarding flow depends on it. ## Model Used - OpenAI GPT-5 Codex coding agent with tool use and local code execution in the Codex CLI environment. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Forgotten --- server/src/__tests__/activity-routes.test.ts | 33 ++++++++++++ .../agent-permissions-routes.test.ts | 24 +++++++++ .../approval-routes-idempotency.test.ts | 52 ++++++++++++++++++- .../company-portability-routes.test.ts | 46 ++++++++++++++++ server/src/routes/activity.ts | 10 +++- server/src/routes/agents.ts | 4 ++ server/src/routes/approvals.ts | 23 +++++++- server/src/routes/companies.ts | 21 +++++--- 8 files changed, 203 insertions(+), 10 deletions(-) diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 86ee374d..ffd343df 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -10,6 +10,10 @@ const mockActivityService = vi.hoisted(() => ({ create: vi.fn(), })); +const mockHeartbeatService = vi.hoisted(() => ({ + getRun: vi.fn(), +})); + const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), getByIdentifier: vi.fn(), @@ -22,6 +26,7 @@ function registerRouteMocks() { vi.doMock("../services/index.js", () => ({ issueService: () => mockIssueService, + heartbeatService: () => mockHeartbeatService, })); } @@ -75,4 +80,32 @@ describe("activity routes", () => { expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1"); expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]); }); + + it("requires company access before creating activity events", async () => { + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-2/activity") + .send({ + actorId: "user-1", + action: "test.event", + entityType: "issue", + entityId: "issue-1", + }); + + expect(res.status).toBe(403); + expect(mockActivityService.create).not.toHaveBeenCalled(); + }); + + it("requires company access before listing issues for another company's run", async () => { + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-2", + companyId: "company-2", + }); + + const app = await createApp(); + const res = await request(app).get("/api/heartbeat-runs/run-2/issues"); + + expect(res.status).toBe(403); + expect(mockActivityService.issuesForRun).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 6ef45db1..c5593f03 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -59,6 +59,8 @@ const mockBudgetService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({ listTaskSessions: vi.fn(), resetRuntimeSession: vi.fn(), + getRun: vi.fn(), + cancelRun: vi.fn(), })); const mockIssueApprovalService = vi.hoisted(() => ({ @@ -397,4 +399,26 @@ describe("agent permission routes", () => { }, ]); }); + + it("rejects heartbeat cancellation outside the caller company scope", async () => { + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "33333333-3333-4333-8333-333333333333", + agentId, + status: "running", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app).post("/api/heartbeat-runs/run-1/cancel").send({}); + + expect(res.status).toBe(403); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts index 83d34cf1..cecee0e8 100644 --- a/server/src/__tests__/approval-routes-idempotency.test.ts +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -39,7 +39,7 @@ vi.mock("../services/index.js", () => ({ secretService: () => mockSecretService, })); -function createApp() { +function createApp(actorOverrides: Record = {}) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -49,6 +49,7 @@ function createApp() { companyIds: ["company-1"], source: "session", isInstanceAdmin: false, + ...actorOverrides, }; next(); }); @@ -84,6 +85,14 @@ describe("approval routes idempotent retries", () => { }); it("does not emit duplicate approval side effects when approve is already resolved", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status: "approved", + payload: {}, + requestedByAgentId: "agent-1", + }); mockApprovalService.approve.mockResolvedValue({ approval: { id: "approval-1", @@ -107,6 +116,13 @@ describe("approval routes idempotent retries", () => { }); it("does not emit duplicate rejection logs when reject is already resolved", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status: "rejected", + payload: {}, + }); mockApprovalService.reject.mockResolvedValue({ approval: { id: "approval-1", @@ -126,6 +142,40 @@ describe("approval routes idempotent retries", () => { expect(mockLogActivity).not.toHaveBeenCalled(); }); + it("rejects approval decisions for companies outside the caller scope", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-2", + companyId: "company-2", + type: "hire_agent", + status: "pending", + payload: {}, + }); + + const res = await request(createApp()) + .post("/api/approvals/approval-2/approve") + .send({}); + + expect(res.status).toBe(403); + expect(mockApprovalService.approve).not.toHaveBeenCalled(); + }); + + it("rejects approval revision requests for companies outside the caller scope", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-3", + companyId: "company-2", + type: "hire_agent", + status: "pending", + payload: {}, + }); + + const res = await request(createApp()) + .post("/api/approvals/approval-3/request-revision") + .send({ decisionNote: "Need changes" }); + + expect(res.status).toBe(403); + expect(mockApprovalService.requestRevision).not.toHaveBeenCalled(); + }); + it("lets agents create generic issue-linked board approval requests", async () => { mockApprovalService.create.mockResolvedValue({ id: "approval-1", diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 8649f631..075f2d9b 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -175,4 +175,50 @@ describe("company portability routes", () => { expect(res.status).toBe(403); expect(res.body.error).toContain("Board access required"); }); + + it("requires instance admin for new-company import preview", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["11111111-1111-4111-8111-111111111111"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post("/api/companies/import/preview") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "new_company", newCompanyName: "Imported Test" }, + collisionStrategy: "rename", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Instance admin"); + expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled(); + }); + + it("requires instance admin for new-company import apply", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["11111111-1111-4111-8111-111111111111"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post("/api/companies/import") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "new_company", newCompanyName: "Imported Test" }, + collisionStrategy: "rename", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Instance admin"); + expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 0884b455..20f2ed53 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -4,7 +4,7 @@ import type { Db } from "@paperclipai/db"; import { validate } from "../middleware/validate.js"; import { activityService } from "../services/activity.js"; import { assertBoard, assertCompanyAccess } from "./authz.js"; -import { issueService } from "../services/index.js"; +import { heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; const createActivitySchema = z.object({ @@ -20,6 +20,7 @@ const createActivitySchema = z.object({ export function activityRoutes(db: Db) { const router = Router(); const svc = activityService(db); + const heartbeat = heartbeatService(db); const issueSvc = issueService(db); async function resolveIssueByRef(rawId: string) { @@ -46,6 +47,7 @@ export function activityRoutes(db: Db) { router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); const event = await svc.create({ companyId, ...req.body, @@ -80,6 +82,12 @@ export function activityRoutes(db: Db) { router.get("/heartbeat-runs/:runId/issues", async (req, res) => { const runId = req.params.runId as string; + const run = await heartbeat.getRun(runId); + if (!run) { + res.json([]); + return; + } + assertCompanyAccess(req, run.companyId); const result = await svc.issuesForRun(runId); res.json(result); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 266803ec..3d982cdc 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2296,6 +2296,10 @@ export function agentRoutes(db: Db) { router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { assertBoard(req); const runId = req.params.runId as string; + const existing = await heartbeat.getRun(runId); + if (existing) { + assertCompanyAccess(req, existing.companyId); + } const run = await heartbeat.cancelRun(runId); if (run) { diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts index 99d33abd..3ed20374 100644 --- a/server/src/routes/approvals.ts +++ b/server/src/routes/approvals.ts @@ -1,4 +1,4 @@ -import { Router } from "express"; +import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { addApprovalCommentSchema, @@ -34,6 +34,15 @@ export function approvalRoutes(db: Db) { const secretsSvc = secretService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; + async function requireApprovalAccess(req: Request, id: string) { + const approval = await svc.getById(id); + if (!approval) { + return null; + } + assertCompanyAccess(req, approval.companyId); + return approval; + } + router.get("/companies/:companyId/approvals", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -121,6 +130,10 @@ export function approvalRoutes(db: Db) { router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const { approval, applied } = await svc.approve( id, req.body.decidedByUserId ?? "board", @@ -216,6 +229,10 @@ export function approvalRoutes(db: Db) { router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const { approval, applied } = await svc.reject( id, req.body.decidedByUserId ?? "board", @@ -243,6 +260,10 @@ export function approvalRoutes(db: Db) { async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const approval = await svc.requestRevision( id, req.body.decidedByUserId ?? "board", diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index d1978c9d..22be5f86 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -24,7 +24,7 @@ import { logActivity, } from "../services/index.js"; import type { StorageService } from "../storage/types.js"; -import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; export function companyRoutes(db: Db, storage?: StorageService) { const router = Router(); @@ -48,6 +48,17 @@ export function companyRoutes(db: Db, storage?: StorageService) { return parsed; } + function assertImportTargetAccess( + req: Request, + target: { mode: "new_company" } | { mode: "existing_company"; companyId: string }, + ) { + if (target.mode === "new_company") { + assertInstanceAdmin(req); + return; + } + assertCompanyAccess(req, target.companyId); + } + async function assertCanUpdateBranding(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") return; @@ -160,18 +171,14 @@ export function companyRoutes(db: Db, storage?: StorageService) { router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => { assertBoard(req); - if (req.body.target.mode === "existing_company") { - assertCompanyAccess(req, req.body.target.companyId); - } + assertImportTargetAccess(req, req.body.target); const preview = await portability.previewImport(req.body); res.json(preview); }); router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => { assertBoard(req); - if (req.body.target.mode === "existing_company") { - assertCompanyAccess(req, req.body.target.companyId); - } + assertImportTargetAccess(req, req.body.target); const actor = getActorInfo(req); const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null); await logActivity(db, { -- 2.52.0 From b672ebb5408408ddb616de098b2e7326de8852a6 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 10 Apr 2026 17:31:36 -0400 Subject: [PATCH 29/85] fix(skills): delete secret row when PAT is cleared via updateSkillAuth When updateSkillAuth(null) is called, the underlying secret row was left orphaned. Now deletes the secret via secretsSvc.remove() before clearing sourceAuthSecretId from metadata. Co-Authored-By: Claude Opus 4.6 --- server/src/services/company-skills.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 6549fda8..b6c36db4 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -2445,9 +2445,15 @@ export function companySkillService(db: Db) { } meta.sourceAuthSecretId = secretId; } else { - // Clear the PAT + // Clear the PAT — delete the secret row to avoid orphaned secrets + if (existingSecretId) { + try { + await secretsSvc.remove(existingSecretId); + } catch { + // Best-effort: don't fail the metadata update if secret deletion fails + } + } delete meta.sourceAuthSecretId; - // Note: we don't delete the secret itself — it may be referenced in audit logs } const [updated] = await db -- 2.52.0 From 022a0df61a297b2556feb18f1219d9ccf50ce47b Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 15:35:47 -0400 Subject: [PATCH 30/85] feat(skills): GitHub PAT support for private skill repos + delete by source - Add optional authToken to skill import for GitHub private repos - Store PAT as encrypted company secret (skill-pat:{skillId}) - Thread auth token through ghFetch, fetchText, fetchJson, and all GitHub resolution functions - Add PATCH /companies/:companyId/skills/:skillId/auth for managing PAT per skill - Add DELETE /companies/:companyId/skills/by-source for bulk deleting skills from a repo - Preserve sourceAuthSecretId across skill re-imports/updates - UI: Add PAT input field in import form for GitHub URLs - UI: Add SkillAuthSection with ShieldCheck icon for viewing/updating/removing PAT - UI: Add trash icon next to source label for delete-by-source Co-Authored-By: Claude Opus 4.6 --- packages/shared/src/index.ts | 1 + .../shared/src/validators/company-skill.ts | 5 + packages/shared/src/validators/index.ts | 1 + .../__tests__/company-skills-routes.test.ts | 2 + server/src/routes/company-skills.ts | 67 ++++++- server/src/services/company-skills.ts | 154 +++++++++++++++-- server/src/services/github-fetch.ts | 8 +- ui/src/api/companySkills.ts | 17 +- ui/src/pages/CompanySkills.tsx | 163 ++++++++++++++++-- 9 files changed, 386 insertions(+), 32 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9b125165..83e5ff79 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -557,6 +557,7 @@ export { companySkillDetailSchema, companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillUpdateAuthSchema, companySkillProjectScanRequestSchema, companySkillProjectScanSkippedSchema, companySkillProjectScanConflictSchema, diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index 7f1df34b..26dcea66 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -66,6 +66,11 @@ export const companySkillUpdateStatusSchema = z.object({ export const companySkillImportSchema = z.object({ source: z.string().min(1), + authToken: z.string().min(1).optional(), +}); + +export const companySkillUpdateAuthSchema = z.object({ + authToken: z.string().min(1).nullable(), }); export const companySkillProjectScanRequestSchema = z.object({ diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index aca19625..2019f35c 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -44,6 +44,7 @@ export { companySkillDetailSchema, companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillUpdateAuthSchema, companySkillProjectScanRequestSchema, companySkillProjectScanSkippedSchema, companySkillProjectScanConflictSchema, diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 6dbad659..16b4f692 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -89,6 +89,7 @@ describe("company skill mutation permissions", () => { expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( "company-1", "https://github.com/vercel-labs/agent-browser", + undefined, ); }); @@ -266,6 +267,7 @@ describe("company skill mutation permissions", () => { expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( "company-1", "https://github.com/vercel-labs/agent-browser", + undefined, ); }); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 9e91bf26..b1a5f7cf 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -4,6 +4,7 @@ import { companySkillCreateSchema, companySkillFileUpdateSchema, companySkillImportSchema, + companySkillUpdateAuthSchema, companySkillProjectScanRequestSchema, } from "@paperclipai/shared"; import { trackSkillImported } from "@paperclipai/shared/telemetry"; @@ -194,7 +195,8 @@ export function companySkillRoutes(db: Db) { const companyId = req.params.companyId as string; await assertCanMutateCompanySkills(req, companyId); const source = String(req.body.source ?? ""); - const result = await svc.importFromSource(companyId, source); + const authToken = typeof req.body.authToken === "string" ? req.body.authToken.trim() : undefined; + const result = await svc.importFromSource(companyId, source, authToken || undefined); const actor = getActorInfo(req); await logActivity(db, { @@ -260,6 +262,36 @@ export function companySkillRoutes(db: Db) { }, ); + router.delete("/companies/:companyId/skills/by-source", async (req, res) => { + const companyId = req.params.companyId as string; + const sourceLocator = String(req.query.source ?? "").trim(); + if (!sourceLocator) { + res.status(400).json({ error: "source query parameter is required" }); + return; + } + await assertCanMutateCompanySkills(req, companyId); + const deleted = await svc.deleteBySource(companyId, sourceLocator); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skills_source_deleted", + entityType: "company", + entityId: companyId, + details: { + sourceLocator, + deletedCount: deleted.length, + deletedSlugs: deleted.map((s) => s.slug), + }, + }); + + res.json({ deleted }); + }); + router.delete("/companies/:companyId/skills/:skillId", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; @@ -318,5 +350,38 @@ export function companySkillRoutes(db: Db) { res.json(result); }); + router.patch( + "/companies/:companyId/skills/:skillId/auth", + validate(companySkillUpdateAuthSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + await assertCanMutateCompanySkills(req, companyId); + const authToken = req.body.authToken as string | null; + const result = await svc.updateSkillAuth(companyId, skillId, authToken); + if (!result) { + res.status(404).json({ error: "Skill not found" }); + return; + } + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: authToken ? "company.skill_auth_updated" : "company.skill_auth_removed", + entityType: "company_skill", + entityId: result.id, + details: { + slug: result.slug, + }, + }); + + res.json(result); + }, + ); + return router; } diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 60fc06b4..c94b9ffa 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -471,20 +471,20 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record(url: string): Promise { +async function fetchJson(url: string, authToken?: string): Promise { const response = await ghFetch(url, { headers: { accept: "application/vnd.github+json", }, - }); + }, authToken); if (!response.ok) { throw unprocessable(`Failed to fetch ${url}: ${response.status}`); } @@ -492,16 +492,18 @@ async function fetchJson(url: string): Promise { } -async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string) { +async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string, authToken?: string) { const response = await fetchJson<{ default_branch?: string }>( `${apiBase}/repos/${owner}/${repo}`, + authToken, ); return asString(response.default_branch) ?? "main"; } -async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string) { +async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string, authToken?: string) { const response = await fetchJson<{ sha?: string }>( `${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`, + authToken, ); const sha = asString(response.sha); if (!sha) { @@ -538,7 +540,7 @@ function parseGitHubSourceUrl(rawUrl: string) { return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef }; } -async function resolveGitHubPinnedRef(parsed: ReturnType) { +async function resolveGitHubPinnedRef(parsed: ReturnType, authToken?: string) { const apiBase = gitHubApiBase(parsed.hostname); if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) { return { @@ -549,8 +551,8 @@ async function resolveGitHubPinnedRef(parsed: ReturnType { const url = sourceUrl.trim(); const warnings: string[] = []; @@ -995,10 +998,11 @@ async function readUrlSkillImports( if (looksLikeRepoUrl) { const parsed = parseGitHubSourceUrl(url); const apiBase = gitHubApiBase(parsed.hostname); - const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed); + const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed, authToken); let ref = pinnedRef; const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>( `${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`, + authToken, ).catch(() => { throw unprocessable(`Failed to read GitHub tree for ${url}`); }); @@ -1025,7 +1029,7 @@ async function readUrlSkillImports( const skills: ImportedSkill[] = []; for (const relativeSkillPath of skillPaths) { const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath; - const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath)); + const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath), authToken); const parsedMarkdown = parseFrontmatterMarkdown(markdown); const skillDir = path.posix.dirname(relativeSkillPath); const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir)); @@ -1087,7 +1091,7 @@ async function readUrlSkillImports( } if (url.startsWith("http://") || url.startsWith("https://")) { - const markdown = await fetchText(url); + const markdown = await fetchText(url, authToken); const parsedMarkdown = parseFrontmatterMarkdown(markdown); const urlObj = new URL(url); const fileName = path.posix.basename(urlObj.pathname); @@ -1459,6 +1463,22 @@ export function companySkillService(db: Db) { const projects = projectService(db); const secretsSvc = secretService(db); + /** Resolve the GitHub auth token from a skill's metadata, if stored. */ + async function resolveSkillAuthToken( + companyId: string, + skill: { metadata: Record | null }, + ): Promise { + const meta = skill.metadata; + if (!meta) return undefined; + const secretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId.trim() : ""; + if (!secretId) return undefined; + try { + return await secretsSvc.resolveSecretValue(companyId, secretId, "latest"); + } catch { + return undefined; + } + } + async function ensureBundledSkills(companyId: string) { for (const skillsRoot of resolveBundledSkillsRoot()) { const stats = await fs.stat(skillsRoot).catch(() => null); @@ -1656,7 +1676,8 @@ export function companySkillService(db: Db) { const hostname = asString(metadata.hostname) || "github.com"; const apiBase = gitHubApiBase(hostname); - const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase); + const authToken = await resolveSkillAuthToken(companyId, skill); + const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase, authToken); return { supported: true, reason: null, @@ -1700,8 +1721,9 @@ export function companySkillService(db: Db) { if (!owner || !repo) { throw unprocessable("Skill source metadata is incomplete."); } + const authToken = await resolveSkillAuthToken(companyId, skill); const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath)); - content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath)); + content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath), authToken); } else if (skill.sourceType === "url") { if (normalizedPath !== "SKILL.md") { throw notFound("This skill source only exposes SKILL.md"); @@ -1818,7 +1840,8 @@ export function companySkillService(db: Db) { throw unprocessable("Skill source locator is missing."); } - const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug); + const authToken = await resolveSkillAuthToken(companyId, skill); + const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug, authToken); const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null; if (!matching) { throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`); @@ -2230,6 +2253,10 @@ export function companySkillService(db: Db) { const metadata = { ...(skill.metadata ?? {}), skillKey: skill.key, + // Preserve auth secret reference across re-imports/updates + ...(existing?.metadata && typeof (existing.metadata as Record).sourceAuthSecretId === "string" + ? { sourceAuthSecretId: (existing.metadata as Record).sourceAuthSecretId } + : {}), }; const values = { companyId, @@ -2265,7 +2292,7 @@ export function companySkillService(db: Db) { return out; } - async function importFromSource(companyId: string, source: string): Promise { + async function importFromSource(companyId: string, source: string, authToken?: string): Promise { await ensureSkillInventoryCurrent(companyId); const parsed = parseSkillImportSourceInput(source); const local = !/^https?:\/\//i.test(parsed.resolvedSource); @@ -2275,7 +2302,7 @@ export function companySkillService(db: Db) { .filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug), warnings: parsed.warnings, } - : await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug) + : await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug, authToken) .then((result) => ({ skills: result.skills, warnings: [...parsed.warnings, ...result.warnings], @@ -2302,6 +2329,35 @@ export function companySkillService(db: Db) { } } const imported = await upsertImportedSkills(companyId, filteredSkills); + + // Store the auth token as an encrypted company secret and link to imported skills + if (authToken && imported.length > 0) { + for (const skill of imported) { + const secretName = `skill-pat:${skill.id}`; + let secretId: string; + const existing = await secretsSvc.getByName(companyId, secretName); + if (existing) { + await secretsSvc.rotate(existing.id, { value: authToken }); + secretId = existing.id; + } else { + const created = await secretsSvc.create(companyId, { + name: secretName, + provider: "local_encrypted", + value: authToken, + description: `GitHub PAT for skill ${skill.slug}`, + }); + secretId = created.id; + } + // Store the secret ID in skill metadata + const meta = (skill.metadata ?? {}) as Record; + meta.sourceAuthSecretId = secretId; + await db + .update(companySkills) + .set({ metadata: meta, updatedAt: new Date() }) + .where(eq(companySkills.id, skill.id)); + } + } + return { imported, warnings }; } @@ -2344,6 +2400,68 @@ export function companySkillService(db: Db) { return skill; } + async function updateSkillAuth( + companyId: string, + skillId: string, + authToken: string | null, + ): Promise { + const skill = await getById(skillId); + if (!skill || skill.companyId !== companyId) return null; + + const meta = (skill.metadata ?? {}) as Record; + const existingSecretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null; + + if (authToken) { + // Set or update the PAT + const secretName = `skill-pat:${skill.id}`; + let secretId: string; + // Check metadata reference first, then fall back to name lookup + // (metadata ref may have been lost during a skill update/re-import) + const existingSecret = existingSecretId + ? await secretsSvc.getById(existingSecretId) + : await secretsSvc.getByName(companyId, secretName); + if (existingSecret) { + await secretsSvc.rotate(existingSecret.id, { value: authToken }); + secretId = existingSecret.id; + } else { + const created = await secretsSvc.create(companyId, { + name: secretName, + provider: "local_encrypted", + value: authToken, + description: `GitHub PAT for skill ${skill.slug}`, + }); + secretId = created.id; + } + meta.sourceAuthSecretId = secretId; + } else { + // Clear the PAT + delete meta.sourceAuthSecretId; + // Note: we don't delete the secret itself — it may be referenced in audit logs + } + + const [updated] = await db + .update(companySkills) + .set({ metadata: meta, updatedAt: new Date() }) + .where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId))) + .returning(); + return updated ? toCompanySkill(updated) : null; + } + + async function deleteBySource(companyId: string, sourceLocator: string): Promise { + const rows = await db + .select() + .from(companySkills) + .where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceLocator, sourceLocator))); + if (rows.length === 0) return []; + + const deleted: CompanySkill[] = []; + for (const row of rows) { + const result = await deleteSkill(companyId, row.id); + if (result) deleted.push(result); + } + return deleted; + } + return { list, listFull, @@ -2359,7 +2477,9 @@ export function companySkillService(db: Db) { updateFile, createLocalSkill, deleteSkill, + deleteBySource, importFromSource, + updateSkillAuth, scanProjectWorkspaces, importPackageFiles, installUpdate, diff --git a/server/src/services/github-fetch.ts b/server/src/services/github-fetch.ts index 787ae0ef..e8f8aee5 100644 --- a/server/src/services/github-fetch.ts +++ b/server/src/services/github-fetch.ts @@ -16,9 +16,13 @@ export function resolveRawGitHubUrl(hostname: string, owner: string, repo: strin : `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`; } -export async function ghFetch(url: string, init?: RequestInit): Promise { +export async function ghFetch(url: string, init?: RequestInit, authToken?: string): Promise { + const headers = new Headers(init?.headers); + if (authToken) { + headers.set("Authorization", `Bearer ${authToken}`); + } try { - return await fetch(url, init); + return await fetch(url, { ...init, headers }); } catch { throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`); } diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index 7377b2fa..bbdb2e60 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -36,10 +36,23 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills`, payload, ), - importFromSource: (companyId: string, source: string) => + importFromSource: (companyId: string, source: string, authToken?: string) => api.post( `/companies/${encodeURIComponent(companyId)}/skills/import`, - { source }, + { source, ...(authToken ? { authToken } : {}) }, + ), + updateAuth: (companyId: string, skillId: string, authToken: string | null) => + api.patch( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/auth`, + { authToken }, + ), + remove: (companyId: string, skillId: string) => + api.delete( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, + ), + removeBySource: (companyId: string, sourceLocator: string) => + api.delete<{ deleted: CompanySkill[] }>( + `/companies/${encodeURIComponent(companyId)}/skills/by-source?source=${encodeURIComponent(sourceLocator)}`, ), scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) => api.post( diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index cc2d5605..5b1530ec 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type SVGProps } from "react"; import { Link, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { + CompanySkill, CompanySkillCreateRequest, CompanySkillDetail, CompanySkillFileDetail, @@ -52,6 +53,7 @@ import { RefreshCw, Save, Search, + ShieldCheck, Trash2, } from "lucide-react"; @@ -487,6 +489,103 @@ function SkillList({ ); } +function SkillAuthSection({ + companyId, + skillId, + hasAuth, +}: { + companyId: string; + skillId: string; + hasAuth: boolean; +}) { + const queryClient = useQueryClient(); + const { pushToast } = useToast(); + const [editing, setEditing] = useState(false); + const [token, setToken] = useState(""); + + const updateAuth = useMutation({ + mutationFn: (authToken: string | null) => + companySkillsApi.updateAuth(companyId, skillId, authToken), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(companyId, skillId) }); + setEditing(false); + setToken(""); + pushToast({ tone: "success", title: "Auth updated" }); + }, + onError: (error) => { + pushToast({ + tone: "error", + title: "Failed to update auth", + body: error instanceof Error ? error.message : "Unknown error", + }); + }, + }); + + return ( +
+ Auth + {!editing ? ( + <> + {hasAuth ? ( + <> + + + + ) : ( + + )} + + ) : ( + <> + setToken(e.target.value)} + placeholder="GitHub Personal Access Token" + className="flex-1 min-w-[200px] rounded-md border border-border px-2 py-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50" + autoComplete="off" + autoFocus + /> + + + + )} +
+ ); +} + function SkillPane({ loading, detail, @@ -525,7 +624,7 @@ function SkillPane({ checkUpdatesPending: boolean; onInstallUpdate: () => void; installUpdatePending: boolean; - onDelete: () => void; + onDelete: (sourceLocator?: string | null) => void; deletePending: boolean; onSave: () => void; savePending: boolean; @@ -572,7 +671,7 @@ function SkillPane({
+ {(detail.sourceType === "github" || detail.sourceType === "skills_sh") && ( + | null)?.sourceAuthSecretId)} + /> + )} {detail.sourceType === "github" && (
Pin @@ -762,6 +876,7 @@ export function CompanySkills() { const { pushToast } = useToast(); const [skillFilter, setSkillFilter] = useState(""); const [source, setSource] = useState(""); + const [importAuthToken, setImportAuthToken] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false); const [expandedSkillId, setExpandedSkillId] = useState(null); @@ -886,8 +1001,17 @@ export function CompanySkills() { } } + function handleDeleteSkill(sourceLocator?: string | null) { + if (sourceLocator) { + deleteSkill.mutate(sourceLocator); + } else { + openDeleteDialog(); + } + } + const importSkill = useMutation({ - mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource), + mutationFn: ({ importSource, authToken }: { importSource: string; authToken?: string }) => + companySkillsApi.importFromSource(selectedCompanyId!, importSource, authToken), onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }); if (result.imported[0]) navigate(skillRoute(result.imported[0].id)); @@ -900,6 +1024,7 @@ export function CompanySkills() { pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] }); } setSource(""); + setImportAuthToken(""); }, onError: (error) => { pushToast({ @@ -1026,8 +1151,13 @@ export function CompanySkills() { }); const deleteSkill = useMutation({ - mutationFn: () => companySkillsApi.delete(selectedCompanyId!, deleteTargetSkillId!), - onSuccess: async (skill) => { + mutationFn: (sourceLocator?: string | null): Promise<{ deleted: CompanySkill[] }> => { + if (sourceLocator) { + return companySkillsApi.removeBySource(selectedCompanyId!, sourceLocator); + } + return companySkillsApi.remove(selectedCompanyId!, deleteTargetSkillId!).then((skill) => ({ deleted: [skill] })); + }, + onSuccess: async (result) => { closeDeleteDialog(false); setDisplayedDetail(null); setDisplayedFile(null); @@ -1048,10 +1178,10 @@ export function CompanySkills() { type: "active", }); navigate("/skills", { replace: true }); + const count = result.deleted.length; pushToast({ tone: "success", - title: "Skill removed", - body: `${skill.name} was removed from the company skill library.`, + title: `${count} skill${count === 1 ? "" : "s"} removed`, }); }, onError: (error) => { @@ -1073,7 +1203,8 @@ export function CompanySkills() { setEmptySourceHelpOpen(true); return; } - importSkill.mutate(trimmedSource); + const token = importAuthToken.trim() || undefined; + importSkill.mutate({ importSource: trimmedSource, authToken: token }); } return ( @@ -1115,7 +1246,7 @@ export function CompanySkills() {
+ {source.trim().length > 0 && /github\.com|^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+/.test(source.trim()) && ( +
+ setImportAuthToken(event.target.value)} + placeholder="GitHub PAT (optional, for private repos)" + className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" + autoComplete="off" + /> +
+ )} {scanStatusMessage && (

{scanStatusMessage} @@ -1284,7 +1427,7 @@ export function CompanySkills() { checkUpdatesPending={updateStatusQuery.isFetching} onInstallUpdate={() => installUpdate.mutate()} installUpdatePending={installUpdate.isPending} - onDelete={openDeleteDialog} + onDelete={handleDeleteSkill} deletePending={deleteSkill.isPending} onSave={() => saveFile.mutate()} savePending={saveFile.isPending} -- 2.52.0 From 54c5e7cb41bb7357e856087650c0c7fe64f4589d Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 15:57:36 -0400 Subject: [PATCH 31/85] fix(skills): atomic deleteBySource + PAT secret cleanup on skill deletion - Pre-check all skills for agent usage before deleting any in deleteBySource to prevent partial/failed deletions - Delete (rotate to empty) the skill-pat: secret when a skill is deleted to prevent orphaned PAT secrets Co-Authored-By: Claude Opus 4.6 --- server/src/services/company-skills.ts | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index c94b9ffa..587adc68 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -2397,6 +2397,17 @@ export function companySkillService(db: Db) { // Clean up materialized runtime files await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true }); + // Delete associated PAT secret if present + const meta = skill.metadata as Record | null; + const secretId = typeof meta?.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null; + if (secretId) { + try { + await secretsSvc.remove(secretId); + } catch { + // Best-effort: don't fail the skill deletion if secret cleanup fails + } + } + return skill; } @@ -2454,6 +2465,28 @@ export function companySkillService(db: Db) { .where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceLocator, sourceLocator))); if (rows.length === 0) return []; + // Pre-check all skills for agent usage before deleting any (atomicity) + const skills = rows.map(toCompanySkill); + for (const skill of skills) { + const usedByAgents = await usage(companyId, skill.key); + if (usedByAgents.length > 0) { + const agentNames = usedByAgents.map((agent) => agent.name).sort((left, right) => left.localeCompare(right)); + throw unprocessable( + `Cannot delete skills from "${sourceLocator}" because skill "${skill.name}" is still used by ${agentNames.join(", ")}. Detach it from those agents first.`, + { + skillId: skill.id, + skillKey: skill.key, + usedByAgents: usedByAgents.map((agent) => ({ + id: agent.id, + name: agent.name, + urlKey: agent.urlKey, + adapterType: agent.adapterType, + })), + }, + ); + } + } + const deleted: CompanySkill[] = []; for (const row of rows) { const result = await deleteSkill(companyId, row.id); -- 2.52.0 From 0b224f0864a4aa037856eee3b8900fa61951d06f Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 16:02:05 -0400 Subject: [PATCH 32/85] fix(ui): remove dead delete API method and add confirmation for delete-by-source - Remove duplicate `delete` method (identical to `remove`) - Route delete-by-source through confirmation dialog with source locator displayed and "Remove all from source" button Co-Authored-By: Claude Opus 4.6 --- ui/src/api/companySkills.ts | 4 --- ui/src/pages/CompanySkills.tsx | 57 +++++++++++++++++++++------------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index bbdb2e60..f72c64c3 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -64,8 +64,4 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`, {}, ), - delete: (companyId: string, skillId: string) => - api.delete( - `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, - ), }; diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 5b1530ec..d2124b03 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -890,6 +890,7 @@ export function CompanySkills() { const [deleteOpen, setDeleteOpen] = useState(false); const [deleteTargetSkillId, setDeleteTargetSkillId] = useState(null); const [deleteTargetDetail, setDeleteTargetDetail] = useState(null); + const [deleteTargetSourceLocator, setDeleteTargetSourceLocator] = useState(null); const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]); const routeSkillId = parsedRoute.skillId; const selectedPath = parsedRoute.filePath; @@ -998,12 +999,16 @@ export function CompanySkills() { if (!open) { setDeleteTargetSkillId(null); setDeleteTargetDetail(null); + setDeleteTargetSourceLocator(null); } } function handleDeleteSkill(sourceLocator?: string | null) { if (sourceLocator) { - deleteSkill.mutate(sourceLocator); + setDeleteTargetSourceLocator(sourceLocator); + setDeleteTargetSkillId(null); + setDeleteTargetDetail(null); + setDeleteOpen(true); } else { openDeleteDialog(); } @@ -1212,30 +1217,40 @@ export function CompanySkills() {

- Remove skill + {deleteTargetSourceLocator ? "Remove skills from source" : "Remove skill"} - Remove this skill from the company library. If any agents still use it, removal will be blocked until it is detached. + {deleteTargetSourceLocator + ? `All skills imported from this source will be permanently removed from the company library.` + : "Remove this skill from the company library. If any agents still use it, removal will be blocked until it is detached."}
-

- {deleteTargetDetail - ? `You are about to remove ${deleteTargetDetail.name}.` - : "You are about to remove this skill."} -

- {deleteTargetDetail?.usedByAgents?.length ? ( -
- Currently used by {deleteTargetDetail.usedByAgents.map((agent) => agent.name).join(", ")}. -
- ) : null} - {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? ( -

- Detach this skill from all agents to enable removal. + {deleteTargetSourceLocator ? ( +

+ {deleteTargetSourceLocator}

- ) : null} + ) : ( + <> +

+ {deleteTargetDetail + ? `You are about to remove ${deleteTargetDetail.name}.` + : "You are about to remove this skill."} +

+ {deleteTargetDetail?.usedByAgents?.length ? ( +
+ Currently used by {deleteTargetDetail.usedByAgents.map((agent) => agent.name).join(", ")}. +
+ ) : null} + {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? ( +

+ Detach this skill from all agents to enable removal. +

+ ) : null} + + )}
- {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? ( + {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 && !deleteTargetSourceLocator ? ( @@ -1246,10 +1261,10 @@ export function CompanySkills() { )} -- 2.52.0 From ec4e94a6e79a6973c65e097171546f532c1e6edc Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 16:03:33 -0400 Subject: [PATCH 33/85] fix: add companyId filter to metadata update + export CompanySkillUpdateAuth type - Scope metadata update WHERE clause to companyId for defence-in-depth - Add CompanySkillUpdateAuth inferred type export to match other schemas Co-Authored-By: Claude Opus 4.6 --- packages/shared/src/validators/company-skill.ts | 1 + packages/shared/src/validators/index.ts | 1 + server/src/services/company-skills.ts | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index 26dcea66..58b9165d 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -138,3 +138,4 @@ export type CompanySkillImport = z.infer; export type CompanySkillProjectScan = z.infer; export type CompanySkillCreate = z.infer; export type CompanySkillFileUpdate = z.infer; +export type CompanySkillUpdateAuth = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 2019f35c..d1bc3929 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -56,6 +56,7 @@ export { type CompanySkillProjectScan, type CompanySkillCreate, type CompanySkillFileUpdate, + type CompanySkillUpdateAuth, } from "./company-skill.js"; export { agentSkillStateSchema, diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 587adc68..6549fda8 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -2354,7 +2354,7 @@ export function companySkillService(db: Db) { await db .update(companySkills) .set({ metadata: meta, updatedAt: new Date() }) - .where(eq(companySkills.id, skill.id)); + .where(and(eq(companySkills.id, skill.id), eq(companySkills.companyId, companyId))); } } -- 2.52.0 From edc77da0822e6ce19e634f31d0b00be146502f94 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 10 Apr 2026 17:31:36 -0400 Subject: [PATCH 34/85] fix(skills): delete secret row when PAT is cleared via updateSkillAuth When updateSkillAuth(null) is called, the underlying secret row was left orphaned. Now deletes the secret via secretsSvc.remove() before clearing sourceAuthSecretId from metadata. Co-Authored-By: Claude Opus 4.6 --- server/src/services/company-skills.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 6549fda8..b6c36db4 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -2445,9 +2445,15 @@ export function companySkillService(db: Db) { } meta.sourceAuthSecretId = secretId; } else { - // Clear the PAT + // Clear the PAT — delete the secret row to avoid orphaned secrets + if (existingSecretId) { + try { + await secretsSvc.remove(existingSecretId); + } catch { + // Best-effort: don't fail the metadata update if secret deletion fails + } + } delete meta.sourceAuthSecretId; - // Note: we don't delete the secret itself — it may be referenced in audit logs } const [updated] = await db -- 2.52.0 From f4a05dc35c2eb80511d56c68c0dd270b16799040 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 10 Apr 2026 17:01:06 -0700 Subject: [PATCH 35/85] fix(cli): prepare plugin sdk before cli dev boot (#3343) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The company import/export e2e exercises the local CLI startup path that boots the dev server inside a workspace > - That startup path loads server and plugin code which depends on built workspace package artifacts such as `@paperclipai/shared` and `@paperclipai/plugin-sdk` > - In a clean worktree those `dist/*` artifacts may not exist yet even though `paperclipai run` can still attempt to import the local server entry > - That mismatch caused the import/export e2e to fail before the actual company package flow ran > - This pull request adds a CLI preflight step that prepares the needed workspace build dependencies before the local server import and fails closed if that preflight is interrupted or stalls > - The benefit is that clean worktrees can boot `paperclipai run` reliably without silently continuing after incomplete dependency preparation ## What Changed - Updated `cli/src/commands/run.ts` to execute `scripts/ensure-plugin-build-deps.mjs` before importing `server/src/index.ts` for local dev startup. - Ensured `paperclipai run` can materialize missing workspace artifacts such as `packages/shared/dist` and `packages/plugins/sdk/dist` automatically in clean worktrees. - Made the preflight fail closed when the child process exits via signal and bounded it with a 120-second timeout so the CLI does not hang indefinitely. - Kept the fix isolated to the CLI startup path; no API contract, schema, or UI behavior changed. - Reused the existing `cli/src/__tests__/company-import-export-e2e.test.ts` coverage that already exercises the failing boot path, so no additional test file was needed. ## Verification - `pnpm test:run cli/src/__tests__/company-import-export-e2e.test.ts` - `pnpm --filter paperclipai typecheck` - On the isolated branch, confirmed `packages/shared/dist/index.js` and `packages/plugins/sdk/dist/index.js` were absent before the run, then reran the targeted e2e and observed a passing result. ## Risks - Low risk: the change only affects the local CLI dev startup path before the server import. - Residual risk: other entrypoints still rely on their own preflight/build behavior, so this does not normalize every workspace startup path. - The 120-second timeout is intentionally generous, but unusually slow machines could still hit it and surface a startup error instead of waiting forever. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment, with shell/tool execution enabled. The exact runtime revision and context window are not exposed by this environment. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- cli/src/commands/run.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 04743b48..b39ab06c 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { spawnSync } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; import * as p from "@clack/prompts"; import pc from "picocolors"; @@ -146,11 +147,35 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void { } } +function ensureDevWorkspaceBuildDeps(projectRoot: string): void { + const buildScript = path.resolve(projectRoot, "scripts/ensure-plugin-build-deps.mjs"); + if (!fs.existsSync(buildScript)) return; + + const result = spawnSync(process.execPath, [buildScript], { + cwd: projectRoot, + stdio: "inherit", + timeout: 120_000, + }); + + if (result.error) { + throw new Error( + `Failed to prepare workspace build artifacts before starting the Paperclip dev server.\n${formatError(result.error)}`, + ); + } + + if ((result.status ?? 1) !== 0) { + throw new Error( + "Failed to prepare workspace build artifacts before starting the Paperclip dev server.", + ); + } +} + async function importServerEntry(): Promise { // Dev mode: try local workspace path (monorepo with tsx) const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); const devEntry = path.resolve(projectRoot, "server/src/index.ts"); if (fs.existsSync(devEntry)) { + ensureDevWorkspaceBuildDeps(projectRoot); maybeEnableUiDevMiddleware(devEntry); const mod = await import(pathToFileURL(devEntry).href); return await startServerFromModule(mod, devEntry); -- 2.52.0 From 548721248e4a0740e33033e223b0ca82db6a7142 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 10 Apr 2026 17:14:06 -0700 Subject: [PATCH 36/85] fix(ui): keep latest issue document revision current (#3342) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Board users and agents collaborate on issue-scoped documents such as plans and revisions need to be trustworthy because they are the audit trail for those artifacts. > - The issue document UI now supports revision history and restore, so the UI has to distinguish the current revision from historical revisions correctly even while multiple queries are refreshing. > - In `PAPA-72`, the newest content could appear under an older revision label because the current document snapshot and the revision-history query could temporarily disagree after an edit. > - That made the UI treat the newest revision like a historical restore target, which is the opposite of the intended behavior. > - This pull request derives one authoritative revision view from both sources, sorts revisions newest-first, and keeps the freshest revision marked current. > - The benefit is that revision history stays stable and trustworthy immediately after edits instead of briefly presenting the newest content as an older revision. ## What Changed - Added a `document-revisions` helper that merges the current document snapshot with fetched revision history into one normalized revision state. - Updated `IssueDocumentsSection` to render from that normalized state instead of trusting either query in isolation. - Added focused tests covering the current-revision selection and ordering behavior. ## Verification - `pnpm -r typecheck` - `pnpm build` - Targeted revision tests passed locally. - Manual reviewer check: - Open an issue document with revision history. - Edit and save the document. - Immediately open the revision selector. - Confirm the newest revision remains marked current and older revisions remain the restore targets. ## Risks - Low risk. The change is isolated to issue document revision presentation in the UI. - Main risk is merging the current snapshot with fetched history incorrectly for edge cases, which is why the helper has focused unit coverage. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../components/IssueDocumentsSection.test.tsx | 182 ++++++++++++++++++ ui/src/components/IssueDocumentsSection.tsx | 26 +-- ui/src/lib/document-revisions.test.ts | 95 +++++++++ ui/src/lib/document-revisions.ts | 72 +++++++ 4 files changed, 364 insertions(+), 11 deletions(-) create mode 100644 ui/src/lib/document-revisions.test.ts create mode 100644 ui/src/lib/document-revisions.ts diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index 99f0cbd1..9f04df9f 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -119,6 +119,35 @@ vi.mock("@/components/ui/dropdown-menu", async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +const localStorageEntries = new Map(); + +function ensureLocalStorageMock() { + if ( + typeof window.localStorage?.getItem === "function" + && typeof window.localStorage?.setItem === "function" + && typeof window.localStorage?.removeItem === "function" + && typeof window.localStorage?.clear === "function" + ) { + return; + } + + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => localStorageEntries.get(key) ?? null, + setItem: (key: string, value: string) => { + localStorageEntries.set(key, value); + }, + removeItem: (key: string) => { + localStorageEntries.delete(key); + }, + clear: () => { + localStorageEntries.clear(); + }, + }, + }); +} + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((res) => { @@ -221,6 +250,7 @@ describe("IssueDocumentsSection", () => { beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); + ensureLocalStorageMock(); window.localStorage.clear(); vi.clearAllMocks(); markdownEditorMockState.emitMountEmptyChange = false; @@ -311,6 +341,158 @@ describe("IssueDocumentsSection", () => { queryClient.clear(); }); + it("returns from a historical preview when the current revision only exists in derived state", async () => { + const currentDocument = createIssueDocument({ + body: "Current plan body", + latestRevisionId: "revision-4", + latestRevisionNumber: 4, + updatedAt: new Date("2026-03-31T12:05:00.000Z"), + }); + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + mockIssuesApi.listDocuments.mockResolvedValue([currentDocument]); + queryClient.setQueryData( + queryKeys.issues.documentRevisions(issue.id, "plan"), + [ + createRevision({ + id: "revision-3", + revisionNumber: 3, + body: "Historical plan body", + createdAt: new Date("2026-03-31T11:00:00.000Z"), + }), + ], + ); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + expect(container.textContent).toContain("Current plan body"); + + const revisionButtons = Array.from(container.querySelectorAll("button")); + const historicalRevisionButton = revisionButtons.find((button) => button.textContent?.includes("rev 3")); + expect(historicalRevisionButton).toBeTruthy(); + + await act(async () => { + historicalRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("Viewing revision 3"); + expect(container.textContent).toContain("Historical plan body"); + + const currentRevisionButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("rev 4")); + expect(currentRevisionButton).toBeTruthy(); + + await act(async () => { + currentRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).not.toContain("Viewing revision 3"); + expect(container.textContent).toContain("Current plan body"); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); + + it("returns from a historical preview when fetched history is newer than the document summary", async () => { + const staleDocument = createIssueDocument({ + body: "Original plan body", + latestRevisionId: "revision-2", + latestRevisionNumber: 2, + updatedAt: new Date("2026-03-31T12:00:00.000Z"), + }); + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + mockIssuesApi.listDocuments.mockResolvedValue([staleDocument]); + queryClient.setQueryData( + queryKeys.issues.documentRevisions(issue.id, "plan"), + [ + createRevision({ + id: "revision-3", + revisionNumber: 3, + body: "Current plan body", + createdAt: new Date("2026-03-31T12:05:00.000Z"), + }), + createRevision({ + id: "revision-2", + revisionNumber: 2, + body: "Original plan body", + createdAt: new Date("2026-03-31T12:00:00.000Z"), + }), + ], + ); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + expect(container.textContent).toContain("Current plan body"); + + const revisionButtons = Array.from(container.querySelectorAll("button")); + const historicalRevisionButton = revisionButtons.find((button) => button.textContent?.includes("rev 2")); + expect(historicalRevisionButton).toBeTruthy(); + + await act(async () => { + historicalRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("Viewing revision 2"); + expect(container.textContent).toContain("Original plan body"); + + const currentRevisionButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("rev 3")); + expect(currentRevisionButton).toBeTruthy(); + + await act(async () => { + currentRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).not.toContain("Viewing revision 2"); + expect(container.textContent).toContain("Current plan body"); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); + it("ignores mount-time editor change noise before a document is actively being edited", async () => { markdownEditorMockState.emitMountEmptyChange = true; diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index c3f0a9d7..6923befa 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -12,6 +12,7 @@ import { useLocation } from "@/lib/router"; import { ApiError } from "../api/client"; import { issuesApi } from "../api/issues"; import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator"; +import { deriveDocumentRevisionState } from "../lib/document-revisions"; import { queryKeys } from "../lib/queryKeys"; import { cn, relativeTime } from "../lib/utils"; import { MarkdownBody } from "./MarkdownBody"; @@ -536,10 +537,10 @@ export function IssueDocumentsSection({ }, []); const previewRevision = useCallback((doc: IssueDocument, revisionId: string) => { - const revisions = getDocumentRevisions(doc.key); - const selectedRevision = revisions.find((revision) => revision.id === revisionId); + const revisionState = deriveDocumentRevisionState(doc, getDocumentRevisions(doc.key)); + const selectedRevision = revisionState.revisions.find((revision) => revision.id === revisionId); if (!selectedRevision) return; - if (selectedRevision.id === doc.latestRevisionId) { + if (selectedRevision.id === revisionState.currentRevision.id) { returnToLatestRevision(doc.key); return; } @@ -787,7 +788,10 @@ export function IssueDocumentsSection({ const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null; const activeConflict = documentConflict?.key === doc.key ? documentConflict : null; const isFolded = foldedDocumentKeys.includes(doc.key); - const revisionHistory = getDocumentRevisions(doc.key); + const rawRevisionHistory = getDocumentRevisions(doc.key); + const revisionState = deriveDocumentRevisionState(doc, rawRevisionHistory); + const revisionHistory = revisionState.revisions; + const currentRevision = revisionState.currentRevision; const selectedRevisionId = selectedRevisionIds[doc.key] ?? null; const selectedHistoricalRevision = selectedRevisionId ? revisionHistory.find((revision) => revision.id === selectedRevisionId) ?? null @@ -795,10 +799,10 @@ export function IssueDocumentsSection({ const isHistoricalPreview = Boolean(selectedHistoricalRevision); const displayedTitle = selectedHistoricalRevision ? selectedHistoricalRevision.title ?? "" - : activeDraft?.title ?? doc.title ?? ""; - const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? doc.body; - const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? doc.latestRevisionNumber; - const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? doc.updatedAt; + : activeDraft?.title ?? currentRevision.title ?? ""; + const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? currentRevision.body; + const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? currentRevision.revisionNumber; + const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? currentRevision.createdAt; const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key); const canVoteOnDocument = Boolean(doc.latestRevisionId && doc.updatedByAgentId && !doc.updatedByUserId && onVote); @@ -845,12 +849,12 @@ export function IssueDocumentsSection({ Revision history - {revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions && revisionHistory.length === 0 ? ( + {revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions && rawRevisionHistory.length === 0 ? ( Loading revisions... ) : revisionHistory.length > 0 ? ( - + {revisionHistory.map((revision) => { - const isCurrentRevision = revision.id === doc.latestRevisionId; + const isCurrentRevision = revision.id === currentRevision.id; return ( = {}): IssueDocument { + return { + id: "document-1", + companyId: "company-1", + issueId: "issue-1", + key: "plan", + title: "Plan", + format: "markdown", + body: "# Current plan", + latestRevisionId: "revision-2", + latestRevisionNumber: 2, + createdByAgentId: "agent-1", + createdByUserId: null, + updatedByAgentId: "agent-1", + updatedByUserId: null, + createdAt: new Date("2026-04-10T15:00:00.000Z"), + updatedAt: new Date("2026-04-10T16:00:00.000Z"), + ...overrides, + }; +} + +function createRevision(overrides: Partial = {}): DocumentRevision { + return { + id: "revision-1", + companyId: "company-1", + documentId: "document-1", + issueId: "issue-1", + key: "plan", + revisionNumber: 1, + title: "Plan", + format: "markdown", + body: "# Revision body", + changeSummary: null, + createdByAgentId: "agent-1", + createdByUserId: null, + createdAt: new Date("2026-04-10T15:00:00.000Z"), + ...overrides, + }; +} + +describe("deriveDocumentRevisionState", () => { + it("falls back to a synthetic current revision when no revision history has been fetched yet", () => { + const state = deriveDocumentRevisionState(createDocument({ + latestRevisionId: null, + latestRevisionNumber: 0, + body: "# Draft plan", + }), []); + + expect(state.currentRevision.id).toBe("document-1-latest"); + expect(state.currentRevision.body).toBe("# Draft plan"); + expect(state.revisions.map((revision) => revision.id)).toEqual(["document-1-latest"]); + }); + + it("sorts fetched revisions newest-first even when the API payload is out of order", () => { + const state = deriveDocumentRevisionState(createDocument(), [ + createRevision({ id: "revision-1", revisionNumber: 1, createdAt: new Date("2026-04-10T15:00:00.000Z") }), + createRevision({ id: "revision-2", revisionNumber: 2, body: "# Current plan", createdAt: new Date("2026-04-10T16:00:00.000Z") }), + ]); + + expect(state.currentRevision.id).toBe("revision-2"); + expect(state.revisions.map((revision) => revision.id)).toEqual(["revision-2", "revision-1"]); + }); + + it("keeps the latest document revision current when the revision history cache is stale", () => { + const state = deriveDocumentRevisionState(createDocument(), [ + createRevision({ id: "revision-1", revisionNumber: 1, body: "# Original plan" }), + ]); + + expect(state.currentRevision.id).toBe("revision-2"); + expect(state.currentRevision.body).toBe("# Current plan"); + expect(state.revisions.map((revision) => revision.id)).toEqual(["revision-2", "revision-1"]); + }); + + it("trusts the fetched revision history when it is newer than the document summary cache", () => { + const staleDocument = createDocument({ + body: "# Original plan", + latestRevisionId: "revision-1", + latestRevisionNumber: 1, + updatedAt: new Date("2026-04-10T15:00:00.000Z"), + }); + + const state = deriveDocumentRevisionState(staleDocument, [ + createRevision({ id: "revision-2", revisionNumber: 2, body: "# Current plan", createdAt: new Date("2026-04-10T16:00:00.000Z") }), + createRevision({ id: "revision-1", revisionNumber: 1, body: "# Original plan", createdAt: new Date("2026-04-10T15:00:00.000Z") }), + ]); + + expect(state.currentRevision.id).toBe("revision-2"); + expect(state.currentRevision.body).toBe("# Current plan"); + expect(state.revisions.map((revision) => revision.id)).toEqual(["revision-2", "revision-1"]); + }); +}); diff --git a/ui/src/lib/document-revisions.ts b/ui/src/lib/document-revisions.ts new file mode 100644 index 00000000..5759d4af --- /dev/null +++ b/ui/src/lib/document-revisions.ts @@ -0,0 +1,72 @@ +import type { DocumentRevision, IssueDocument } from "@paperclipai/shared"; + +type DocumentRevisionState = { + currentRevision: DocumentRevision; + revisions: DocumentRevision[]; +}; + +function toTimestamp(value: Date | string | null | undefined) { + if (!value) return 0; + const timestamp = new Date(value).getTime(); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +function sortRevisionsDescending(revisions: DocumentRevision[]) { + return [...revisions].sort((a, b) => { + if (a.revisionNumber !== b.revisionNumber) { + return b.revisionNumber - a.revisionNumber; + } + const createdAtDelta = toTimestamp(b.createdAt) - toTimestamp(a.createdAt); + if (createdAtDelta !== 0) return createdAtDelta; + return b.id.localeCompare(a.id); + }); +} + +function createCurrentRevisionSnapshot(document: IssueDocument): DocumentRevision { + return { + id: document.latestRevisionId ?? `${document.id}-latest`, + companyId: document.companyId, + documentId: document.id, + issueId: document.issueId, + key: document.key, + revisionNumber: document.latestRevisionNumber, + title: document.title, + format: document.format, + body: document.body, + changeSummary: null, + createdByAgentId: document.updatedByAgentId ?? document.createdByAgentId, + createdByUserId: document.updatedByUserId ?? document.createdByUserId, + createdAt: document.updatedAt, + }; +} + +export function deriveDocumentRevisionState( + document: IssueDocument, + revisions: DocumentRevision[], +): DocumentRevisionState { + const sortedRevisions = sortRevisionsDescending(revisions); + const currentSnapshot = createCurrentRevisionSnapshot(document); + const highestFetchedRevision = sortedRevisions[0] ?? null; + const documentAppearsStale = Boolean( + highestFetchedRevision && highestFetchedRevision.revisionNumber > document.latestRevisionNumber, + ); + + const currentRevision = documentAppearsStale + ? highestFetchedRevision! + : sortedRevisions.find((revision) => revision.id === document.latestRevisionId) ?? currentSnapshot; + + const revisionsWithCurrent = sortRevisionsDescending([currentRevision, ...sortedRevisions]); + const dedupedRevisions: DocumentRevision[] = []; + const seenRevisionIds = new Set(); + + for (const revision of revisionsWithCurrent) { + if (seenRevisionIds.has(revision.id)) continue; + seenRevisionIds.add(revision.id); + dedupedRevisions.push(revision); + } + + return { + currentRevision, + revisions: dedupedRevisions, + }; +} -- 2.52.0 From 772130a5737881788c6610412f3a8f48cc10654d Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 10 Apr 2026 21:22:38 -0400 Subject: [PATCH 37/85] feat(docker): add jq to production stage alongside other tooling --- Dockerfile | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 36d5acab..2713e785 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,7 +55,18 @@ ARG USER_UID=1000 ARG USER_GID=1000 WORKDIR /app COPY --chown=node:node --from=build /app /app -RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \ +RUN apt-get update \ + && apt-get install -y --no-install-recommends jq nano vim \ + && rm -rf /var/lib/apt/lists/* \ + && curl -fsSL https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \ + && chmod +x /usr/local/bin/kubectl \ + && curl -fsSL https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.36.6/kubeseal-0.36.6-linux-amd64.tar.gz | tar -xzf - -C /tmp \ + && mv /tmp/kubeseal /usr/local/bin/kubeseal \ + && rm -rf /tmp/kubeseal /tmp/LICENSE /tmp/README.md \ + && curl -LsSf https://astral.sh/uv/install.sh | sh \ + && mv /root/.local/bin/uv /usr/local/bin/uv \ + && mv /root/.local/bin/uvx /usr/local/bin/uvx \ + && npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \ && mkdir -p /paperclip \ && chown node:node /paperclip -- 2.52.0 From 4c413429bec8ed873967fdb7425156b7b10bb33d Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 10 Apr 2026 21:47:22 -0400 Subject: [PATCH 38/85] fix(docker): add passwd package for usermod/groupmod --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index be8b01a3..92d48661 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:lts-trixie-slim AS base ARG USER_UID=1000 ARG USER_GID=1000 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates gosu curl git wget ripgrep python3 \ + && apt-get install -y --no-install-recommends ca-certificates gosu curl git wget ripgrep python3 passwd \ && rm -rf /var/lib/apt/lists/* \ && curl -fsSL https://github.com/cli/cli/releases/download/v2.89.0/gh_2.89.0_linux_amd64.tar.gz | tar -xzf - -C /tmp \ && mv /tmp/gh_2.89.0_linux_amd64/bin/gh /usr/local/bin/ \ -- 2.52.0 From dab95740be90dc133d345dc7e7ed6626bddccc7a Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 10 Apr 2026 22:26:21 -0500 Subject: [PATCH 39/85] feat: polish inbox and issue list workflows --- .../issue-document-restore-routes.test.ts | 16 +- server/src/__tests__/routines-e2e.test.ts | 99 ++++--- server/src/__tests__/routines-routes.test.ts | 52 ++-- ui/src/components/AgentProperties.tsx | 8 +- ui/src/components/GoalProperties.tsx | 6 +- ui/src/components/IssueColumns.tsx | 61 +++- ui/src/components/IssueFiltersPopover.tsx | 40 ++- ui/src/components/IssueLinkQuicklook.tsx | 135 +++++++++ ui/src/components/IssueProperties.test.tsx | 115 ++++++++ ui/src/components/IssueProperties.tsx | 200 +++++++++---- ui/src/components/IssueRow.test.tsx | 26 +- ui/src/components/IssueRow.tsx | 1 + ui/src/components/IssuesList.test.tsx | 154 +++++++++- ui/src/components/IssuesList.tsx | 54 +++- ui/src/components/IssuesQuicklook.tsx | 30 +- ui/src/components/KanbanBoard.tsx | 1 + .../KeyboardShortcutsCheatsheet.tsx | 1 + ui/src/components/Layout.tsx | 9 + ui/src/components/NewIssueDialog.test.tsx | 33 +-- ui/src/components/NewIssueDialog.tsx | 138 ++++----- ui/src/components/ProjectProperties.tsx | 14 +- ui/src/components/PropertiesPanel.tsx | 4 +- ui/src/components/Sidebar.tsx | 2 +- ui/src/components/SidebarAgents.tsx | 2 + ui/src/components/SidebarNavItem.tsx | 2 + ui/src/components/SidebarProjects.tsx | 2 + ui/src/hooks/useKeyboardShortcuts.test.tsx | 51 ++++ ui/src/hooks/useKeyboardShortcuts.ts | 23 +- ui/src/lib/inbox.test.ts | 150 ++++++++++ ui/src/lib/inbox.ts | 198 ++++++++++++- ui/src/lib/issue-filters.ts | 15 + ui/src/lib/keyboardShortcuts.test.ts | 72 ++++- ui/src/lib/keyboardShortcuts.ts | 51 ++++ ui/src/lib/optimistic-issue-runs.test.ts | 24 -- ui/src/pages/Inbox.test.tsx | 1 + ui/src/pages/Inbox.tsx | 270 +++++++++++++----- ui/src/pages/Routines.tsx | 25 +- 37 files changed, 1674 insertions(+), 411 deletions(-) create mode 100644 ui/src/components/IssueLinkQuicklook.tsx diff --git a/server/src/__tests__/issue-document-restore-routes.test.ts b/server/src/__tests__/issue-document-restore-routes.test.ts index d576d3ac..386da1e2 100644 --- a/server/src/__tests__/issue-document-restore-routes.test.ts +++ b/server/src/__tests__/issue-document-restore-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { issueRoutes } from "../routes/issues.js"; +import { errorHandler } from "../middleware/index.js"; const issueId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; @@ -50,11 +52,7 @@ vi.mock("../services/index.js", () => ({ workProductService: () => ({}), })); -async function createApp() { - const [{ issueRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/issues.js"), - import("../middleware/index.js"), - ]); +function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -74,7 +72,6 @@ async function createApp() { describe("issue document revision routes", () => { beforeEach(() => { - vi.resetModules(); vi.resetAllMocks(); mockIssueService.getById.mockResolvedValue({ id: issueId, @@ -125,10 +122,9 @@ describe("issue document revision routes", () => { }); it("returns revision snapshots including title and format", async () => { - const res = await request(await createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`); + const res = await request(createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`); expect(res.status).toBe(200); - expect(mockDocumentsService.listIssueDocumentRevisions).toHaveBeenCalledWith(issueId, "plan"); expect(res.body).toEqual([ expect.objectContaining({ revisionNumber: 2, @@ -140,7 +136,7 @@ describe("issue document revision routes", () => { }); it("restores a revision through the append-only route and logs the action", async () => { - const res = await request(await createApp()) + const res = await request(createApp()) .post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`) .send({}); @@ -172,7 +168,7 @@ describe("issue document revision routes", () => { }); it("rejects invalid document keys before attempting restore", async () => { - const res = await request(await createApp()) + const res = await request(createApp()) .post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`) .send({}); diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index 12a3d837..deb3e110 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -26,56 +26,53 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { errorHandler } from "../middleware/index.js"; import { accessService } from "../services/access.js"; -function registerServiceMocks() { - vi.doMock("../services/index.js", async () => { - const actual = await vi.importActual("../services/index.js"); +vi.mock("../services/index.js", async () => { + const actual = await vi.importActual("../services/index.js"); - return { - ...actual, - routineService: (db: any) => - actual.routineService(db, { - heartbeat: { - wakeup: async (agentId: string, wakeupOpts: any) => { - const issueId = - (typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) || - (typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) || - null; - if (!issueId) return null; + return { + ...actual, + routineService: (db: any) => + actual.routineService(db, { + heartbeat: { + wakeup: async (agentId: string, wakeupOpts: any) => { + const issueId = + (typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) || + (typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) || + null; + if (!issueId) return null; - const issue = await db - .select({ companyId: issues.companyId }) - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows: Array<{ companyId: string }>) => rows[0] ?? null); - if (!issue) return null; + const issue = await db + .select({ companyId: issues.companyId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows: Array<{ companyId: string }>) => rows[0] ?? null); + if (!issue) return null; - const queuedRunId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: queuedRunId, - companyId: issue.companyId, - agentId, - 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 queuedRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: queuedRunId, + companyId: issue.companyId, + agentId, + 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 embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -95,11 +92,6 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { db = createDb(tempDb.connectionString); }, 20_000); - beforeEach(() => { - vi.resetModules(); - registerServiceMocks(); - }); - afterEach(async () => { await db.delete(activityLog); await db.delete(routineRuns); @@ -123,8 +115,15 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { await tempDb?.cleanup(); }); + beforeEach(() => { + vi.resetModules(); + }); + async function createApp(actor: Record) { - const { routineRoutes } = await import("../routes/routines.js"); + const [{ routineRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/routines.js"), + import("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index ce1db549..d3a9edea 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { routineRoutes } from "../routes/routines.js"; const companyId = "22222222-2222-4222-8222-222222222222"; const agentId = "11111111-1111-4111-8111-111111111111"; @@ -83,28 +85,22 @@ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackRoutineCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -function registerRouteMocks() { - vi.doMock("@paperclipai/shared/telemetry", () => ({ - trackRoutineCreated: mockTrackRoutineCreated, - trackErrorHandlerCrash: vi.fn(), - })); +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackRoutineCreated: mockTrackRoutineCreated, + trackErrorHandlerCrash: vi.fn(), +})); - vi.doMock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, - })); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); - vi.doMock("../services/index.js", () => ({ - accessService: () => mockAccessService, - logActivity: mockLogActivity, - routineService: () => mockRoutineService, - })); -} +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + logActivity: mockLogActivity, + routineService: () => mockRoutineService, +})); -async function createApp(actor: Record) { - const [{ routineRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/routines.js"), - import("../middleware/index.js"), - ]); +function createApp(actor: Record) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -118,9 +114,7 @@ async function createApp(actor: Record) { describe("routine routes", () => { beforeEach(() => { - vi.resetModules(); - registerRouteMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockRoutineService.create.mockResolvedValue(routine); mockRoutineService.get.mockResolvedValue(routine); @@ -136,7 +130,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission for non-admin board routine creation", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -158,7 +152,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to retarget a routine assignee", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -179,7 +173,7 @@ describe("routine routes", () => { it("requires tasks:assign permission to reactivate a routine", async () => { mockRoutineService.get.mockResolvedValue(pausedRoutine); - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -199,7 +193,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to create a trigger", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -221,7 +215,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to update a trigger", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -241,7 +235,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to manually run a routine", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -260,7 +254,7 @@ describe("routine routes", () => { it("allows routine creation when the board user has tasks:assign", async () => { mockAccessService.canUser.mockResolvedValue(true); - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index 911a109a..b10f69e6 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -19,9 +19,9 @@ const roleLabels = AGENT_ROLE_LABELS as Record; function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
- {label} -
{children}
+
+ {label} +
{children}
); } @@ -68,7 +68,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) { )} {runtimeState?.lastError && ( - {runtimeState.lastError} + {runtimeState.lastError} )} {agent.lastHeartbeatAt && ( diff --git a/ui/src/components/GoalProperties.tsx b/ui/src/components/GoalProperties.tsx index fdc4da2a..27700a0e 100644 --- a/ui/src/components/GoalProperties.tsx +++ b/ui/src/components/GoalProperties.tsx @@ -20,9 +20,9 @@ interface GoalPropertiesProps { function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
- {label} -
{children}
+
+ {label} +
{children}
); } diff --git a/ui/src/components/IssueColumns.tsx b/ui/src/components/IssueColumns.tsx index a1489a7e..516a8c45 100644 --- a/ui/src/components/IssueColumns.tsx +++ b/ui/src/components/IssueColumns.tsx @@ -12,6 +12,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { formatAssigneeUserLabel } from "../lib/assignees"; import type { InboxIssueColumn } from "../lib/inbox"; import { cn } from "../lib/utils"; @@ -50,12 +51,12 @@ export function issueActivityText(issue: Issue): string { function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string { return columns .map((column) => { - if (column === "assignee") return "minmax(7.5rem, 9.5rem)"; - if (column === "project") return "minmax(6.5rem, 8.5rem)"; - if (column === "workspace") return "minmax(9rem, 12rem)"; - if (column === "parent") return "minmax(5rem, 7rem)"; - if (column === "labels") return "minmax(8rem, 10rem)"; - return "minmax(4rem, 5.5rem)"; + if (column === "assignee") return "minmax(6rem, 8rem)"; + if (column === "project") return "minmax(4.5rem, 7rem)"; + if (column === "workspace") return "minmax(6rem, 9rem)"; + if (column === "parent") return "minmax(3.5rem, 5.5rem)"; + if (column === "labels") return "minmax(3rem, 6rem)"; + return "minmax(3.5rem, 4.5rem)"; }) .join(" "); } @@ -66,24 +67,27 @@ export function IssueColumnPicker({ onToggleColumn, onResetColumns, title, + iconOnly = false, }: { availableColumns: InboxIssueColumn[]; visibleColumnSet: ReadonlySet; onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void; onResetColumns: () => void; title: string; + iconOnly?: boolean; }) { return ( @@ -189,23 +193,27 @@ export function InboxIssueTrailingColumns({ columns, projectName, projectColor, + workspaceId, workspaceName, assigneeName, currentUserId, parentIdentifier, parentTitle, assigneeContent, + onFilterWorkspace, }: { issue: Issue; columns: InboxIssueColumn[]; projectName: string | null; projectColor: string | null; + workspaceId?: string | null; workspaceName: string | null; assigneeName: string | null; currentUserId: string | null; parentIdentifier: string | null; parentTitle: string | null; assigneeContent?: ReactNode; + onFilterWorkspace?: (workspaceId: string) => void; }) { const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt); const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"; @@ -276,20 +284,22 @@ export function InboxIssueTrailingColumns({ if (column === "labels") { if ((issue.labels ?? []).length > 0) { return ( - + {(issue.labels ?? []).slice(0, 2).map((label) => ( {label.name} ))} {(issue.labels ?? []).length > 2 ? ( - + +{(issue.labels ?? []).length - 2} ) : null} @@ -307,7 +317,28 @@ export function InboxIssueTrailingColumns({ return ( - {workspaceName} + {workspaceId && onFilterWorkspace ? ( + + + + + + Filter by workspace + + + ) : ( + workspaceName + )} ); } diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index ac35a308..02f63acf 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -1,7 +1,7 @@ import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Filter, X, User } from "lucide-react"; +import { Filter, X, User, HardDrive } from "lucide-react"; import { PriorityIcon } from "./PriorityIcon"; import { StatusIcon } from "./StatusIcon"; import { @@ -31,6 +31,11 @@ type LabelOption = { color: string; }; +type WorkspaceOption = { + id: string; + name: string; +}; + export function IssueFiltersPopover({ state, onChange, @@ -41,6 +46,8 @@ export function IssueFiltersPopover({ currentUserId, enableRoutineVisibilityFilter = false, buttonVariant = "ghost", + iconOnly = false, + workspaces, }: { state: IssueFilterState; onChange: (patch: Partial) => void; @@ -51,15 +58,18 @@ export function IssueFiltersPopover({ currentUserId?: string | null; enableRoutineVisibilityFilter?: boolean; buttonVariant?: "ghost" | "outline"; + iconOnly?: boolean; + workspaces?: WorkspaceOption[]; }) { return ( -
) : null} + {workspaces && workspaces.length > 0 ? ( +
+ Workspace +
+ {workspaces.map((workspace) => ( + + ))} +
+
+ ) : null} + {enableRoutineVisibilityFilter ? (
Visibility diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx new file mode 100644 index 00000000..4bb0048f --- /dev/null +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -0,0 +1,135 @@ +import * as React from "react"; +import { useMemo, useState } from "react"; +import * as RouterDom from "react-router-dom"; +import type { Issue } from "@paperclipai/shared"; +import { useQuery } from "@tanstack/react-query"; +import { issuesApi } from "@/api/issues"; +import { queryKeys } from "@/lib/queryKeys"; +import { timeAgo } from "@/lib/timeAgo"; +import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb"; +import { cn } from "@/lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { StatusIcon } from "@/components/StatusIcon"; + +function summarizeIssueDescription(description: string | null | undefined) { + if (!description) return null; + const summary = description + .replace(/!\[[^\]]*]\([^)]+\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[#>*_`~-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + + if (!summary) return null; + return summary.length > 180 ? `${summary.slice(0, 177).trimEnd()}...` : summary; +} + +export function IssueQuicklookCard({ + issue, + linkTo, + linkState, + compact = false, +}: { + issue: Issue; + linkTo: RouterDom.To; + linkState?: unknown; + compact?: boolean; +}) { + const description = useMemo(() => summarizeIssueDescription(issue.description), [issue.description]); + + return ( +
+
+ + + {issue.title} + +
+
+ {issue.identifier ?? issue.id.slice(0, 8)} + · + {issue.status.replace(/_/g, " ")} + · + {timeAgo(new Date(issue.updatedAt))} +
+ {description ? ( +

+ {description} +

+ ) : null} +
+ ); +} + +export const IssueLinkQuicklook = React.forwardRef< + HTMLAnchorElement, + React.ComponentProps & { issuePathId: string } +>(function IssueLinkQuicklookImpl( + { + issuePathId, + to, + children, + className, + onClick, + ...props + }, + ref, +) { + const [open, setOpen] = useState(false); + const { data, isLoading } = useQuery({ + queryKey: queryKeys.issues.detail(issuePathId), + queryFn: () => issuesApi.get(issuePathId), + enabled: open, + staleTime: 60_000, + }); + + const detailPath = createIssueDetailPath(issuePathId); + + return ( + + setOpen(true)} + onMouseLeave={() => setOpen(false)} + > + { + setOpen(false); + onClick?.(event); + }} + {...props} + > + {children} + + + setOpen(true)} + onMouseLeave={() => setOpen(false)} + onOpenAutoFocus={(event) => event.preventDefault()} + > + {data ? ( + + ) : ( +
+
+
+
+ {!isLoading ? ( +

Unable to load issue preview.

+ ) : null} +
+ )} + + + ); +}); diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index a1fe7e1c..3c92683a 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -18,6 +18,7 @@ const mockProjectsApi = vi.hoisted(() => ({ })); const mockIssuesApi = vi.hoisted(() => ({ + list: vi.fn(), listLabels: vi.fn(), })); @@ -193,6 +194,7 @@ describe("IssueProperties", () => { document.body.appendChild(container); mockAgentsApi.list.mockResolvedValue([]); mockProjectsApi.list.mockResolvedValue([]); + mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]); mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } }); }); @@ -227,6 +229,119 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("shows an add-label button when labels already exist and opens the picker", async () => { + const root = renderProperties(container, { + issue: createIssue({ + labels: [{ id: "label-1", companyId: "company-1", name: "Bug", color: "#ef4444", createdAt: new Date("2026-04-06T12:00:00.000Z"), updatedAt: new Date("2026-04-06T12:00:00.000Z") }], + labelIds: ["label-1"], + }), + childIssues: [], + onUpdate: vi.fn(), + inline: true, + }); + await flush(); + + const addLabelButton = container.querySelector('button[aria-label="Add label"]'); + expect(addLabelButton).not.toBeNull(); + expect(container.querySelector('input[placeholder="Search labels..."]')).toBeNull(); + + await act(async () => { + addLabelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(container.querySelector('input[placeholder="Search labels..."]')).not.toBeNull(); + expect(container.querySelector('button[title="Delete Bug"]')).toBeNull(); + + act(() => root.unmount()); + }); + + it("allows setting and clearing a parent issue from the properties pane", async () => { + const onUpdate = vi.fn(); + mockIssuesApi.list.mockResolvedValue([ + createIssue({ id: "issue-2", identifier: "PAP-2", title: "Candidate parent", status: "in_progress" }), + ]); + + const root = renderProperties(container, { + issue: createIssue(), + childIssues: [], + onUpdate, + inline: true, + }); + await flush(); + + const parentTrigger = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("No parent")); + expect(parentTrigger).not.toBeUndefined(); + + await act(async () => { + parentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const candidateButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("PAP-2 Candidate parent")); + expect(candidateButton).not.toBeUndefined(); + + await act(async () => { + candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ parentId: "issue-2" }); + + onUpdate.mockClear(); + const rerenderedIssue = createIssue({ + parentId: "issue-2", + ancestors: [ + { + id: "issue-2", + identifier: "PAP-2", + title: "Candidate parent", + description: null, + status: "in_progress", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + projectId: null, + goalId: null, + project: null, + goal: null, + }, + ], + }); + + act(() => root.unmount()); + + const rerenderedRoot = renderProperties(container, { + issue: rerenderedIssue, + childIssues: [], + onUpdate, + inline: true, + }); + await flush(); + + const selectedParentTrigger = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("PAP-2 Candidate parent")); + expect(selectedParentTrigger).not.toBeUndefined(); + + await act(async () => { + selectedParentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const clearParentButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("No parent")); + expect(clearParentButton).not.toBeUndefined(); + + await act(async () => { + clearParentButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ parentId: null }); + + act(() => rerenderedRoot.unmount()); + }); + it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => { const onUpdate = vi.fn(); const root = renderProperties(container, { diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index c6d33ff2..ed7a5c31 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -20,7 +20,7 @@ import { formatDate, cn, projectUrl } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2, GitBranch, FolderOpen, Copy, Check } from "lucide-react"; +import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Copy, Check } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) { @@ -82,9 +82,9 @@ interface IssuePropertiesProps { function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
- {label} -
{children}
+
+ {label} +
{children}
); } @@ -114,7 +114,7 @@ function PropertyPicker({ children: React.ReactNode; }) { const btnCn = cn( - "inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors", + "inline-flex items-start gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors min-w-0 max-w-full text-left", triggerClassName, ); @@ -167,6 +167,8 @@ export function IssueProperties({ const [projectSearch, setProjectSearch] = useState(""); const [blockedByOpen, setBlockedByOpen] = useState(false); const [blockedBySearch, setBlockedBySearch] = useState(""); + const [parentOpen, setParentOpen] = useState(false); + const [parentSearch, setParentSearch] = useState(""); const [reviewersOpen, setReviewersOpen] = useState(false); const [reviewerSearch, setReviewerSearch] = useState(""); const [approversOpen, setApproversOpen] = useState(false); @@ -212,7 +214,7 @@ export function IssueProperties({ const { data: allIssues } = useQuery({ queryKey: queryKeys.issues.list(companyId!), queryFn: () => issuesApi.list(companyId!), - enabled: !!companyId && blockedByOpen, + enabled: !!companyId && (blockedByOpen || parentOpen), }); const createLabel = useMutation({ @@ -224,15 +226,6 @@ export function IssueProperties({ }, }); - const deleteLabel = useMutation({ - mutationFn: (labelId: string) => issuesApi.deleteLabel(labelId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); - }, - }); - const toggleLabel = (labelId: string) => { const ids = issue.labelIds ?? []; const next = ids.includes(labelId) @@ -304,10 +297,10 @@ export function IssueProperties({ return value; }; const reviewerTrigger = reviewerValues.length > 0 - ? {reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")} + ? {reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")} : None; const approverTrigger = approverValues.length > 0 - ? {approverValues.map((value) => executionParticipantLabel(value)).join(", ")} + ? {approverValues.map((value) => executionParticipantLabel(value)).join(", ")} : None; const nextRunnableExecutionStage = (() => { if (issue.executionState?.status === "changes_requested" && issue.executionState.currentStageType) { @@ -369,6 +362,17 @@ export function IssueProperties({ No labels ); + const labelsExtra = (issue.labelIds ?? []).length > 0 ? ( + + ) : undefined; const labelsContent = ( <> @@ -388,26 +392,17 @@ export function IssueProperties({ .map((label) => { const selected = (issue.labelIds ?? []).includes(label.id); return ( -
- - -
+ ); })}
@@ -609,7 +604,7 @@ export function IssueProperties({ className="shrink-0 h-3 w-3 rounded-sm" style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }} /> - {projectName(issue.projectId)} + {projectName(issue.projectId)} ) : ( <> @@ -685,6 +680,100 @@ export function IssueProperties({ ); const blockedByIds = issue.blockedBy?.map((relation) => relation.id) ?? []; + const descendantIssueIds = useMemo(() => { + if (!allIssues?.length) return new Set(); + const childrenByParentId = new Map(); + for (const candidate of allIssues) { + if (!candidate.parentId) continue; + const children = childrenByParentId.get(candidate.parentId) ?? []; + children.push(candidate.id); + childrenByParentId.set(candidate.parentId, children); + } + + const descendants = new Set(); + const stack = [...(childrenByParentId.get(issue.id) ?? [])]; + while (stack.length > 0) { + const candidateId = stack.pop(); + if (!candidateId || descendants.has(candidateId)) continue; + descendants.add(candidateId); + stack.push(...(childrenByParentId.get(candidateId) ?? [])); + } + return descendants; + }, [allIssues, issue.id]); + const currentParentIssue = useMemo(() => { + if (!issue.parentId) return null; + return allIssues?.find((candidate) => candidate.id === issue.parentId) ?? null; + }, [allIssues, issue.parentId]); + const parentTrigger = issue.parentId ? ( + + {issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier + ? `${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier} ` + : ""} + {issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId.slice(0, 8)} + + ) : ( + No parent + ); + const parentOptions = (allIssues ?? []) + .filter((candidate) => candidate.id !== issue.id) + .filter((candidate) => !descendantIssueIds.has(candidate.id)) + .filter((candidate) => { + if (!parentSearch.trim()) return true; + const query = parentSearch.toLowerCase(); + return ( + (candidate.identifier ?? "").toLowerCase().includes(query) || + candidate.title.toLowerCase().includes(query) + ); + }) + .sort((a, b) => { + const aLabel = `${a.identifier ?? ""} ${a.title}`.trim(); + const bLabel = `${b.identifier ?? ""} ${b.title}`.trim(); + return aLabel.localeCompare(bLabel); + }); + const parentContent = ( + <> + setParentSearch(e.target.value)} + autoFocus={!inline} + /> +
+ + {parentOptions.map((candidate) => ( + + ))} +
+ + ); const blockedByTrigger = blockedByIds.length > 0 ? (
{(issue.blockedBy ?? []).slice(0, 2).map((relation) => ( @@ -793,6 +882,7 @@ export function IssueProperties({ triggerContent={labelsTrigger} triggerClassName="min-w-0 max-w-full" popoverClassName="w-64" + extra={labelsExtra} > {labelsContent} @@ -838,6 +928,30 @@ export function IssueProperties({ {projectContent} + { + setParentOpen(open); + if (!open) setParentSearch(""); + }} + triggerContent={parentTrigger} + triggerClassName="min-w-0 max-w-full" + popoverClassName="w-72" + extra={issue.parentId ? ( + e.stopPropagation()} + > + + + ) : undefined} + > + {parentContent} + + )} - {issue.parentId && ( - - - {issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)} - - - )} {issue.requestDepth > 0 && ( {issue.requestDepth} diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 4ba0cee2..2b52a042 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -7,8 +7,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssueRow } from "./IssueRow"; vi.mock("@/lib/router", () => ({ - Link: ({ children, className, ...props }: React.ComponentProps<"a">) => ( - {children} + Link: ({ children, className, disableIssueQuicklook: _disableIssueQuicklook, ...props }: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean }) => ( + + {children} + ), })); @@ -135,6 +141,22 @@ describe("IssueRow", () => { }); }); + it("opts issue quicklook out for dense inbox rows", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null; + expect(link).not.toBeNull(); + expect(link?.getAttribute("data-disable-issue-quicklook")).toBe("true"); + + act(() => { + root.unmount(); + }); + }); + it("renders titleSuffix inline after the issue title", () => { const root = createRoot(container); const issue = createIssue({ title: "Parent task" }); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 13488489..2ba4e92e 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -58,6 +58,7 @@ export function IssueRow({ rememberIssueDetailLocationState(issuePathId, detailState)} className={cn( diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 96ea92e8..e87fa41c 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssuesList } from "./IssuesList"; +import { TooltipProvider } from "@/components/ui/tooltip"; const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1", @@ -161,7 +162,9 @@ function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) { act(() => { root.render( - {node} + + {node} + , ); }); @@ -297,7 +300,10 @@ describe("IssuesList", () => { ); await waitForAssertion(() => { - expect(container.textContent).toContain("Columns"); + const columnsButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.getAttribute("title") === "Columns", + ); + expect(columnsButton).not.toBeUndefined(); expect(container.textContent).toContain("PAP-9"); expect(container.textContent).toContain("Agent One"); expect(container.textContent).not.toContain("Updated"); @@ -308,6 +314,77 @@ describe("IssuesList", () => { }); }); + it("filters the list to a single workspace when a workspace name is clicked", async () => { + localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["id", "workspace"])); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + mockExecutionWorkspacesApi.list.mockResolvedValue([ + { + id: "workspace-alpha", + name: "Alpha", + mode: "isolated_workspace", + status: "active", + projectWorkspaceId: null, + }, + { + id: "workspace-beta", + name: "Beta", + mode: "isolated_workspace", + status: "active", + projectWorkspaceId: null, + }, + ]); + + const alphaIssue = createIssue({ + id: "issue-alpha", + identifier: "PAP-20", + title: "Alpha issue", + executionWorkspaceId: "workspace-alpha", + }); + const betaIssue = createIssue({ + id: "issue-beta", + identifier: "PAP-21", + title: "Beta issue", + executionWorkspaceId: "workspace-beta", + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Alpha issue"); + expect(container.textContent).toContain("Beta issue"); + const workspaceButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Alpha", + ); + expect(workspaceButton).not.toBeUndefined(); + }); + + await act(async () => { + const workspaceButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Alpha", + ); + workspaceButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Alpha issue"); + expect(container.textContent).not.toContain("Beta issue"); + }); + + act(() => { + root.unmount(); + }); + }); + it("hides routine-backed issues by default and reveals them when the routine filter is enabled", async () => { const manualIssue = createIssue({ id: "issue-manual", @@ -341,7 +418,7 @@ describe("IssuesList", () => { await act(async () => { const filterButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent?.includes("Filter"), + (button) => button.getAttribute("title") === "Filter", ); filterButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); @@ -370,4 +447,75 @@ describe("IssuesList", () => { root.unmount(); }); }); + + it("blurs the search input on Enter without clearing the query", async () => { + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null; + expect(input).not.toBeNull(); + input?.focus(); + expect(document.activeElement).toBe(input); + }); + + const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement; + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + })); + }); + + expect(document.activeElement).not.toBe(input); + expect(input.value).toBe("bug"); + + act(() => { + root.unmount(); + }); + }); + + it("blurs the search input on Escape once the field is empty", async () => { + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null; + expect(input).not.toBeNull(); + input?.focus(); + expect(document.activeElement).toBe(input); + }); + + const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement; + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + })); + }); + + expect(document.activeElement).not.toBe(input); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 576a9ede..9dde9e08 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -7,6 +7,10 @@ import { issuesApi } from "../api/issues"; import { authApi } from "../api/auth"; import { instanceSettingsApi } from "../api/instanceSettings"; import { queryKeys } from "../lib/queryKeys"; +import { + shouldBlurPageSearchOnEnter, + shouldBlurPageSearchOnEscape, +} from "../lib/keyboardShortcuts"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { groupBy } from "../lib/groupBy"; import { @@ -15,6 +19,7 @@ import { defaultIssueFilterState, issueFilterLabel, issuePriorityOrder, + resolveIssueFilterWorkspaceId, issueStatusOrder, type IssueFilterState, } from "../lib/issue-filters"; @@ -170,9 +175,27 @@ function IssueSearchInput({ onChange={(e) => { setDraftValue(e.target.value); }} + onKeyDown={(e) => { + if (shouldBlurPageSearchOnEnter({ + key: e.key, + isComposing: e.nativeEvent.isComposing, + })) { + e.currentTarget.blur(); + return; + } + + if (shouldBlurPageSearchOnEscape({ + key: e.key, + isComposing: e.nativeEvent.isComposing, + currentValue: e.currentTarget.value, + })) { + e.currentTarget.blur(); + } + }} placeholder="Search issues..." className="pl-7 text-xs sm:text-sm" aria-label="Search issues" + data-page-search-target="true" />
); @@ -346,6 +369,16 @@ export function IssuesList({ return map; }, [executionWorkspaceById, projectWorkspaceById]); + const workspaceOptions = useMemo(() => { + const options = new Map(); + for (const [workspaceId, workspaceName] of workspaceNameMap) { + options.set(workspaceId, workspaceName); + } + return [...options.entries()] + .sort((a, b) => a[1].localeCompare(b[1])) + .map(([id, name]) => ({ id, name })); + }, [workspaceNameMap]); + const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]); const availableIssueColumns = useMemo( () => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled), @@ -404,7 +437,7 @@ export function IssuesList({ .map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! })); } if (viewState.groupBy === "workspace") { - const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace"); + const groups = groupBy(filtered, (issue) => resolveIssueFilterWorkspaceId(issue) ?? "__no_workspace"); return Object.keys(groups) .sort((a, b) => { // Groups with items first, "no workspace" last @@ -467,6 +500,10 @@ export function IssuesList({ return defaults; }, [projectId, viewState.groupBy]); + const filterToWorkspace = useCallback((workspaceId: string) => { + updateView({ workspaces: [workspaceId] }); + }, [updateView]); + const setIssueColumns = useCallback((next: InboxIssueColumn[]) => { const normalized = normalizeInboxIssueColumns(next); setVisibleIssueColumns(normalized); @@ -531,6 +568,7 @@ export function IssuesList({ onToggleColumn={toggleIssueColumn} onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} title="Choose which issue columns stay visible" + iconOnly /> ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} + iconOnly + workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined} /> {/* Sort (list view only) */} {viewState.viewMode === "list" && ( - @@ -592,9 +631,8 @@ export function IssuesList({ {viewState.viewMode === "list" && ( - @@ -751,11 +789,13 @@ export function IssuesList({ columns={visibleTrailingIssueColumns} projectName={issueProject?.name ?? null} projectColor={issueProject?.color ?? null} + workspaceId={resolveIssueFilterWorkspaceId(issue)} workspaceName={resolveIssueWorkspaceName(issue, { executionWorkspaceById, projectWorkspaceById, defaultProjectWorkspaceIdByProjectId, })} + onFilterWorkspace={filterToWorkspace} assigneeName={agentName(issue.assigneeAgentId)} currentUserId={currentUserId} parentIdentifier={parentIssue?.identifier ?? null} diff --git a/ui/src/components/IssuesQuicklook.tsx b/ui/src/components/IssuesQuicklook.tsx index ba89d1cd..f8a12ce6 100644 --- a/ui/src/components/IssuesQuicklook.tsx +++ b/ui/src/components/IssuesQuicklook.tsx @@ -1,10 +1,8 @@ import { useState } from "react"; import type { Issue } from "@paperclipai/shared"; -import { Link } from "@/lib/router"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { StatusIcon } from "./StatusIcon"; import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb"; -import { timeAgo } from "../lib/timeAgo"; +import { IssueQuicklookCard } from "./IssueLinkQuicklook"; interface IssuesQuicklookProps { issue: Issue; @@ -24,32 +22,18 @@ export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) { {children} setOpen(true)} onMouseLeave={() => setOpen(false)} onOpenAutoFocus={(e) => e.preventDefault()} > -
-
- - - {issue.title} - -
-
- {issue.identifier ?? issue.id.slice(0, 8)} - · - {issue.status.replace(/_/g, " ")} - · - {timeAgo(new Date(issue.updatedAt))} -
-
+
); diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx index 8b2bad2c..3e1ef1fb 100644 --- a/ui/src/components/KanbanBoard.tsx +++ b/ui/src/components/KanbanBoard.tsx @@ -148,6 +148,7 @@ function KanbanCard({ > { // Prevent navigation during drag diff --git a/ui/src/components/KeyboardShortcutsCheatsheet.tsx b/ui/src/components/KeyboardShortcutsCheatsheet.tsx index 937292ad..45d6858d 100644 --- a/ui/src/components/KeyboardShortcutsCheatsheet.tsx +++ b/ui/src/components/KeyboardShortcutsCheatsheet.tsx @@ -34,6 +34,7 @@ const sections: ShortcutSection[] = [ { title: "Global", shortcuts: [ + { keys: ["/"], label: "Search current page or quick search" }, { keys: ["c"], label: "New issue" }, { keys: ["["], label: "Toggle sidebar" }, { keys: ["]"], label: "Toggle panel" }, diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 461d429c..cd697218 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -154,12 +154,21 @@ export function Layout() { ]); const togglePanel = togglePanelVisible; + const openSearch = useCallback(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { + key: "k", + metaKey: true, + bubbles: true, + cancelable: true, + })); + }, []); useCompanyPageMemory(); useKeyboardShortcuts({ enabled: keyboardShortcutsEnabled, onNewIssue: () => openNewIssue(), + onSearch: openSearch, onToggleSidebar: toggleSidebar, onTogglePanel: togglePanel, onShowShortcuts: () => setShortcutsOpen(true), diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index b1c29f70..b81b30e4 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -222,18 +222,6 @@ async function flush() { }); } -async function waitForValue(getValue: () => T | null | undefined, attempts = 10): Promise { - for (let attempt = 0; attempt < attempts; attempt += 1) { - const value = getValue(); - if (value != null) { - return value; - } - await flush(); - } - - throw new Error("Timed out waiting for value"); -} - function renderDialog(container: HTMLDivElement) { const queryClient = new QueryClient({ defaultOptions: { @@ -394,10 +382,15 @@ describe("NewIssueDialog", () => { expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]"); expect(dialogContent?.className).toContain("overflow-hidden"); + const titleInput = container.querySelector('textarea[placeholder="Issue title"]'); const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]'); - const descriptionScrollRegion = descriptionInput?.parentElement?.parentElement; - expect(descriptionScrollRegion?.className).toContain("flex-1"); - expect(descriptionScrollRegion?.className).toContain("overflow-y-auto"); + const bodyScrollRegion = Array.from(container.querySelectorAll("div")).find((element) => + typeof element.className === "string" && element.className.includes("overscroll-contain"), + ); + expect(bodyScrollRegion?.className).toContain("flex-1"); + expect(bodyScrollRegion?.className).toContain("overflow-y-auto"); + expect(bodyScrollRegion?.contains(titleInput ?? null)).toBe(true); + expect(bodyScrollRegion?.contains(descriptionInput ?? null)).toBe(true); act(() => root.unmount()); }); @@ -452,13 +445,13 @@ describe("NewIssueDialog", () => { expect(container.textContent).not.toContain("will no longer use the parent issue workspace"); - const modeSelect = await waitForValue( - () => container.querySelector("select") as HTMLSelectElement | null, - ); + const selects = Array.from(container.querySelectorAll("select")); + const modeSelect = selects[0] as HTMLSelectElement | undefined; + expect(modeSelect).not.toBeUndefined(); await act(async () => { - modeSelect.value = "shared_workspace"; - modeSelect.dispatchEvent(new Event("change", { bubbles: true })); + modeSelect!.value = "shared_workspace"; + modeSelect!.dispatchEvent(new Event("change", { bubbles: true })); }); await flush(); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 618428d7..7798c234 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1056,9 +1056,10 @@ export function NewIssueDialog() {
- {/* Title */} -
-