Merge origin/master into pap-1298-runtime-routines-followups

This commit is contained in:
Dotta committed 2026-04-10 12:41:19 -05:00
commit 556a3e71d7
26 files changed
+857 -67

No files matched your search

+1 -1
View File
@@ -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)}`);
+1 -1
View File
@@ -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,
};
}
+3 -3
View File
@@ -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
View File
@@ -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();
+2 -2
View File
@@ -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",
});
+1
View File
@@ -21,6 +21,7 @@ export {
runDatabaseBackup,
runDatabaseRestore,
formatDatabaseBackupResult,
type BackupRetentionPolicy,
type RunDatabaseBackupOptions,
type RunDatabaseBackupResult,
type RunDatabaseRestoreOptions,
+2 -2
View File
@@ -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",
}),
});
+8
View File
@@ -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,
+2 -1
View File
@@ -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,
+17
View File
@@ -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();
});
});
+1 -1
View File
@@ -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
View File
@@ -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",
+9 -1
View File
@@ -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);
});
+4
View File
@@ -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) {
+22 -1
View File
@@ -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",
+14 -7
View File
@@ -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, {
+3
View File
@@ -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,
};
}
+110 -1
View File
@@ -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">