diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index dd9efee6..7aa171f5 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -175,6 +175,8 @@ Seed modes: After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance. +Provisioned git worktrees also pause all seeded routines in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development. + That repo-local env also sets: - `PAPERCLIP_IN_WORKTREE=true` diff --git a/doc/spec/agent-runs.md b/doc/spec/agent-runs.md index f0d02275..56e2eae1 100644 --- a/doc/spec/agent-runs.md +++ b/doc/spec/agent-runs.md @@ -249,7 +249,7 @@ Runs local `claude` CLI directly. "cwd": "/absolute/or/relative/path", "promptTemplate": "You are agent {{agent.id}} ...", "model": "optional-model-id", - "maxTurnsPerRun": 300, + "maxTurnsPerRun": 1000, "dangerouslySkipPermissions": true, "env": {"KEY": "VALUE"}, "extraArgs": [], diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index c6029e0c..d3a0b68b 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -20,7 +20,7 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports | `env` | object | No | Environment variables (supports secret refs) | | `timeoutSec` | number | No | Process timeout (0 = no timeout) | | `graceSec` | number | No | Grace period before force-kill | -| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) | +| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `1000`) | | `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (dev only) | ## Prompt Templates diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts new file mode 100644 index 00000000..4c59216b --- /dev/null +++ b/packages/db/src/backup-lib.test.ts @@ -0,0 +1,179 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import postgres from "postgres"; +import { createBufferedTextFileWriter, runDatabaseBackup, runDatabaseRestore } from "./backup-lib.js"; +import { ensurePostgresDatabase } from "./client.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./test-embedded-postgres.js"; + +const cleanups: Array<() => Promise | void> = []; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + cleanups.push(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + return dir; +} + +async function createTempDatabase(): Promise { + const db = await startEmbeddedPostgresTestDatabase("paperclip-db-backup-"); + cleanups.push(db.cleanup); + return db.connectionString; +} + +async function createSiblingDatabase(connectionString: string, databaseName: string): Promise { + const adminUrl = new URL(connectionString); + adminUrl.pathname = "/postgres"; + await ensurePostgresDatabase(adminUrl.toString(), databaseName); + const targetUrl = new URL(connectionString); + targetUrl.pathname = `/${databaseName}`; + return targetUrl.toString(); +} + +afterEach(async () => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + await cleanup?.(); + } +}); + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres backup tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describe("createBufferedTextFileWriter", () => { + it("preserves line boundaries across buffered flushes", async () => { + const tempDir = createTempDir("paperclip-buffered-writer-"); + const outputPath = path.join(tempDir, "backup.sql"); + const writer = createBufferedTextFileWriter(outputPath, 16); + const lines = [ + "-- header", + "BEGIN;", + "", + "INSERT INTO test VALUES (1);", + "-- footer", + ]; + + for (const line of lines) { + writer.emit(line); + } + + await writer.close(); + + expect(fs.readFileSync(outputPath, "utf8")).toBe(lines.join("\n")); + }); +}); + +describeEmbeddedPostgres("runDatabaseBackup", () => { + it( + "backs up and restores large table payloads without materializing one giant string", + async () => { + const sourceConnectionString = await createTempDatabase(); + const restoreConnectionString = await createSiblingDatabase( + sourceConnectionString, + "paperclip_restore_target", + ); + const backupDir = createTempDir("paperclip-db-backup-output-"); + const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} }); + const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} }); + + try { + await sourceSql.unsafe(` + CREATE TYPE "public"."backup_test_state" AS ENUM ('pending', 'done'); + `); + await sourceSql.unsafe(` + CREATE TABLE "public"."backup_test_records" ( + "id" serial PRIMARY KEY, + "title" text NOT NULL, + "payload" text NOT NULL, + "state" "public"."backup_test_state" NOT NULL, + "metadata" jsonb, + "created_at" timestamptz NOT NULL DEFAULT now() + ); + `); + + const payload = "x".repeat(8192); + for (let index = 0; index < 160; index += 1) { + const createdAt = new Date(Date.UTC(2026, 0, 1, 0, 0, index)); + await sourceSql` + INSERT INTO "public"."backup_test_records" ( + "title", + "payload", + "state", + "metadata", + "created_at" + ) + VALUES ( + ${`row-${index}`}, + ${payload}, + ${index % 2 === 0 ? "pending" : "done"}::"public"."backup_test_state", + ${JSON.stringify({ index, even: index % 2 === 0 })}::jsonb, + ${createdAt} + ) + `; + } + + const result = await runDatabaseBackup({ + connectionString: sourceConnectionString, + backupDir, + retentionDays: 7, + filenamePrefix: "paperclip-test", + }); + + expect(result.backupFile).toMatch(/paperclip-test-.*\.sql$/); + expect(result.sizeBytes).toBeGreaterThan(1024 * 1024); + expect(fs.existsSync(result.backupFile)).toBe(true); + + await runDatabaseRestore({ + connectionString: restoreConnectionString, + backupFile: result.backupFile, + }); + + const counts = await restoreSql.unsafe<{ count: number }[]>(` + SELECT count(*)::int AS count + FROM "public"."backup_test_records" + `); + expect(counts[0]?.count).toBe(160); + + const sampleRows = await restoreSql.unsafe<{ + title: string; + payload: string; + state: string; + metadata: { index: number; even: boolean }; + }[]>(` + SELECT "title", "payload", "state"::text AS "state", "metadata" + FROM "public"."backup_test_records" + WHERE "title" IN ('row-0', 'row-159') + ORDER BY "title" + `); + expect(sampleRows).toEqual([ + { + title: "row-0", + payload, + state: "pending", + metadata: { index: 0, even: true }, + }, + { + title: "row-159", + payload, + state: "done", + metadata: { index: 159, even: false }, + }, + ]); + } finally { + await sourceSql.end(); + await restoreSql.end(); + } + }, + 60_000, + ); +}); diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 26f918c3..4a2ca64a 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -1,5 +1,5 @@ -import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; -import { readFile, writeFile } from "node:fs/promises"; +import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { basename, resolve } from "node:path"; import postgres from "postgres"; @@ -47,6 +47,7 @@ type TableDefinition = { const DRIZZLE_SCHEMA = "drizzle"; const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; +const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024; const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900"; @@ -141,6 +142,98 @@ function tableKey(schemaName: string, tableName: string): string { return `${schemaName}.${tableName}`; } +export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes = DEFAULT_BACKUP_WRITE_BUFFER_BYTES) { + const stream = createWriteStream(filePath, { encoding: "utf8" }); + const flushThreshold = Math.max(1, Math.trunc(maxBufferedBytes)); + let bufferedLines: string[] = []; + let bufferedBytes = 0; + let firstChunk = true; + let closed = false; + let streamError: Error | null = null; + let pendingWrite = Promise.resolve(); + + stream.on("error", (error) => { + streamError = error; + }); + + const writeChunk = async (chunk: string): Promise => { + if (streamError) throw streamError; + const canContinue = stream.write(chunk); + if (!canContinue) { + await new Promise((resolve, reject) => { + const handleDrain = () => { + cleanup(); + resolve(); + }; + const handleError = (error: Error) => { + cleanup(); + reject(error); + }; + const cleanup = () => { + stream.off("drain", handleDrain); + stream.off("error", handleError); + }; + stream.once("drain", handleDrain); + stream.once("error", handleError); + }); + } + if (streamError) throw streamError; + }; + + const flushBufferedLines = () => { + if (bufferedLines.length === 0) return; + const linesToWrite = bufferedLines; + bufferedLines = []; + bufferedBytes = 0; + const chunkBody = linesToWrite.join("\n"); + const chunk = firstChunk ? chunkBody : `\n${chunkBody}`; + firstChunk = false; + pendingWrite = pendingWrite.then(() => writeChunk(chunk)); + }; + + return { + emit(line: string) { + if (closed) { + throw new Error(`Cannot write to closed backup file: ${filePath}`); + } + if (streamError) throw streamError; + bufferedLines.push(line); + bufferedBytes += Buffer.byteLength(line, "utf8") + 1; + if (bufferedBytes >= flushThreshold) { + flushBufferedLines(); + } + }, + async close() { + if (closed) return; + closed = true; + flushBufferedLines(); + await pendingWrite; + await new Promise((resolve, reject) => { + if (streamError) { + reject(streamError); + return; + } + stream.end((error?: Error | null) => { + if (error) reject(error); + else resolve(); + }); + }); + if (streamError) throw streamError; + }, + async abort() { + if (closed) return; + closed = true; + bufferedLines = []; + bufferedBytes = 0; + stream.destroy(); + await pendingWrite.catch(() => {}); + if (existsSync(filePath)) { + unlinkSync(filePath); + } + }, + }; +} + export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise { const filenamePrefix = opts.filenamePrefix ?? "paperclip"; const retentionDays = Math.max(1, Math.trunc(opts.retentionDays)); @@ -149,12 +242,14 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise 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); try { await sql`SELECT 1`; - const lines: string[] = []; - const emit = (line: string) => lines.push(line); + const emit = (line: string) => writer.emit(line); const emitStatement = (statement: string) => { emit(statement); emit(STATEMENT_BREAKPOINT); @@ -503,10 +598,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emitStatement("COMMIT;"); emit(""); - // Write the backup file - mkdirSync(opts.backupDir, { recursive: true }); - const backupFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`); - await writeFile(backupFile, lines.join("\n"), "utf8"); + await writer.close(); const sizeBytes = statSync(backupFile).size; const prunedCount = pruneOldBackups(opts.backupDir, retentionDays, filenamePrefix); @@ -516,6 +608,9 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise sizeBytes, prunedCount, }; + } catch (error) { + await writer.abort(); + throw error; } finally { await sql.end(); } diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index ec156d89..70599868 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -2,6 +2,7 @@ import type { FeedbackDataSharingPreference } from "./feedback.js"; export interface InstanceGeneralSettings { censorUsernameInLogs: boolean; + keyboardShortcuts: boolean; feedbackDataSharingPreference: FeedbackDataSharingPreference; } diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 4afad283..5634c8f6 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -4,6 +4,7 @@ import { feedbackDataSharingPreferenceSchema } from "./feedback.js"; export const instanceGeneralSettingsSchema = z.object({ censorUsernameInLogs: z.boolean().default(false), + keyboardShortcuts: z.boolean().default(false), feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default( DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, ), diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index f1d2afcd..7856591d 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -19,12 +19,14 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { if (parsed.success) { return { censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false, + keyboardShortcuts: parsed.data.keyboardShortcuts ?? false, feedbackDataSharingPreference: parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, }; } return { censorUsernameInLogs: false, + keyboardShortcuts: false, feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, }; } diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 67ca00b5..296e3341 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -166,7 +166,7 @@ If you are asked to create or manage routines you MUST read: - **Budget**: auto-paused at 100%. Above 80%, focus on critical tasks only. - **Escalate** via `chainOfCommand` when stuck. Reassign to manager or create a task for them. - **Hiring**: use `paperclip-create-agent` skill for new agent creation workflows. -- **Commit Co-author**: if you make a git commit you MUST add `Co-Authored-By: Paperclip ` to the end of each commit message +- **Commit Co-author**: if you make a git commit you MUST add EXACTLY `Co-Authored-By: Paperclip ` to the end of each commit message. Do not put in your agent name, put `Co-Authored-By: Paperclip ` ## Comment Style (Required) @@ -293,10 +293,10 @@ PATCH /api/agents/{agentId}/instructions-path | Import company skills | `POST /api/companies/:companyId/skills/import` | | Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` | | Sync agent desired skills | `POST /api/agents/:agentId/skills/sync` | -| Preview CEO-safe company import | `POST /api/companies/:companyId/imports/preview` | -| Apply CEO-safe company import | `POST /api/companies/:companyId/imports/apply` | -| Preview company export | `POST /api/companies/:companyId/exports/preview` | -| Build company export | `POST /api/companies/:companyId/exports` | +| Preview CEO-safe company import | `POST /api/companies/:companyId/imports/preview` | +| Apply CEO-safe company import | `POST /api/companies/:companyId/imports/apply` | +| Preview company export | `POST /api/companies/:companyId/exports/preview` | +| Build company export | `POST /api/companies/:companyId/exports` | | Dashboard | `GET /api/companies/:companyId/dashboard` | | Search issues | `GET /api/companies/:companyId/issues?q=search+term` | | Upload attachment (multipart, field=file) | `POST /api/companies/:companyId/issues/:issueId/attachments` | diff --git a/ui/src/adapters/claude-local/config-fields.tsx b/ui/src/adapters/claude-local/config-fields.tsx index 972c378e..fd46352b 100644 --- a/ui/src/adapters/claude-local/config-fields.tsx +++ b/ui/src/adapters/claude-local/config-fields.tsx @@ -125,9 +125,9 @@ export function ClaudeLocalAdvancedFields({ value={eff( "adapterConfig", "maxTurnsPerRun", - Number(config.maxTurnsPerRun ?? 300), + Number(config.maxTurnsPerRun ?? 1000), )} - onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 300)} + onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 1000)} immediate className={inputClass} /> diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx new file mode 100644 index 00000000..8ba65c60 --- /dev/null +++ b/ui/src/components/CommentThread.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import type { Agent } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CommentThread } from "./CommentThread"; + +vi.mock("./MarkdownBody", () => ({ + MarkdownBody: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), +})); + +vi.mock("./MarkdownEditor", () => ({ + MarkdownEditor: ({ value, onChange, placeholder }: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + }) => ( +