mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Merge origin/master into pap-1298-runtime-routines-followups
This commit is contained in:
commit
556a3e71d7
26 files changed
+857
-67
No files matched your search
@@ -73,7 +73,7 @@ export async function dbBackupCommand(opts: DbBackupOptions): Promise<void> {
|
||||
const result = await runDatabaseBackup({
|
||||
connectionString: connection.value,
|
||||
backupDir,
|
||||
retentionDays,
|
||||
retention: { dailyDays: retentionDays, weeklyWeeks: 4, monthlyMonths: 1 },
|
||||
filenamePrefix,
|
||||
});
|
||||
spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`);
|
||||
|
||||
@@ -991,7 +991,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,
|
||||
|
||||
@@ -309,11 +309,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
const commandNotes = instructionsFilePath
|
||||
? [
|
||||
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
|
||||
]
|
||||
: [];
|
||||
|
||||
const runtimeConfig = await buildClaudeRuntimeConfig({
|
||||
runId,
|
||||
@@ -351,7 +346,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
|
||||
const pathDirective = `\nThe above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsFileDir}.`;
|
||||
const pathDirective =
|
||||
`\nThe above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsFileDir}. ` +
|
||||
`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.`;
|
||||
combinedInstructionsContents = instructionsContent + pathDirective;
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
@@ -361,19 +360,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
);
|
||||
}
|
||||
}
|
||||
const promptBundle = await prepareClaudePromptBundle({
|
||||
companyId: agent.companyId,
|
||||
skills: claudeSkillEntries.filter((entry) => desiredSkillNames.has(entry.key)),
|
||||
instructionsContents: combinedInstructionsContents,
|
||||
onLog,
|
||||
});
|
||||
const effectiveInstructionsFilePath = promptBundle.instructionsFilePath ?? undefined;
|
||||
commandNotes.push(`Using stable Claude prompt bundle ${promptBundle.bundleKey}.`);
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const runtimePromptBundleKey = asString(runtimeSessionParams.promptBundleKey, "");
|
||||
|
||||
let promptBundle = await prepareClaudePromptBundle({
|
||||
companyId: agent.companyId,
|
||||
skills: claudeSkillEntries.filter((entry) => desiredSkillNames.has(entry.key)),
|
||||
instructionsContents: combinedInstructionsContents,
|
||||
onLog,
|
||||
materializeInstructions: runtimeSessionId.length === 0,
|
||||
});
|
||||
let effectiveInstructionsFilePath = promptBundle.instructionsFilePath ?? undefined;
|
||||
const hasMatchingPromptBundle =
|
||||
runtimePromptBundleKey.length === 0 || runtimePromptBundleKey === promptBundle.bundleKey;
|
||||
const canResumeSession =
|
||||
@@ -397,6 +396,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
`[paperclip] Claude session "${runtimeSessionId}" was saved for prompt bundle "${runtimePromptBundleKey}" and will not be resumed with "${promptBundle.bundleKey}".\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const ensureMaterializedInstructionsFilePath = async () => {
|
||||
if (!combinedInstructionsContents) return undefined;
|
||||
if (effectiveInstructionsFilePath) return effectiveInstructionsFilePath;
|
||||
promptBundle = await prepareClaudePromptBundle({
|
||||
companyId: agent.companyId,
|
||||
skills: claudeSkillEntries.filter((entry) => desiredSkillNames.has(entry.key)),
|
||||
instructionsContents: combinedInstructionsContents,
|
||||
onLog,
|
||||
materializeInstructions: true,
|
||||
});
|
||||
effectiveInstructionsFilePath = promptBundle.instructionsFilePath ?? undefined;
|
||||
return effectiveInstructionsFilePath;
|
||||
};
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
@@ -429,7 +442,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
const buildClaudeArgs = (resumeSessionId: string | null) => {
|
||||
const buildClaudeArgs = (resumeSessionId: string | null, instructionsPath?: string) => {
|
||||
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
||||
@@ -442,8 +455,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
}
|
||||
if (effort) args.push("--effort", effort);
|
||||
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
||||
if (effectiveInstructionsFilePath) {
|
||||
args.push("--append-system-prompt-file", effectiveInstructionsFilePath);
|
||||
if (!resumeSessionId && instructionsPath) {
|
||||
args.push("--append-system-prompt-file", instructionsPath);
|
||||
}
|
||||
args.push("--add-dir", promptBundle.addDir);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
@@ -467,7 +480,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildClaudeArgs(resumeSessionId);
|
||||
const instructionsPath = resumeSessionId ? undefined : await ensureMaterializedInstructionsFilePath();
|
||||
const args = buildClaudeArgs(resumeSessionId, instructionsPath);
|
||||
const commandNotes =
|
||||
!resumeSessionId && instructionsPath
|
||||
? [
|
||||
`Injected agent instructions via --append-system-prompt-file ${instructionsPath} (with path directive appended)`,
|
||||
]
|
||||
: [];
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "claude_local",
|
||||
|
||||
@@ -134,8 +134,9 @@ export async function prepareClaudePromptBundle(input: {
|
||||
skills: SkillEntry[];
|
||||
instructionsContents: string | null;
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
materializeInstructions?: boolean;
|
||||
}): Promise<ClaudePromptBundle> {
|
||||
const { companyId, skills, instructionsContents, onLog } = input;
|
||||
const { companyId, skills, instructionsContents, onLog, materializeInstructions = true } = input;
|
||||
const bundleKey = await buildClaudePromptBundleKey({
|
||||
skills,
|
||||
instructionsContents,
|
||||
@@ -159,7 +160,7 @@ export async function prepareClaudePromptBundle(input: {
|
||||
const instructionsFilePath = instructionsContents
|
||||
? path.join(rootDir, "agent-instructions.md")
|
||||
: null;
|
||||
if (instructionsFilePath && instructionsContents) {
|
||||
if (materializeInstructions && instructionsFilePath && instructionsContents) {
|
||||
await ensureReadableFile(instructionsFilePath, instructionsContents);
|
||||
}
|
||||
|
||||
@@ -167,6 +168,6 @@ export async function prepareClaudePromptBundle(input: {
|
||||
bundleKey,
|
||||
rootDir,
|
||||
addDir: rootDir,
|
||||
instructionsFilePath,
|
||||
instructionsFilePath: materializeInstructions ? instructionsFilePath : null,
|
||||
};
|
||||
}
|
||||
@@ -125,12 +125,12 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
|
||||
const result = await runDatabaseBackup({
|
||||
connectionString: sourceConnectionString,
|
||||
backupDir,
|
||||
retentionDays: 7,
|
||||
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
|
||||
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({
|
||||
|
||||
+108
-16
@@ -1,12 +1,20 @@
|
||||
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 BackupRetentionPolicy = {
|
||||
dailyDays: number;
|
||||
weeklyWeeks: number;
|
||||
monthlyMonths: number;
|
||||
};
|
||||
|
||||
export type RunDatabaseBackupOptions = {
|
||||
connectionString: string;
|
||||
backupDir: string;
|
||||
retentionDays: number;
|
||||
retention: BackupRetentionPolicy;
|
||||
filenamePrefix?: string;
|
||||
connectTimeoutSeconds?: number;
|
||||
includeMigrationJournal?: boolean;
|
||||
@@ -75,23 +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}-`) || !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) {
|
||||
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<string>();
|
||||
const keepMonthBuckets = new Set<string>();
|
||||
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 {
|
||||
@@ -148,7 +224,9 @@ function tableKey(schemaName: string, tableName: string): string {
|
||||
}
|
||||
|
||||
async function* readRestoreStatements(backupFile: string): AsyncGenerator<string> {
|
||||
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 +258,7 @@ async function* readRestoreStatements(backupFile: string): AsyncGenerator<string
|
||||
} finally {
|
||||
reader.close();
|
||||
stream.destroy();
|
||||
raw.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,15 +360,16 @@ export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes
|
||||
|
||||
export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise<RunDatabaseBackupResult> {
|
||||
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);
|
||||
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
|
||||
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
|
||||
mkdirSync(opts.backupDir, { recursive: true });
|
||||
const backupFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
|
||||
const writer = createBufferedTextFileWriter(backupFile);
|
||||
const sqlFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
|
||||
const backupFile = `${sqlFile}.gz`;
|
||||
const writer = createBufferedTextFileWriter(sqlFile);
|
||||
|
||||
try {
|
||||
await sql`SELECT 1`;
|
||||
@@ -664,8 +744,14 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
|
||||
await writer.close();
|
||||
|
||||
// Compress the SQL file with gzip
|
||||
const sqlReadStream = createReadStream(sqlFile);
|
||||
const gzWriteStream = createWriteStream(backupFile);
|
||||
await pipeline(sqlReadStream, createGzip(), gzWriteStream);
|
||||
unlinkSync(sqlFile);
|
||||
|
||||
const sizeBytes = statSync(backupFile).size;
|
||||
const prunedCount = pruneOldBackups(opts.backupDir, retentionDays, filenamePrefix);
|
||||
const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix);
|
||||
|
||||
return {
|
||||
backupFile,
|
||||
@@ -674,6 +760,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
};
|
||||
} catch (error) {
|
||||
await writer.abort();
|
||||
if (existsSync(backupFile)) {
|
||||
try { unlinkSync(backupFile); } catch { /* ignore */ }
|
||||
}
|
||||
if (existsSync(sqlFile)) {
|
||||
try { unlinkSync(sqlFile); } catch { /* ignore */ }
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await sql.end();
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
runDatabaseBackup,
|
||||
runDatabaseRestore,
|
||||
formatDatabaseBackupResult,
|
||||
type BackupRetentionPolicy,
|
||||
type RunDatabaseBackupOptions,
|
||||
type RunDatabaseBackupResult,
|
||||
type RunDatabaseRestoreOptions,
|
||||
|
||||
@@ -21,7 +21,7 @@ export const llmConfigSchema = z.object({
|
||||
export const databaseBackupConfigSchema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
intervalMinutes: z.number().int().min(1).max(7 * 24 * 60).default(60),
|
||||
retentionDays: z.number().int().min(1).max(3650).default(30),
|
||||
retentionDays: z.number().int().min(1).max(3650).default(7),
|
||||
dir: z.string().default("~/.paperclip/instances/default/data/backups"),
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ export const databaseConfigSchema = z.object({
|
||||
backup: databaseBackupConfigSchema.default({
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
retentionDays: 7,
|
||||
dir: "~/.paperclip/instances/default/data/backups",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -189,6 +189,7 @@ export type {
|
||||
InstanceExperimentalSettings,
|
||||
InstanceGeneralSettings,
|
||||
InstanceSettings,
|
||||
BackupRetentionPolicy,
|
||||
Agent,
|
||||
AgentAccessState,
|
||||
AgentChainOfCommandEntry,
|
||||
@@ -370,6 +371,13 @@ export {
|
||||
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
|
||||
} from "./types/feedback.js";
|
||||
|
||||
export {
|
||||
DAILY_RETENTION_PRESETS,
|
||||
WEEKLY_RETENTION_PRESETS,
|
||||
MONTHLY_RETENTION_PRESETS,
|
||||
DEFAULT_BACKUP_RETENTION,
|
||||
} from "./types/instance.js";
|
||||
|
||||
export {
|
||||
getClosedIsolatedExecutionWorkspaceMessage,
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
|
||||
@@ -11,7 +11,8 @@ export type {
|
||||
FeedbackTraceBundleFile,
|
||||
FeedbackTraceBundle,
|
||||
} from "./feedback.js";
|
||||
export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings } 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,
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import type { FeedbackDataSharingPreference } from "./feedback.js";
|
||||
|
||||
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;
|
||||
backupRetention: BackupRetentionPolicy;
|
||||
}
|
||||
|
||||
export interface InstanceExperimentalSettings {
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
import { z } from "zod";
|
||||
import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE } from "../types/feedback.js";
|
||||
import {
|
||||
DAILY_RETENTION_PRESETS,
|
||||
WEEKLY_RETENTION_PRESETS,
|
||||
MONTHLY_RETENTION_PRESETS,
|
||||
DEFAULT_BACKUP_RETENTION,
|
||||
} from "../types/instance.js";
|
||||
import { feedbackDataSharingPreferenceSchema } from "./feedback.js";
|
||||
|
||||
function presetSchema<T extends readonly number[]>(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),
|
||||
keyboardShortcuts: z.boolean().default(false),
|
||||
feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default(
|
||||
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
),
|
||||
backupRetention: backupRetentionPolicySchema.default(DEFAULT_BACKUP_RETENTION),
|
||||
}).strict();
|
||||
|
||||
export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ vi.mock("../services/index.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
function createApp(actorOverrides: Record<string, unknown> = {}) {
|
||||
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",
|
||||
|
||||
@@ -21,6 +21,8 @@ const payload = {
|
||||
addDir,
|
||||
instructionsFilePath,
|
||||
instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null,
|
||||
appendedSystemPromptFilePath: instructionsFilePath,
|
||||
appendedSystemPromptFileContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null,
|
||||
skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [],
|
||||
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
|
||||
};
|
||||
@@ -45,7 +47,323 @@ type CapturePayload = {
|
||||
claudeConfigDir: string | null;
|
||||
};
|
||||
|
||||
async function writeRetryThenSucceedClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const addDirIndex = argv.indexOf("--add-dir");
|
||||
const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null;
|
||||
const instructionsIndex = argv.indexOf("--append-system-prompt-file");
|
||||
const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null;
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
const statePath = process.env.PAPERCLIP_TEST_STATE_PATH;
|
||||
const payload = {
|
||||
argv,
|
||||
prompt: fs.readFileSync(0, "utf8"),
|
||||
addDir,
|
||||
instructionsFilePath,
|
||||
instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null,
|
||||
appendedSystemPromptFilePath: instructionsFilePath,
|
||||
appendedSystemPromptFileContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null,
|
||||
skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [],
|
||||
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
|
||||
};
|
||||
if (capturePath) {
|
||||
const entries = fs.existsSync(capturePath) ? JSON.parse(fs.readFileSync(capturePath, "utf8")) : [];
|
||||
entries.push(payload);
|
||||
fs.writeFileSync(capturePath, JSON.stringify(entries), "utf8");
|
||||
}
|
||||
const resumed = argv.includes("--resume");
|
||||
const shouldFailResume = resumed && statePath && !fs.existsSync(statePath);
|
||||
if (shouldFailResume) {
|
||||
fs.writeFileSync(statePath, "retried", "utf8");
|
||||
console.log(JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "error",
|
||||
session_id: "claude-session-1",
|
||||
result: "No conversation found with session id claude-session-1",
|
||||
errors: ["No conversation found with session id claude-session-1"],
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-2", model: "claude-sonnet" }));
|
||||
console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-2", message: { content: [{ type: "text", text: "hello" }] } }));
|
||||
console.log(JSON.stringify({ type: "result", session_id: "claude-session-2", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
async function setupExecuteEnv(
|
||||
root: string,
|
||||
options?: { commandWriter?: (commandPath: string) => Promise<void> },
|
||||
) {
|
||||
const workspace = path.join(root, "workspace");
|
||||
const binDir = path.join(root, "bin");
|
||||
const commandPath = path.join(binDir, "claude");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
const statePath = path.join(root, "state.txt");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await (options?.commandWriter ?? writeFakeClaudeCommand)(commandPath);
|
||||
const previousHome = process.env.HOME;
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.HOME = root;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
||||
return {
|
||||
workspace,
|
||||
commandPath,
|
||||
capturePath,
|
||||
statePath,
|
||||
restore: () => {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = previousPath;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("claude execute", () => {
|
||||
/**
|
||||
* Regression tests for https://github.com/paperclipai/paperclip/issues/2848
|
||||
*
|
||||
* --append-system-prompt-file should only be passed on fresh sessions.
|
||||
* On resumed sessions the instructions are already in the session cache;
|
||||
* re-injecting them wastes tokens and may be rejected by the CLI.
|
||||
*/
|
||||
it("passes --append-system-prompt-file on a fresh session when instructionsFile is set", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-fresh-"));
|
||||
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root);
|
||||
const instructionsFile = path.join(root, "instructions.md");
|
||||
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
|
||||
try {
|
||||
await execute({
|
||||
runId: "run-fresh",
|
||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
||||
promptTemplate: "Do work.",
|
||||
instructionsFilePath: instructionsFile,
|
||||
},
|
||||
context: {},
|
||||
authToken: "tok",
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
});
|
||||
const captured = JSON.parse(await fs.readFile(capturePath, "utf-8"));
|
||||
expect(captured.argv).toContain("--append-system-prompt-file");
|
||||
} finally {
|
||||
restore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("omits --append-system-prompt-file on a resumed session even when instructionsFile is set", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-resume-"));
|
||||
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root);
|
||||
const instructionsFile = path.join(root, "instructions.md");
|
||||
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
|
||||
try {
|
||||
await execute({
|
||||
runId: "run-resume",
|
||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||
runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
||||
promptTemplate: "Do work.",
|
||||
instructionsFilePath: instructionsFile,
|
||||
},
|
||||
context: {},
|
||||
authToken: "tok",
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
});
|
||||
const captured = JSON.parse(await fs.readFile(capturePath, "utf-8"));
|
||||
expect(captured.argv).not.toContain("--append-system-prompt-file");
|
||||
expect(captured.argv).toContain("--resume");
|
||||
} finally {
|
||||
restore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression tests for commandNotes accuracy (Greptile P2).
|
||||
*
|
||||
* commandNotes should only claim instructions were injected when the flag
|
||||
* was actually passed — i.e. on fresh sessions, not resumed ones.
|
||||
*/
|
||||
it("commandNotes reports injection on a fresh session with instructionsFile", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-notes-fresh-"));
|
||||
const { workspace, commandPath, restore } = await setupExecuteEnv(root);
|
||||
const instructionsFile = path.join(root, "instructions.md");
|
||||
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
|
||||
let capturedNotes: string[] = [];
|
||||
try {
|
||||
await execute({
|
||||
runId: "run-notes-fresh",
|
||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
promptTemplate: "Do work.",
|
||||
instructionsFilePath: instructionsFile,
|
||||
},
|
||||
context: {},
|
||||
authToken: "tok",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => { capturedNotes = (meta.commandNotes as string[]) ?? []; },
|
||||
});
|
||||
expect(capturedNotes.some((n) => n.includes("--append-system-prompt-file"))).toBe(true);
|
||||
} finally {
|
||||
restore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("commandNotes is empty on a resumed session even when instructionsFile is set", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-notes-resume-"));
|
||||
const { workspace, commandPath, restore } = await setupExecuteEnv(root);
|
||||
const instructionsFile = path.join(root, "instructions.md");
|
||||
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
|
||||
let capturedNotes: string[] = ["sentinel"];
|
||||
try {
|
||||
await execute({
|
||||
runId: "run-notes-resume",
|
||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||
runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
promptTemplate: "Do work.",
|
||||
instructionsFilePath: instructionsFile,
|
||||
},
|
||||
context: {},
|
||||
authToken: "tok",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => { capturedNotes = (meta.commandNotes as string[]) ?? []; },
|
||||
});
|
||||
expect(capturedNotes).toHaveLength(0);
|
||||
} finally {
|
||||
restore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression test for unnecessary file I/O on resumed sessions (Greptile P2).
|
||||
*
|
||||
* The combined agent-instructions.md temp file must NOT be written when
|
||||
* resuming, since the instructions are already baked into the session cache.
|
||||
*/
|
||||
it("does not write agent-instructions temp file on a resumed session", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-io-resume-"));
|
||||
const { workspace, commandPath, restore } = await setupExecuteEnv(root);
|
||||
const instructionsFile = path.join(root, "instructions.md");
|
||||
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
|
||||
try {
|
||||
await execute({
|
||||
runId: "run-io-resume",
|
||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||
runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
promptTemplate: "Do work.",
|
||||
instructionsFilePath: instructionsFile,
|
||||
},
|
||||
context: {},
|
||||
authToken: "tok",
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
});
|
||||
// The skills dir lives under HOME/.paperclip/skills — verify no combined
|
||||
// agent-instructions.md was written anywhere under root on a resume.
|
||||
const allFiles = await fs.readdir(root, { recursive: true });
|
||||
const tempInstructionsWritten = (allFiles as string[]).some((f) =>
|
||||
f.includes("agent-instructions.md"),
|
||||
);
|
||||
expect(tempInstructionsWritten).toBe(false);
|
||||
} finally {
|
||||
restore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rebuilds the combined instructions file when an unknown resumed session falls back to fresh", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-resume-fallback-"));
|
||||
const { workspace, commandPath, capturePath, statePath, restore } = await setupExecuteEnv(root, {
|
||||
commandWriter: writeRetryThenSucceedClaudeCommand,
|
||||
});
|
||||
const instructionsFile = path.join(root, "instructions.md");
|
||||
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
|
||||
const metaEvents: Array<{ commandArgs: string[]; commandNotes: string[] }> = [];
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-resume-fallback",
|
||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||
runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
PAPERCLIP_TEST_STATE_PATH: statePath,
|
||||
},
|
||||
promptTemplate: "Do work.",
|
||||
instructionsFilePath: instructionsFile,
|
||||
},
|
||||
context: {},
|
||||
authToken: "tok",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
metaEvents.push({
|
||||
commandArgs: ((meta.commandArgs as string[]) ?? []).slice(),
|
||||
commandNotes: ((meta.commandNotes as string[]) ?? []).slice(),
|
||||
});
|
||||
},
|
||||
});
|
||||
const captured = JSON.parse(await fs.readFile(capturePath, "utf-8")) as Array<{
|
||||
argv: string[];
|
||||
appendedSystemPromptFilePath: string | null;
|
||||
appendedSystemPromptFileContents: string | null;
|
||||
}>;
|
||||
expect(captured).toHaveLength(2);
|
||||
expect(captured[0]?.argv).toContain("--resume");
|
||||
expect(captured[0]?.argv).not.toContain("--append-system-prompt-file");
|
||||
expect(captured[1]?.argv).not.toContain("--resume");
|
||||
expect(captured[1]?.argv).toContain("--append-system-prompt-file");
|
||||
expect(captured[1]?.appendedSystemPromptFilePath).toContain("agent-instructions.md");
|
||||
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)}/. ` +
|
||||
`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);
|
||||
expect(metaEvents[1]?.commandNotes.some((note) => note.includes("--append-system-prompt-file"))).toBe(true);
|
||||
expect(result.sessionId).toBe("claude-session-2");
|
||||
expect(result.clearSession).toBe(false);
|
||||
} finally {
|
||||
restore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it("logs HOME, CLAUDE_CONFIG_DIR, and the resolved executable path in invocation metadata", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-meta-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
@@ -252,7 +570,7 @@ describe("claude execute", () => {
|
||||
expect(capture1.addDir).toBeTruthy();
|
||||
expect(capture1.addDir).toBe(capture2.addDir);
|
||||
expect(capture1.instructionsFilePath).toBeTruthy();
|
||||
expect(capture1.instructionsFilePath).toBe(capture2.instructionsFilePath);
|
||||
expect(capture2.instructionsFilePath).toBeNull();
|
||||
expect(capture1.addDir?.startsWith(expectedRoot)).toBe(true);
|
||||
expect(capture1.instructionsFilePath?.startsWith(expectedRoot)).toBe(true);
|
||||
expect(capture1.instructionsContents).toContain("You are managed instructions.");
|
||||
@@ -260,6 +578,7 @@ describe("claude execute", () => {
|
||||
expect(capture1.skillEntries).toContain("paperclip");
|
||||
expect(capture2.argv).toContain("--resume");
|
||||
expect(capture2.argv).toContain("claude-session-1");
|
||||
expect(capture2.argv).not.toContain("--append-system-prompt-file");
|
||||
expect(capture2.prompt).toContain("## Paperclip Resume Delta");
|
||||
expect(capture2.prompt).not.toContain("Follow the paperclip heartbeat.");
|
||||
} finally {
|
||||
|
||||
@@ -177,4 +177,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();
|
||||
});
|
||||
});
|
||||
@@ -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 ??
|
||||
|
||||
+12
-6
@@ -31,6 +31,7 @@ import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
|
||||
import {
|
||||
feedbackService,
|
||||
heartbeatService,
|
||||
instanceSettingsService,
|
||||
reconcilePersistedRuntimeServicesOnStartup,
|
||||
routineService,
|
||||
} from "./services/index.js";
|
||||
@@ -623,20 +624,25 @@ export async function startServer(): Promise<StartedServer> {
|
||||
|
||||
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 retention = generalSettings.backupRetention;
|
||||
|
||||
const result = await runDatabaseBackup({
|
||||
connectionString: activeDatabaseConnectionString,
|
||||
backupDir: config.databaseBackupDir,
|
||||
retentionDays: config.databaseBackupRetentionDays,
|
||||
retention,
|
||||
filenamePrefix: "paperclip",
|
||||
});
|
||||
logger.info(
|
||||
@@ -645,7 +651,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||
sizeBytes: result.sizeBytes,
|
||||
prunedCount: result.prunedCount,
|
||||
backupDir: config.databaseBackupDir,
|
||||
retentionDays: config.databaseBackupRetentionDays,
|
||||
retention,
|
||||
},
|
||||
`Automatic database backup complete: ${formatDatabaseBackupResult(result)}`,
|
||||
);
|
||||
@@ -655,11 +661,11 @@ export async function startServer(): Promise<StartedServer> {
|
||||
backupInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
logger.info(
|
||||
{
|
||||
intervalMinutes: config.databaseBackupIntervalMinutes,
|
||||
retentionDays: config.databaseBackupRetentionDays,
|
||||
retentionSource: "instance-settings-db",
|
||||
backupDir: config.databaseBackupDir,
|
||||
},
|
||||
"Automatic database backups enabled",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -2297,6 +2297,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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +2,7 @@ import type { Db } from "@paperclipai/db";
|
||||
import { companies, instanceSettings } from "@paperclipai/db";
|
||||
import {
|
||||
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
DEFAULT_BACKUP_RETENTION,
|
||||
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,
|
||||
backupRetention: parsed.data.backupRetention ?? DEFAULT_BACKUP_RETENTION,
|
||||
};
|
||||
}
|
||||
return {
|
||||
censorUsernameInLogs: false,
|
||||
keyboardShortcuts: false,
|
||||
feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
backupRetention: DEFAULT_BACKUP_RETENTION,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { PatchInstanceGeneralSettings } from "@paperclipai/shared";
|
||||
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";
|
||||
@@ -67,6 +73,7 @@ export function InstanceGeneralSettings() {
|
||||
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
|
||||
const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true;
|
||||
const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
|
||||
const backupRetention: BackupRetentionPolicy = generalQuery.data?.backupRetention ?? DEFAULT_BACKUP_RETENTION;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
@@ -123,6 +130,108 @@ export function InstanceGeneralSettings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Backup retention</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Daily</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAILY_RETENTION_PRESETS.map((days) => {
|
||||
const active = backupRetention.dailyDays === days;
|
||||
return (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
disabled={updateGeneralMutation.isPending}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
active
|
||||
? "border-foreground bg-accent text-foreground"
|
||||
: "border-border bg-background hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() =>
|
||||
updateGeneralMutation.mutate({
|
||||
backupRetention: { ...backupRetention, dailyDays: days },
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="text-sm font-medium">{days} days</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Weekly</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{WEEKLY_RETENTION_PRESETS.map((weeks) => {
|
||||
const active = backupRetention.weeklyWeeks === weeks;
|
||||
const label = weeks === 1 ? "1 week" : `${weeks} weeks`;
|
||||
return (
|
||||
<button
|
||||
key={weeks}
|
||||
type="button"
|
||||
disabled={updateGeneralMutation.isPending}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
active
|
||||
? "border-foreground bg-accent text-foreground"
|
||||
: "border-border bg-background hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() =>
|
||||
updateGeneralMutation.mutate({
|
||||
backupRetention: { ...backupRetention, weeklyWeeks: weeks },
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Monthly</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{MONTHLY_RETENTION_PRESETS.map((months) => {
|
||||
const active = backupRetention.monthlyMonths === months;
|
||||
const label = months === 1 ? "1 month" : `${months} months`;
|
||||
return (
|
||||
<button
|
||||
key={months}
|
||||
type="button"
|
||||
disabled={updateGeneralMutation.isPending}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
active
|
||||
? "border-foreground bg-accent text-foreground"
|
||||
: "border-border bg-background hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() =>
|
||||
updateGeneralMutation.mutate({
|
||||
backupRetention: { ...backupRetention, monthlyMonths: months },
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
Reference in new issue
Block a user