From f99f174e2dd186373e0b981ba5172a0dc75e3f5d Mon Sep 17 00:00:00 2001 From: Chris Schneider Date: Fri, 6 Mar 2026 17:16:39 +0000 Subject: [PATCH 001/874] Show issue creator in properties sidebar --- ui/src/components/IssueProperties.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index ff7229dc..87846ecd 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -525,6 +525,23 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
+ {(issue.createdByAgentId || issue.createdByUserId) && ( + + {issue.createdByAgentId ? ( + + + + ) : ( + <> + + {creatorUserLabel ?? "User"} + + )} + + )} {issue.startedAt && ( {formatDate(issue.startedAt)} From b19d0b6f3b3295f2fc96b01208bd36db02e98411 Mon Sep 17 00:00:00 2001 From: JonCSykes Date: Fri, 6 Mar 2026 16:39:35 -0500 Subject: [PATCH 002/874] Add support for company logos, including schema adjustments, validation, assets handling, and UI display enhancements. --- .gitignore | 4 +- cli/src/__tests__/company-delete.test.ts | 1 + docs/api/companies.md | 19 + .../src/migrations/0026_high_anita_blake.sql | 1 + .../db/src/migrations/meta/0026_snapshot.json | 5855 +++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/companies.ts | 1 + packages/shared/src/types/company.ts | 1 + packages/shared/src/validators/company.ts | 10 + pnpm-lock.yaml | 21 +- server/src/__tests__/assets.test.ts | 157 + server/src/routes/assets.ts | 9 +- ui/src/api/companies.ts | 9 +- ui/src/components/CompanyPatternIcon.tsx | 32 +- ui/src/components/CompanyRail.tsx | 1 + ui/src/context/CompanyContext.tsx | 15 +- ui/src/pages/CompanySettings.tsx | 94 +- 17 files changed, 6211 insertions(+), 26 deletions(-) create mode 100644 packages/db/src/migrations/0026_high_anita_blake.sql create mode 100644 packages/db/src/migrations/meta/0026_snapshot.json create mode 100644 server/src/__tests__/assets.test.ts diff --git a/.gitignore b/.gitignore index 9d9f5e35..6b68f737 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,6 @@ tmp/ *.tmp .vscode/ .claude/settings.local.json -.paperclip-local/ \ No newline at end of file +.paperclip-local/ +/.idea/ +/.agents/ diff --git a/cli/src/__tests__/company-delete.test.ts b/cli/src/__tests__/company-delete.test.ts index 6858a3d1..65e5a021 100644 --- a/cli/src/__tests__/company-delete.test.ts +++ b/cli/src/__tests__/company-delete.test.ts @@ -14,6 +14,7 @@ function makeCompany(overrides: Partial): Company { spentMonthlyCents: 0, requireBoardApprovalForNewAgents: false, brandColor: null, + logoUrl: null, createdAt: new Date(), updatedAt: new Date(), ...overrides, diff --git a/docs/api/companies.md b/docs/api/companies.md index a0aafae5..4b92fe1e 100644 --- a/docs/api/companies.md +++ b/docs/api/companies.md @@ -42,6 +42,24 @@ PATCH /api/companies/{companyId} } ``` +## Upload Company Logo + +Upload an image for a company icon and store it as that company’s logo. + +``` +POST /api/companies/{companyId}/assets/images +Content-Type: multipart/form-data +``` + +Valid image content types: + +- `image/png` +- `image/jpeg` +- `image/jpg` +- `image/webp` +- `image/gif` +- `image/svg+xml` (`.svg`) + ## Archive Company ``` @@ -58,6 +76,7 @@ Archives a company. Archived companies are hidden from default listings. | `name` | string | Company name | | `description` | string | Company description | | `status` | string | `active`, `paused`, `archived` | +| `logoUrl` | string | Optional path or URL for the logo image | | `budgetMonthlyCents` | number | Monthly budget limit | | `createdAt` | string | ISO timestamp | | `updatedAt` | string | ISO timestamp | diff --git a/packages/db/src/migrations/0026_high_anita_blake.sql b/packages/db/src/migrations/0026_high_anita_blake.sql new file mode 100644 index 00000000..17be3222 --- /dev/null +++ b/packages/db/src/migrations/0026_high_anita_blake.sql @@ -0,0 +1 @@ +ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "logo_url" text; diff --git a/packages/db/src/migrations/meta/0026_snapshot.json b/packages/db/src/migrations/meta/0026_snapshot.json new file mode 100644 index 00000000..400b7eb5 --- /dev/null +++ b/packages/db/src/migrations/meta/0026_snapshot.json @@ -0,0 +1,5855 @@ +{ + "id": "ada32d9a-1735-4149-91c7-83ae8f5dd482", + "prevId": "bd8d9b8d-3012-4c58-bcfd-b3215c164f82", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_log_company_created_idx": { + "name": "activity_log_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_run_id_idx": { + "name": "activity_log_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_entity_type_id_idx": { + "name": "activity_log_entity_type_id_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_company_id_companies_id_fk": { + "name": "activity_log_company_id_companies_id_fk", + "tableFrom": "activity_log", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_agent_id_agents_id_fk": { + "name": "activity_log_agent_id_agents_id_fk", + "tableFrom": "activity_log", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_run_id_heartbeat_runs_id_fk": { + "name": "activity_log_run_id_heartbeat_runs_id_fk", + "tableFrom": "activity_log", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_api_keys": { + "name": "agent_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_api_keys_key_hash_idx": { + "name": "agent_api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_api_keys_company_agent_idx": { + "name": "agent_api_keys_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_api_keys_agent_id_agents_id_fk": { + "name": "agent_api_keys_agent_id_agents_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_api_keys_company_id_companies_id_fk": { + "name": "agent_api_keys_company_id_companies_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config_revisions": { + "name": "agent_config_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'patch'" + }, + "rolled_back_from_revision_id": { + "name": "rolled_back_from_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "changed_keys": { + "name": "changed_keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "before_config": { + "name": "before_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "after_config": { + "name": "after_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_config_revisions_company_agent_created_idx": { + "name": "agent_config_revisions_company_agent_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_config_revisions_agent_created_idx": { + "name": "agent_config_revisions_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_revisions_company_id_companies_id_fk": { + "name": "agent_config_revisions_company_id_companies_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_config_revisions_agent_id_agents_id_fk": { + "name": "agent_config_revisions_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_config_revisions_created_by_agent_id_agents_id_fk": { + "name": "agent_config_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_runtime_state": { + "name": "agent_runtime_state", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_json": { + "name": "state_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_input_tokens": { + "name": "total_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_output_tokens": { + "name": "total_output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cached_input_tokens": { + "name": "total_cached_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost_cents": { + "name": "total_cost_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_runtime_state_company_agent_idx": { + "name": "agent_runtime_state_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_runtime_state_company_updated_idx": { + "name": "agent_runtime_state_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_runtime_state_agent_id_agents_id_fk": { + "name": "agent_runtime_state_agent_id_agents_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_runtime_state_company_id_companies_id_fk": { + "name": "agent_runtime_state_company_id_companies_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task_sessions": { + "name": "agent_task_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_key": { + "name": "task_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_params_json": { + "name": "session_params_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_display_id": { + "name": "session_display_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_task_sessions_company_agent_adapter_task_uniq": { + "name": "agent_task_sessions_company_agent_adapter_task_uniq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_agent_updated_idx": { + "name": "agent_task_sessions_company_agent_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_task_updated_idx": { + "name": "agent_task_sessions_company_task_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task_sessions_company_id_companies_id_fk": { + "name": "agent_task_sessions_company_id_companies_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_agent_id_agents_id_fk": { + "name": "agent_task_sessions_agent_id_agents_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_last_run_id_heartbeat_runs_id_fk": { + "name": "agent_task_sessions_last_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "last_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_wakeup_requests": { + "name": "agent_wakeup_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "coalesced_count": { + "name": "coalesced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "requested_by_actor_type": { + "name": "requested_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_actor_id": { + "name": "requested_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_wakeup_requests_company_agent_status_idx": { + "name": "agent_wakeup_requests_company_agent_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_company_requested_idx": { + "name": "agent_wakeup_requests_company_requested_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_agent_requested_idx": { + "name": "agent_wakeup_requests_agent_requested_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_wakeup_requests_company_id_companies_id_fk": { + "name": "agent_wakeup_requests_company_id_companies_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_wakeup_requests_agent_id_agents_id_fk": { + "name": "agent_wakeup_requests_agent_id_agents_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "reports_to": { + "name": "reports_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'process'" + }, + "adapter_config": { + "name": "adapter_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "runtime_config": { + "name": "runtime_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_company_status_idx": { + "name": "agents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_company_reports_to_idx": { + "name": "agents_company_reports_to_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reports_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_company_id_companies_id_fk": { + "name": "agents_company_id_companies_id_fk", + "tableFrom": "agents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agents_reports_to_agents_id_fk": { + "name": "agents_reports_to_agents_id_fk", + "tableFrom": "agents", + "tableTo": "agents", + "columnsFrom": [ + "reports_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_comments": { + "name": "approval_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_comments_company_idx": { + "name": "approval_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_idx": { + "name": "approval_comments_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_created_idx": { + "name": "approval_comments_approval_created_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_comments_company_id_companies_id_fk": { + "name": "approval_comments_company_id_companies_id_fk", + "tableFrom": "approval_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_approval_id_approvals_id_fk": { + "name": "approval_comments_approval_id_approvals_id_fk", + "tableFrom": "approval_comments", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_author_agent_id_agents_id_fk": { + "name": "approval_comments_author_agent_id_agents_id_fk", + "tableFrom": "approval_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approvals": { + "name": "approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_agent_id": { + "name": "requested_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decision_note": { + "name": "decision_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approvals_company_status_type_idx": { + "name": "approvals_company_status_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approvals_company_id_companies_id_fk": { + "name": "approvals_company_id_companies_id_fk", + "tableFrom": "approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approvals_requested_by_agent_id_agents_id_fk": { + "name": "approvals_requested_by_agent_id_agents_id_fk", + "tableFrom": "approvals", + "tableTo": "agents", + "columnsFrom": [ + "requested_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_company_created_idx": { + "name": "assets_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_provider_idx": { + "name": "assets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_object_key_uq": { + "name": "assets_company_object_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_company_id_companies_id_fk": { + "name": "assets_company_id_companies_id_fk", + "tableFrom": "assets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "assets_created_by_agent_id_agents_id_fk": { + "name": "assets_created_by_agent_id_agents_id_fk", + "tableFrom": "assets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issue_prefix": { + "name": "issue_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PAP'" + }, + "issue_counter": { + "name": "issue_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "companies_issue_prefix_idx": { + "name": "companies_issue_prefix_idx", + "columns": [ + { + "expression": "issue_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_memberships_company_principal_unique_idx": { + "name": "company_memberships_company_principal_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_principal_status_idx": { + "name": "company_memberships_principal_status_idx", + "columns": [ + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_company_status_idx": { + "name": "company_memberships_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_versions": { + "name": "company_secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "material": { + "name": "material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "value_sha256": { + "name": "value_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_secret_versions_secret_idx": { + "name": "company_secret_versions_secret_idx", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_value_sha256_idx": { + "name": "company_secret_versions_value_sha256_idx", + "columns": [ + { + "expression": "value_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_secret_version_uq": { + "name": "company_secret_versions_secret_version_uq", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_versions_secret_id_company_secrets_id_fk": { + "name": "company_secret_versions_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_versions_created_by_agent_id_agents_id_fk": { + "name": "company_secret_versions_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secrets": { + "name": "company_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_encrypted'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version": { + "name": "latest_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_secrets_company_idx": { + "name": "company_secrets_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_provider_idx": { + "name": "company_secrets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_name_uq": { + "name": "company_secrets_company_name_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secrets_company_id_companies_id_fk": { + "name": "company_secrets_company_id_companies_id_fk", + "tableFrom": "company_secrets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secrets_created_by_agent_id_agents_id_fk": { + "name": "company_secrets_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secrets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_events": { + "name": "cost_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_events_company_occurred_idx": { + "name": "cost_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_agent_occurred_idx": { + "name": "cost_events_company_agent_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_events_company_id_companies_id_fk": { + "name": "cost_events_company_id_companies_id_fk", + "tableFrom": "cost_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_agent_id_agents_id_fk": { + "name": "cost_events_agent_id_agents_id_fk", + "tableFrom": "cost_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_issue_id_issues_id_fk": { + "name": "cost_events_issue_id_issues_id_fk", + "tableFrom": "cost_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_project_id_projects_id_fk": { + "name": "cost_events_project_id_projects_id_fk", + "tableFrom": "cost_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_goal_id_goals_id_fk": { + "name": "cost_events_goal_id_goals_id_fk", + "tableFrom": "cost_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goals_company_idx": { + "name": "goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_company_id_companies_id_fk": { + "name": "goals_company_id_companies_id_fk", + "tableFrom": "goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_parent_id_goals_id_fk": { + "name": "goals_parent_id_goals_id_fk", + "tableFrom": "goals", + "tableTo": "goals", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_owner_agent_id_agents_id_fk": { + "name": "goals_owner_agent_id_agents_id_fk", + "tableFrom": "goals", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_run_events": { + "name": "heartbeat_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_events_run_seq_idx": { + "name": "heartbeat_run_events_run_seq_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_run_idx": { + "name": "heartbeat_run_events_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_created_idx": { + "name": "heartbeat_run_events_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_events_company_id_companies_id_fk": { + "name": "heartbeat_run_events_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_agent_id_agents_id_fk": { + "name": "heartbeat_run_events_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_runs": { + "name": "heartbeat_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invocation_source": { + "name": "invocation_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'on_demand'" + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wakeup_request_id": { + "name": "wakeup_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signal": { + "name": "signal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id_before": { + "name": "session_id_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id_after": { + "name": "session_id_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_run_id": { + "name": "external_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_snapshot": { + "name": "context_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_runs_company_agent_started_idx": { + "name": "heartbeat_runs_company_agent_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_runs_company_id_companies_id_fk": { + "name": "heartbeat_runs_company_id_companies_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_agent_id_agents_id_fk": { + "name": "heartbeat_runs_agent_id_agents_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { + "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agent_wakeup_requests", + "columnsFrom": [ + "wakeup_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_user_roles": { + "name": "instance_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'instance_admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_user_roles_user_role_unique_idx": { + "name": "instance_user_roles_user_role_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "instance_user_roles_role_idx": { + "name": "instance_user_roles_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invite_type": { + "name": "invite_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company_join'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_join_types": { + "name": "allowed_join_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "defaults_payload": { + "name": "defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique_idx": { + "name": "invites_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_company_invite_state_idx": { + "name": "invites_company_invite_state_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invite_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_approvals": { + "name": "issue_approvals", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "linked_by_agent_id": { + "name": "linked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_approvals_issue_idx": { + "name": "issue_approvals_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_approval_idx": { + "name": "issue_approvals_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_company_idx": { + "name": "issue_approvals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_approvals_company_id_companies_id_fk": { + "name": "issue_approvals_company_id_companies_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_approvals_issue_id_issues_id_fk": { + "name": "issue_approvals_issue_id_issues_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_approval_id_approvals_id_fk": { + "name": "issue_approvals_approval_id_approvals_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_linked_by_agent_id_agents_id_fk": { + "name": "issue_approvals_linked_by_agent_id_agents_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "agents", + "columnsFrom": [ + "linked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_approvals_pk": { + "name": "issue_approvals_pk", + "columns": [ + "issue_id", + "approval_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_attachments": { + "name": "issue_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_comment_id": { + "name": "issue_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_attachments_company_issue_idx": { + "name": "issue_attachments_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_issue_comment_idx": { + "name": "issue_attachments_issue_comment_idx", + "columns": [ + { + "expression": "issue_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_asset_uq": { + "name": "issue_attachments_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_attachments_company_id_companies_id_fk": { + "name": "issue_attachments_company_id_companies_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_attachments_issue_id_issues_id_fk": { + "name": "issue_attachments_issue_id_issues_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_asset_id_assets_id_fk": { + "name": "issue_attachments_asset_id_assets_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_issue_comment_id_issue_comments_id_fk": { + "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issue_comments", + "columnsFrom": [ + "issue_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_comments": { + "name": "issue_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_comments_issue_idx": { + "name": "issue_comments_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_idx": { + "name": "issue_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_issue_created_at_idx": { + "name": "issue_comments_company_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_author_issue_created_at_idx": { + "name": "issue_comments_company_author_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_comments_company_id_companies_id_fk": { + "name": "issue_comments_company_id_companies_id_fk", + "tableFrom": "issue_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_issue_id_issues_id_fk": { + "name": "issue_comments_issue_id_issues_id_fk", + "tableFrom": "issue_comments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_author_agent_id_agents_id_fk": { + "name": "issue_comments_author_agent_id_agents_id_fk", + "tableFrom": "issue_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_labels": { + "name": "issue_labels", + "schema": "", + "columns": { + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_labels_issue_idx": { + "name": "issue_labels_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_label_idx": { + "name": "issue_labels_label_idx", + "columns": [ + { + "expression": "label_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_company_idx": { + "name": "issue_labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_labels_issue_id_issues_id_fk": { + "name": "issue_labels_issue_id_issues_id_fk", + "tableFrom": "issue_labels", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_label_id_labels_id_fk": { + "name": "issue_labels_label_id_labels_id_fk", + "tableFrom": "issue_labels", + "tableTo": "labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_company_id_companies_id_fk": { + "name": "issue_labels_company_id_companies_id_fk", + "tableFrom": "issue_labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_labels_pk": { + "name": "issue_labels_pk", + "columns": [ + "issue_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_read_states": { + "name": "issue_read_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_read_states_company_issue_idx": { + "name": "issue_read_states_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_user_idx": { + "name": "issue_read_states_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_issue_user_idx": { + "name": "issue_read_states_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_read_states_company_id_companies_id_fk": { + "name": "issue_read_states_company_id_companies_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_read_states_issue_id_issues_id_fk": { + "name": "issue_read_states_issue_id_issues_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issues": { + "name": "issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_run_id": { + "name": "checkout_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_run_id": { + "name": "execution_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_agent_name_key": { + "name": "execution_agent_name_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_locked_at": { + "name": "execution_locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_depth": { + "name": "request_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_adapter_overrides": { + "name": "assignee_adapter_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issues_company_status_idx": { + "name": "issues_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_status_idx": { + "name": "issues_company_assignee_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_user_status_idx": { + "name": "issues_company_assignee_user_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_parent_idx": { + "name": "issues_company_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_idx": { + "name": "issues_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_identifier_idx": { + "name": "issues_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_company_id_companies_id_fk": { + "name": "issues_company_id_companies_id_fk", + "tableFrom": "issues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_goal_id_goals_id_fk": { + "name": "issues_goal_id_goals_id_fk", + "tableFrom": "issues", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_agent_id_agents_id_fk": { + "name": "issues_assignee_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_checkout_run_id_heartbeat_runs_id_fk": { + "name": "issues_checkout_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "checkout_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_execution_run_id_heartbeat_runs_id_fk": { + "name": "issues_execution_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "execution_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_created_by_agent_id_agents_id_fk": { + "name": "issues_created_by_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_approval'" + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requesting_user_id": { + "name": "requesting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_email_snapshot": { + "name": "request_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_defaults_payload": { + "name": "agent_defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_secret_expires_at": { + "name": "claim_secret_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_secret_consumed_at": { + "name": "claim_secret_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_agent_id": { + "name": "created_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "join_requests_invite_unique_idx": { + "name": "join_requests_invite_unique_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_company_status_type_created_idx": { + "name": "join_requests_company_status_type_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "join_requests_invite_id_invites_id_fk": { + "name": "join_requests_invite_id_invites_id_fk", + "tableFrom": "join_requests", + "tableTo": "invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_company_id_companies_id_fk": { + "name": "join_requests_company_id_companies_id_fk", + "tableFrom": "join_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_created_agent_id_agents_id_fk": { + "name": "join_requests_created_agent_id_agents_id_fk", + "tableFrom": "join_requests", + "tableTo": "agents", + "columnsFrom": [ + "created_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.labels": { + "name": "labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "labels_company_idx": { + "name": "labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "labels_company_name_idx": { + "name": "labels_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "labels_company_id_companies_id_fk": { + "name": "labels_company_id_companies_id_fk", + "tableFrom": "labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_permission_grants": { + "name": "principal_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_key": { + "name": "permission_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "principal_permission_grants_unique_idx": { + "name": "principal_permission_grants_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "principal_permission_grants_company_permission_idx": { + "name": "principal_permission_grants_company_permission_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "principal_permission_grants_company_id_companies_id_fk": { + "name": "principal_permission_grants_company_id_companies_id_fk", + "tableFrom": "principal_permission_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_goals": { + "name": "project_goals", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_goals_project_idx": { + "name": "project_goals_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_goal_idx": { + "name": "project_goals_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_company_idx": { + "name": "project_goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_goals_project_id_projects_id_fk": { + "name": "project_goals_project_id_projects_id_fk", + "tableFrom": "project_goals", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_goal_id_goals_id_fk": { + "name": "project_goals_goal_id_goals_id_fk", + "tableFrom": "project_goals", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_company_id_companies_id_fk": { + "name": "project_goals_company_id_companies_id_fk", + "tableFrom": "project_goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_goals_project_id_goal_id_pk": { + "name": "project_goals_project_id_goal_id_pk", + "columns": [ + "project_id", + "goal_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_workspaces": { + "name": "project_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_ref": { + "name": "repo_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_workspaces_company_project_idx": { + "name": "project_workspaces_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_primary_idx": { + "name": "project_workspaces_project_primary_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_primary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_workspaces_company_id_companies_id_fk": { + "name": "project_workspaces_company_id_companies_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_workspaces_project_id_projects_id_fk": { + "name": "project_workspaces_project_id_projects_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "lead_agent_id": { + "name": "lead_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_date": { + "name": "target_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_company_idx": { + "name": "projects_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_company_id_companies_id_fk": { + "name": "projects_company_id_companies_id_fk", + "tableFrom": "projects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_goal_id_goals_id_fk": { + "name": "projects_goal_id_goals_id_fk", + "tableFrom": "projects", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_lead_agent_id_agents_id_fk": { + "name": "projects_lead_agent_id_agents_id_fk", + "tableFrom": "projects", + "tableTo": "agents", + "columnsFrom": [ + "lead_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index c3e25050..22a695b4 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1772807461603, "tag": "0025_nasty_salo", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1772823634634, + "tag": "0026_high_anita_blake", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/companies.ts b/packages/db/src/schema/companies.ts index 29c82b71..3e75d4e6 100644 --- a/packages/db/src/schema/companies.ts +++ b/packages/db/src/schema/companies.ts @@ -15,6 +15,7 @@ export const companies = pgTable( .notNull() .default(true), brandColor: text("brand_color"), + logoUrl: text("logo_url"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/packages/shared/src/types/company.ts b/packages/shared/src/types/company.ts index 435be80d..3002c7d3 100644 --- a/packages/shared/src/types/company.ts +++ b/packages/shared/src/types/company.ts @@ -11,6 +11,7 @@ export interface Company { spentMonthlyCents: number; requireBoardApprovalForNewAgents: boolean; brandColor: string | null; + logoUrl: string | null; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/validators/company.ts b/packages/shared/src/validators/company.ts index 407d2ae4..65083c91 100644 --- a/packages/shared/src/validators/company.ts +++ b/packages/shared/src/validators/company.ts @@ -1,10 +1,19 @@ import { z } from "zod"; import { COMPANY_STATUSES } from "../constants.js"; +const logoUrlSchema = z + .string() + .trim() + .max(2048) + .regex(/^\/api\/assets\/[^\s]+$|^https?:\/\/[^\s]+$/) + .nullable() + .optional(); + export const createCompanySchema = z.object({ name: z.string().min(1), description: z.string().optional().nullable(), budgetMonthlyCents: z.number().int().nonnegative().optional().default(0), + logoUrl: logoUrlSchema, }); export type CreateCompany = z.infer; @@ -16,6 +25,7 @@ export const updateCompanySchema = createCompanySchema spentMonthlyCents: z.number().int().nonnegative().optional(), requireBoardApprovalForNewAgents: z.boolean().optional(), brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(), + logoUrl: logoUrlSchema, }); export type UpdateCompany = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 492cd35a..b9b5f3e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -156,8 +159,8 @@ importers: version: 1.1.1 devDependencies: '@types/node': - specifier: ^22.12.0 - version: 22.19.11 + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -8162,7 +8165,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/chai@5.2.3': dependencies: @@ -8171,7 +8174,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/cookiejar@2.1.5': {} @@ -8189,7 +8192,7 @@ snapshots: '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -8246,18 +8249,18 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 24.12.0 + '@types/node': 25.2.3 form-data: 4.0.5 '@types/supertest@6.0.3': @@ -8271,7 +8274,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@ungap/structured-clone@1.3.0': {} diff --git a/server/src/__tests__/assets.test.ts b/server/src/__tests__/assets.test.ts new file mode 100644 index 00000000..eb58e24a --- /dev/null +++ b/server/src/__tests__/assets.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import express from "express"; +import request from "supertest"; +import { assetRoutes } from "../routes/assets.js"; +import type { StorageService } from "../storage/types.js"; + +const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() => ({ + createAssetMock: vi.fn(), + getAssetByIdMock: vi.fn(), + logActivityMock: vi.fn(), +})); + +vi.mock("../services/index.js", () => ({ + assetService: vi.fn(() => ({ + create: createAssetMock, + getById: getAssetByIdMock, + })), + logActivity: logActivityMock, +})); + +function createAsset() { + const now = new Date("2026-01-01T00:00:00.000Z"); + return { + id: "asset-1", + companyId: "company-1", + provider: "local", + objectKey: "assets/abc", + contentType: "image/svg+xml", + byteSize: 40, + sha256: "sha256-sample", + originalFilename: "logo.svg", + createdByAgentId: null, + createdByUserId: "user-1", + createdAt: now, + updatedAt: now, + }; +} + +function createStorageService(contentType = "image/svg+xml"): StorageService { + const putFile: StorageService["putFile"] = vi.fn(async (input: { + companyId: string; + namespace: string; + originalFilename: string | null; + contentType: string; + body: Buffer; + }) => { + return { + provider: "local_disk" as const, + objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`, + contentType: contentType || input.contentType, + byteSize: input.body.length, + sha256: "sha256-sample", + originalFilename: input.originalFilename, + }; + }); + + return { + provider: "local_disk" as const, + putFile, + getObject: vi.fn(), + headObject: vi.fn(), + deleteObject: vi.fn(), + }; +} + +function createApp(storage: ReturnType) { + const app = express(); + app.use((req, _res, next) => { + req.actor = { + type: "board", + source: "local_implicit", + userId: "user-1", + }; + next(); + }); + app.use("/api", assetRoutes({} as any, storage)); + return app; +} + +describe("POST /api/companies/:companyId/assets/images", () => { + afterEach(() => { + createAssetMock.mockReset(); + getAssetByIdMock.mockReset(); + logActivityMock.mockReset(); + }); + + it("accepts SVG image uploads and returns an asset path", async () => { + const svg = createStorageService("image/svg+xml"); + const app = createApp(svg); + + createAssetMock.mockResolvedValue(createAsset()); + + const res = await request(app) + .post("/api/companies/company-1/assets/images") + .field("namespace", "companies") + .attach("file", Buffer.from(""), "logo.svg"); + + expect(res.status).toBe(201); + expect(res.body.contentPath).toBe("/api/assets/asset-1/content"); + expect(createAssetMock).toHaveBeenCalledTimes(1); + expect(svg.putFile).toHaveBeenCalledWith({ + companyId: "company-1", + namespace: "assets/companies", + originalFilename: "logo.svg", + contentType: "image/svg+xml", + body: expect.any(Buffer), + }); + }); + + it("rejects files larger than 100 KB", async () => { + const app = createApp(createStorageService()); + createAssetMock.mockResolvedValue(createAsset()); + + const file = Buffer.alloc(100 * 1024 + 1, "a"); + const res = await request(app) + .post("/api/companies/company-1/assets/images") + .field("namespace", "companies") + .attach("file", file, "too-large.png"); + + expect(res.status).toBe(422); + expect(res.body.error).toBe("Image exceeds 102400 bytes"); + }); + + it("allows larger non-logo images within the general asset limit", async () => { + const png = createStorageService("image/png"); + const app = createApp(png); + + createAssetMock.mockResolvedValue({ + ...createAsset(), + contentType: "image/png", + originalFilename: "goal.png", + }); + + const file = Buffer.alloc(150 * 1024, "a"); + const res = await request(app) + .post("/api/companies/company-1/assets/images") + .field("namespace", "goals") + .attach("file", file, "goal.png"); + + expect(res.status).toBe(201); + expect(createAssetMock).toHaveBeenCalledTimes(1); + }); + + it("rejects unsupported image types", async () => { + const app = createApp(createStorageService("text/plain")); + createAssetMock.mockResolvedValue(createAsset()); + + const res = await request(app) + .post("/api/companies/company-1/assets/images") + .field("namespace", "companies") + .attach("file", Buffer.from("not an image"), "note.txt"); + + expect(res.status).toBe(422); + expect(res.body.error).toBe("Unsupported image type: text/plain"); + expect(createAssetMock).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index cde29ada..0b1b81cb 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -7,12 +7,14 @@ import { assetService, logActivity } from "../services/index.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; +const MAX_COMPANY_LOGO_BYTES = 100 * 1024; const ALLOWED_IMAGE_CONTENT_TYPES = new Set([ "image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif", + "image/svg+xml", ]); export function assetRoutes(db: Db, storage: StorageService) { @@ -73,6 +75,12 @@ export function assetRoutes(db: Db, storage: StorageService) { } const namespaceSuffix = parsedMeta.data.namespace ?? "general"; + const isCompanyLogoNamespace = namespaceSuffix === "companies" || namespaceSuffix.startsWith("companies/"); + if (isCompanyLogoNamespace && file.buffer.length > MAX_COMPANY_LOGO_BYTES) { + res.status(422).json({ error: `Image exceeds ${MAX_COMPANY_LOGO_BYTES} bytes` }); + return; + } + const actor = getActorInfo(req); const stored = await storage.putFile({ companyId, @@ -150,4 +158,3 @@ export function assetRoutes(db: Db, storage: StorageService) { return router; } - diff --git a/ui/src/api/companies.ts b/ui/src/api/companies.ts index 583d9e69..eb53524b 100644 --- a/ui/src/api/companies.ts +++ b/ui/src/api/companies.ts @@ -14,14 +14,19 @@ export const companiesApi = { list: () => api.get("/companies"), get: (companyId: string) => api.get(`/companies/${companyId}`), stats: () => api.get("/companies/stats"), - create: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => + create: (data: { + name: string; + description?: string | null; + budgetMonthlyCents?: number; + logoUrl?: string | null; + }) => api.post("/companies", data), update: ( companyId: string, data: Partial< Pick< Company, - "name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor" + "name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor" | "logoUrl" > >, ) => api.patch(`/companies/${companyId}`, data), diff --git a/ui/src/components/CompanyPatternIcon.tsx b/ui/src/components/CompanyPatternIcon.tsx index c7e5acc3..6ea40788 100644 --- a/ui/src/components/CompanyPatternIcon.tsx +++ b/ui/src/components/CompanyPatternIcon.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { cn } from "../lib/utils"; const BAYER_4X4 = [ @@ -10,6 +10,7 @@ const BAYER_4X4 = [ interface CompanyPatternIconProps { companyName: string; + logoUrl?: string | null; brandColor?: string | null; className?: string; } @@ -159,8 +160,18 @@ function makeCompanyPatternDataUrl(seed: string, brandColor?: string | null, log return canvas.toDataURL("image/png"); } -export function CompanyPatternIcon({ companyName, brandColor, className }: CompanyPatternIconProps) { +export function CompanyPatternIcon({ + companyName, + logoUrl, + brandColor, + className, +}: CompanyPatternIconProps) { const initial = companyName.trim().charAt(0).toUpperCase() || "?"; + const [imageError, setImageError] = useState(false); + const logo = !imageError && typeof logoUrl === "string" && logoUrl.trim().length > 0 ? logoUrl : null; + useEffect(() => { + setImageError(false); + }, [logoUrl]); const patternDataUrl = useMemo( () => makeCompanyPatternDataUrl(companyName.trim().toLowerCase(), brandColor), [companyName, brandColor], @@ -173,7 +184,14 @@ export function CompanyPatternIcon({ companyName, brandColor, className }: Compa className, )} > - {patternDataUrl ? ( + {logo ? ( + {`${companyName} setImageError(true)} + className="absolute inset-0 h-full w-full object-cover" + /> + ) : patternDataUrl ? ( )} - - {initial} - + {!logo && ( + + {initial} + + )}
); } diff --git a/ui/src/components/CompanyRail.tsx b/ui/src/components/CompanyRail.tsx index 62a8bf3e..46a6d155 100644 --- a/ui/src/components/CompanyRail.tsx +++ b/ui/src/components/CompanyRail.tsx @@ -121,6 +121,7 @@ function SortableCompanyItem({ > Promise; } @@ -86,7 +87,12 @@ export function CompanyProvider({ children }: { children: ReactNode }) { }, [queryClient]); const createMutation = useMutation({ - mutationFn: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => + mutationFn: (data: { + name: string; + description?: string | null; + budgetMonthlyCents?: number; + logoUrl?: string | null; + }) => companiesApi.create(data), onSuccess: (company) => { queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); @@ -95,7 +101,12 @@ export function CompanyProvider({ children }: { children: ReactNode }) { }); const createCompany = useCallback( - async (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => { + async (data: { + name: string; + description?: string | null; + budgetMonthlyCents?: number; + logoUrl?: string | null; + }) => { return createMutation.mutateAsync(data); }, [createMutation], diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 0b9cd255..7dd51da0 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -1,9 +1,10 @@ -import { useEffect, useState } from "react"; +import { ChangeEvent, useEffect, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { companiesApi } from "../api/companies"; import { accessApi } from "../api/access"; +import { assetsApi } from "../api/assets"; import { queryKeys } from "../lib/queryKeys"; import { Button } from "@/components/ui/button"; import { Settings, Check } from "lucide-react"; @@ -34,6 +35,8 @@ export function CompanySettings() { const [companyName, setCompanyName] = useState(""); const [description, setDescription] = useState(""); const [brandColor, setBrandColor] = useState(""); + const [logoUrl, setLogoUrl] = useState(""); + const [logoUploadError, setLogoUploadError] = useState(null); // Sync local state from selected company useEffect(() => { @@ -41,6 +44,7 @@ export function CompanySettings() { setCompanyName(selectedCompany.name); setDescription(selectedCompany.description ?? ""); setBrandColor(selectedCompany.brandColor ?? ""); + setLogoUrl(selectedCompany.logoUrl ?? ""); }, [selectedCompany]); const [inviteError, setInviteError] = useState(null); @@ -130,6 +134,46 @@ export function CompanySettings() { } }); + const syncLogoState = (nextLogoUrl: string | null) => { + setLogoUrl(nextLogoUrl ?? ""); + void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); + }; + + const logoUploadMutation = useMutation({ + mutationFn: (file: File) => + assetsApi + .uploadImage(selectedCompanyId!, file, "companies") + .then((asset) => companiesApi.update(selectedCompanyId!, { logoUrl: asset.contentPath })), + onSuccess: (company) => { + syncLogoState(company.logoUrl); + setLogoUploadError(null); + } + }); + + const clearLogoMutation = useMutation({ + mutationFn: () => companiesApi.update(selectedCompanyId!, { logoUrl: null }), + onSuccess: (company) => { + setLogoUploadError(null); + syncLogoState(company.logoUrl); + } + }); + + function handleLogoFileChange(event: ChangeEvent) { + const file = event.target.files?.[0] ?? null; + event.currentTarget.value = ""; + if (!file) return; + if (file.size >= 100 * 1024) { + setLogoUploadError("Logo image must be smaller than 100 KB."); + return; + } + setLogoUploadError(null); + logoUploadMutation.mutate(file); + } + + function handleClearLogo() { + clearLogoMutation.mutate(); + } + useEffect(() => { setInviteError(null); setInviteSnippet(null); @@ -226,11 +270,53 @@ export function CompanySettings() {
-
+
+ +
+ + {logoUrl && ( +
+ +
+ )} + {(logoUploadMutation.isError || logoUploadError) && ( + + {logoUploadError ?? + (logoUploadMutation.error instanceof Error + ? logoUploadMutation.error.message + : "Logo upload failed")} + + )} + {clearLogoMutation.isError && ( + + {clearLogoMutation.error.message} + + )} + {logoUploadMutation.isPending && ( + Uploading logo... + )} +
+
- {generalMutation.error instanceof Error - ? generalMutation.error.message - : "Failed to save"} + {generalMutation.error.message} )}
From 1448b55ca42509c186f38b942994bb3e4337c4e7 Mon Sep 17 00:00:00 2001 From: JonCSykes Date: Fri, 6 Mar 2026 16:47:04 -0500 Subject: [PATCH 003/874] Improve error handling in CompanySettings for mutation failure messages. --- ui/src/pages/CompanySettings.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 7dd51da0..8d68fcff 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -372,7 +372,9 @@ export function CompanySettings() { )} {generalMutation.isError && ( - {generalMutation.error.message} + {generalMutation.error instanceof Error + ? generalMutation.error.message + : "Failed to save"} )}
From a4702e48f976351f8ca0f3d29a41bba9564cf011 Mon Sep 17 00:00:00 2001 From: JonCSykes Date: Fri, 6 Mar 2026 17:18:43 -0500 Subject: [PATCH 004/874] Add sanitization for SVG uploads and enhance security headers for asset responses - Introduced SVG sanitization using `dompurify` to prevent malicious content. - Updated tests to validate SVG sanitization with various scenarios. - Enhanced response headers for assets, adding CSP and nosniff for SVGs. - Adjusted UI to better clarify supported file types for logo uploads. - Updated dependencies to include `jsdom` and `dompurify`. --- docs/api/companies.md | 2 +- pnpm-lock.yaml | 429 +++++++++++++++++++++++++++- server/package.json | 5 +- server/src/__tests__/assets.test.ts | 68 ++++- server/src/routes/assets.ts | 93 +++++- ui/src/pages/CompanySettings.tsx | 4 +- 6 files changed, 569 insertions(+), 32 deletions(-) diff --git a/docs/api/companies.md b/docs/api/companies.md index 4b92fe1e..efcac8c2 100644 --- a/docs/api/companies.md +++ b/docs/api/companies.md @@ -58,7 +58,7 @@ Valid image content types: - `image/jpg` - `image/webp` - `image/gif` -- `image/svg+xml` (`.svg`) +- `image/svg+xml` ## Archive Company diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9b5f3e0..053c76c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) cli: dependencies: @@ -126,9 +126,6 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: - '@types/node': - specifier: ^24.6.0 - version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -191,7 +188,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) packages/shared: dependencies: @@ -234,10 +231,13 @@ importers: version: link:../packages/shared better-auth: specifier: 1.4.18 - version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)) detect-port: specifier: ^2.1.0 version: 2.1.0 + dompurify: + specifier: ^3.3.2 + version: 3.3.2 dotenv: specifier: ^17.0.1 version: 17.3.1 @@ -250,6 +250,9 @@ importers: express: specifier: ^5.1.0 version: 5.2.1 + jsdom: + specifier: ^28.1.0 + version: 28.1.0(@noble/hashes@2.0.1) multer: specifier: ^2.0.2 version: 2.0.2 @@ -278,6 +281,9 @@ importers: '@types/express-serve-static-core': specifier: ^5.0.0 version: 5.1.1 + '@types/jsdom': + specifier: ^28.0.0 + version: 28.0.0 '@types/multer': specifier: ^2.0.0 version: 2.0.0 @@ -304,7 +310,7 @@ importers: version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) ui: dependencies: @@ -410,10 +416,23 @@ importers: version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -689,6 +708,10 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@changesets/apply-release-plan@7.1.0': resolution: {integrity: sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==} @@ -855,6 +878,37 @@ packages: react: ^16.8.0 || ^17 || ^18 || ^19 react-dom: ^16.8.0 || ^17 || ^18 || ^19 + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': + resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1383,6 +1437,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -2825,6 +2888,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/jsdom@28.0.0': + resolution: {integrity: sha512-A8TBQQC/xAOojy9kM8E46cqT00sF0h7dWjV8t8BJhUi2rG6JRh7XXQo/oLoENuZIQEpXsxLccLCnknyQd7qssQ==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -2875,6 +2941,12 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -2940,6 +3012,10 @@ packages: resolution: {integrity: sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==} engines: {node: '>= 16.0.0'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + anser@2.3.5: resolution: {integrity: sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==} @@ -3070,6 +3146,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -3232,11 +3311,19 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -3244,6 +3331,10 @@ packages: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -3256,6 +3347,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -3320,6 +3414,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dompurify@3.3.2: + resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} + engines: {node: '>=20'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -3458,6 +3556,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -3700,6 +3802,10 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -3707,6 +3813,14 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-id@4.1.3: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true @@ -3778,6 +3892,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -3824,6 +3941,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3940,6 +4066,10 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -4019,6 +4149,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -4270,6 +4403,12 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -4422,6 +4561,10 @@ packages: pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -4567,6 +4710,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -4611,6 +4758,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -4766,6 +4917,9 @@ packages: resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} engines: {node: '>=14.18.0'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -4808,6 +4962,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.25: + resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} + + tldts@7.0.25: + resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4816,6 +4977,14 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4855,6 +5024,13 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.22.0: + resolution: {integrity: sha512-RKZvifiL60xdsIuC80UY0dq8Z7DbJUV8/l2hOVbyZAxBzEeQU4Z58+4ZzJ6WN2Lidi9KzT5EbiGX+PI/UGYuRw==} + + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + unidiff@1.0.4: resolution: {integrity: sha512-ynU0vsAXw0ir8roa+xPCUHmnJ5goc5BTM2Kuc3IJd8UwgaeRs7VSD5+eeaQL+xp1JtB92hu/Zy/Lgy7RZcr1pQ==} @@ -5052,6 +5228,22 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -5081,6 +5273,13 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -5103,6 +5302,26 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.6 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -5731,6 +5950,10 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@changesets/apply-release-plan@7.1.0': dependencies: '@changesets/config': 3.1.3 @@ -6179,6 +6402,28 @@ snapshots: react-dom: 19.2.4(react@19.2.4) react-is: 17.0.2 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': {} + + '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': dependencies: react: 19.2.4 @@ -6465,6 +6710,10 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@exodus/bytes@1.15.0(@noble/hashes@2.0.1)': + optionalDependencies: + '@noble/hashes': 2.0.1 + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -8209,6 +8458,13 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/jsdom@28.0.0': + dependencies: + '@types/node': 25.2.3 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + undici-types: 7.22.0 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -8268,6 +8524,11 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -8353,6 +8614,8 @@ snapshots: address@2.0.3: {} + agent-base@7.1.4: {} + anser@2.3.5: {} ansi-colors@4.1.3: {} @@ -8389,7 +8652,7 @@ snapshots: baseline-browser-mapping@2.9.19: {} - better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) @@ -8409,7 +8672,7 @@ snapshots: pg: 8.18.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) better-call@1.1.8(zod@4.3.6): dependencies: @@ -8424,6 +8687,10 @@ snapshots: dependencies: is-windows: 1.0.2 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -8587,8 +8854,20 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@csstools/css-syntax-patches-for-csstree': 1.1.0 + css-tree: 3.2.1 + lru-cache: 11.2.6 + csstype@3.2.3: {} d@1.0.2: @@ -8596,12 +8875,21 @@ snapshots: es5-ext: 0.10.64 type: 2.7.3 + data-urls@7.0.0(@noble/hashes@2.0.1): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.0.1) + transitivePeerDependencies: + - '@noble/hashes' + dateformat@4.6.3: {} debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -8650,6 +8938,10 @@ snapshots: dependencies: path-type: 4.0.0 + dompurify@3.3.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@16.6.1: {} dotenv@17.3.1: {} @@ -8723,6 +9015,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@6.0.1: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -9092,6 +9386,12 @@ snapshots: help-me@5.0.0: {} + html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + transitivePeerDependencies: + - '@noble/hashes' + html-url-attributes@3.0.1: {} http-errors@2.0.1: @@ -9102,6 +9402,20 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-id@4.1.3: {} iconv-lite@0.7.2: @@ -9149,6 +9463,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-subdir@1.2.0: @@ -9184,6 +9500,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@28.1.0(@noble/hashes@2.0.1): + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + cssstyle: 6.2.0 + data-urls: 7.0.0(@noble/hashes@2.0.1) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + undici: 7.22.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.0.1) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json5@2.2.3: {} @@ -9265,6 +9608,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@11.2.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -9475,6 +9820,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + media-typer@0.3.0: {} media-typer@1.1.0: {} @@ -9881,6 +10228,14 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parse5@8.0.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -10034,6 +10389,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -10250,6 +10607,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -10315,6 +10674,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} secure-json-parse@4.1.0: {} @@ -10490,6 +10853,8 @@ snapshots: transitivePeerDependencies: - supports-color + symbol-tree@3.2.4: {} + tabbable@6.4.0: {} tailwind-merge@3.4.1: {} @@ -10519,12 +10884,26 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@7.0.25: {} + + tldts@7.0.25: + dependencies: + tldts-core: 7.0.25 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.25 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -10559,6 +10938,10 @@ snapshots: undici-types@7.16.0: {} + undici-types@7.22.0: {} + + undici@7.22.0: {} + unidiff@1.0.4: dependencies: diff: 5.2.2 @@ -10752,7 +11135,7 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -10780,6 +11163,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.12.0 + jsdom: 28.1.0(@noble/hashes@2.0.1) transitivePeerDependencies: - jiti - less @@ -10794,7 +11178,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -10822,6 +11206,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 25.2.3 + jsdom: 28.1.0(@noble/hashes@2.0.1) transitivePeerDependencies: - jiti - less @@ -10838,6 +11223,22 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.0.1): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -10856,6 +11257,10 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: {} yallist@3.1.1: {} diff --git a/server/package.json b/server/package.json index 2e470111..cf68566b 100644 --- a/server/package.json +++ b/server/package.json @@ -34,17 +34,19 @@ "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", - "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/shared": "workspace:*", "better-auth": "1.4.18", "detect-port": "^2.1.0", + "dompurify": "^3.3.2", "dotenv": "^17.0.1", "drizzle-orm": "^0.38.4", "embedded-postgres": "^18.1.0-beta.16", "express": "^5.1.0", + "jsdom": "^28.1.0", "multer": "^2.0.2", "open": "^11.0.0", "pino": "^9.6.0", @@ -56,6 +58,7 @@ "devDependencies": { "@types/express": "^5.0.0", "@types/express-serve-static-core": "^5.0.0", + "@types/jsdom": "^28.0.0", "@types/multer": "^2.0.0", "@types/node": "^24.6.0", "@types/supertest": "^6.0.2", diff --git a/server/src/__tests__/assets.test.ts b/server/src/__tests__/assets.test.ts index eb58e24a..85ddf56a 100644 --- a/server/src/__tests__/assets.test.ts +++ b/server/src/__tests__/assets.test.ts @@ -25,10 +25,10 @@ function createAsset() { companyId: "company-1", provider: "local", objectKey: "assets/abc", - contentType: "image/svg+xml", + contentType: "image/png", byteSize: 40, sha256: "sha256-sample", - originalFilename: "logo.svg", + originalFilename: "logo.png", createdByAgentId: null, createdByUserId: "user-1", createdAt: now, @@ -36,7 +36,7 @@ function createAsset() { }; } -function createStorageService(contentType = "image/svg+xml"): StorageService { +function createStorageService(contentType = "image/png"): StorageService { const putFile: StorageService["putFile"] = vi.fn(async (input: { companyId: string; namespace: string; @@ -84,29 +84,63 @@ describe("POST /api/companies/:companyId/assets/images", () => { logActivityMock.mockReset(); }); - it("accepts SVG image uploads and returns an asset path", async () => { - const svg = createStorageService("image/svg+xml"); - const app = createApp(svg); + it("accepts PNG image uploads and returns an asset path", async () => { + const png = createStorageService("image/png"); + const app = createApp(png); createAssetMock.mockResolvedValue(createAsset()); const res = await request(app) .post("/api/companies/company-1/assets/images") .field("namespace", "companies") - .attach("file", Buffer.from(""), "logo.svg"); + .attach("file", Buffer.from("png"), "logo.png"); expect(res.status).toBe(201); expect(res.body.contentPath).toBe("/api/assets/asset-1/content"); expect(createAssetMock).toHaveBeenCalledTimes(1); - expect(svg.putFile).toHaveBeenCalledWith({ + expect(png.putFile).toHaveBeenCalledWith({ companyId: "company-1", namespace: "assets/companies", - originalFilename: "logo.svg", - contentType: "image/svg+xml", + originalFilename: "logo.png", + contentType: "image/png", body: expect.any(Buffer), }); }); + it("sanitizes SVG image uploads before storing them", async () => { + const svg = createStorageService("image/svg+xml"); + const app = createApp(svg); + + createAssetMock.mockResolvedValue({ + ...createAsset(), + contentType: "image/svg+xml", + originalFilename: "logo.svg", + }); + + const res = await request(app) + .post("/api/companies/company-1/assets/images") + .field("namespace", "companies") + .attach( + "file", + Buffer.from( + "", + ), + "logo.svg", + ); + + expect(res.status).toBe(201); + expect(svg.putFile).toHaveBeenCalledTimes(1); + const stored = (svg.putFile as ReturnType).mock.calls[0]?.[0]; + expect(stored.contentType).toBe("image/svg+xml"); + expect(stored.originalFilename).toBe("logo.svg"); + const body = stored.body.toString("utf8"); + expect(body).toContain(" { const app = createApp(createStorageService()); createAssetMock.mockResolvedValue(createAsset()); @@ -154,4 +188,18 @@ describe("POST /api/companies/:companyId/assets/images", () => { expect(res.body.error).toBe("Unsupported image type: text/plain"); expect(createAssetMock).not.toHaveBeenCalled(); }); + + it("rejects SVG image uploads that cannot be sanitized", async () => { + const app = createApp(createStorageService("image/svg+xml")); + createAssetMock.mockResolvedValue(createAsset()); + + const res = await request(app) + .post("/api/companies/company-1/assets/images") + .field("namespace", "companies") + .attach("file", Buffer.from("not actually svg"), "logo.svg"); + + expect(res.status).toBe(422); + expect(res.body.error).toBe("SVG could not be sanitized"); + expect(createAssetMock).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index 0b1b81cb..7af319e0 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -1,5 +1,7 @@ import { Router, type Request, type Response } from "express"; import multer from "multer"; +import createDOMPurify from "dompurify"; +import { JSDOM } from "jsdom"; import type { Db } from "@paperclipai/db"; import { createAssetImageMetadataSchema } from "@paperclipai/shared"; import type { StorageService } from "../storage/types.js"; @@ -8,15 +10,80 @@ import { assertCompanyAccess, getActorInfo } from "./authz.js"; const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; const MAX_COMPANY_LOGO_BYTES = 100 * 1024; +const SVG_CONTENT_TYPE = "image/svg+xml"; const ALLOWED_IMAGE_CONTENT_TYPES = new Set([ "image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif", - "image/svg+xml", + SVG_CONTENT_TYPE, ]); +function sanitizeSvgBuffer(input: Buffer): Buffer | null { + const raw = input.toString("utf8").trim(); + if (!raw) return null; + + const baseDom = new JSDOM(""); + const domPurify = createDOMPurify( + baseDom.window as unknown as Parameters[0], + ); + domPurify.addHook("uponSanitizeAttribute", (_node, data) => { + const attrName = data.attrName.toLowerCase(); + const attrValue = (data.attrValue ?? "").trim(); + + if (attrName.startsWith("on")) { + data.keepAttr = false; + return; + } + + if ((attrName === "href" || attrName === "xlink:href") && attrValue && !attrValue.startsWith("#")) { + data.keepAttr = false; + } + }); + + let parsedDom: JSDOM | null = null; + try { + const sanitized = domPurify.sanitize(raw, { + USE_PROFILES: { svg: true, svgFilters: true, html: false }, + FORBID_TAGS: ["script", "foreignObject"], + FORBID_CONTENTS: ["script", "foreignObject"], + RETURN_TRUSTED_TYPE: false, + }); + + parsedDom = new JSDOM(sanitized, { contentType: SVG_CONTENT_TYPE }); + const document = parsedDom.window.document; + const root = document.documentElement; + if (!root || root.tagName.toLowerCase() !== "svg") return null; + + for (const el of Array.from(root.querySelectorAll("script, foreignObject"))) { + el.remove(); + } + for (const el of Array.from(root.querySelectorAll("*"))) { + for (const attr of Array.from(el.attributes)) { + const attrName = attr.name.toLowerCase(); + const attrValue = attr.value.trim(); + if (attrName.startsWith("on")) { + el.removeAttribute(attr.name); + continue; + } + if ((attrName === "href" || attrName === "xlink:href") && attrValue && !attrValue.startsWith("#")) { + el.removeAttribute(attr.name); + } + } + } + + const output = root.outerHTML.trim(); + if (!output || !/^]/i.test(output)) return null; + return Buffer.from(output, "utf8"); + } catch { + return null; + } finally { + parsedDom?.window.close(); + baseDom.window.close(); + } +} + export function assetRoutes(db: Db, storage: StorageService) { const router = Router(); const svc = assetService(db); @@ -58,12 +125,21 @@ export function assetRoutes(db: Db, storage: StorageService) { return; } - const contentType = (file.mimetype || "").toLowerCase(); + let contentType = (file.mimetype || "").toLowerCase(); if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) { res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` }); return; } - if (file.buffer.length <= 0) { + let fileBody = file.buffer; + if (contentType === SVG_CONTENT_TYPE) { + const sanitized = sanitizeSvgBuffer(file.buffer); + if (!sanitized || sanitized.length <= 0) { + res.status(422).json({ error: "SVG could not be sanitized" }); + return; + } + fileBody = sanitized; + } + if (fileBody.length <= 0) { res.status(422).json({ error: "Image is empty" }); return; } @@ -76,7 +152,7 @@ export function assetRoutes(db: Db, storage: StorageService) { const namespaceSuffix = parsedMeta.data.namespace ?? "general"; const isCompanyLogoNamespace = namespaceSuffix === "companies" || namespaceSuffix.startsWith("companies/"); - if (isCompanyLogoNamespace && file.buffer.length > MAX_COMPANY_LOGO_BYTES) { + if (isCompanyLogoNamespace && fileBody.length > MAX_COMPANY_LOGO_BYTES) { res.status(422).json({ error: `Image exceeds ${MAX_COMPANY_LOGO_BYTES} bytes` }); return; } @@ -87,7 +163,7 @@ export function assetRoutes(db: Db, storage: StorageService) { namespace: `assets/${namespaceSuffix}`, originalFilename: file.originalname || null, contentType, - body: file.buffer, + body: fileBody, }); const asset = await svc.create(companyId, { @@ -144,9 +220,14 @@ export function assetRoutes(db: Db, storage: StorageService) { assertCompanyAccess(req, asset.companyId); const object = await storage.getObject(asset.companyId, asset.objectKey); - res.setHeader("Content-Type", asset.contentType || object.contentType || "application/octet-stream"); + const responseContentType = asset.contentType || object.contentType || "application/octet-stream"; + res.setHeader("Content-Type", responseContentType); res.setHeader("Content-Length", String(asset.byteSize || object.contentLength || 0)); res.setHeader("Cache-Control", "private, max-age=60"); + res.setHeader("X-Content-Type-Options", "nosniff"); + if (responseContentType === SVG_CONTENT_TYPE) { + res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + } const filename = asset.originalFilename ?? "asset"; res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`); diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 8d68fcff..cf174c9d 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -278,12 +278,12 @@ export function CompanySettings() {
From f44efce2658484829ae96b60a38df01b946f8dd2 Mon Sep 17 00:00:00 2001 From: JonCSykes Date: Fri, 6 Mar 2026 23:15:45 -0500 Subject: [PATCH 005/874] Add `@types/node` as a devDependency in cursor-local package --- packages/adapters/cursor-local/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/adapters/cursor-local/package.json b/packages/adapters/cursor-local/package.json index 575f9e1b..4ef66052 100644 --- a/packages/adapters/cursor-local/package.json +++ b/packages/adapters/cursor-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 053c76c5..2a6c5003 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 From 5114c32810c7957e5126e073dc7c5e6dcf6820a7 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 7 Mar 2026 19:01:04 +0000 Subject: [PATCH 006/874] Fix opencode-local adapter: parser, UI, CLI, and environment tests - Move costUsd to top-level return field in parseOpenCodeJsonl (out of usage) - Fix session-not-found regex to match "Session not found" pattern - Use callID for toolUseId in UI stdout parser, add status/metadata header - Fix CLI formatter: separate tool_call/tool_result lines, split step_finish - Enable createIfMissing for cwd validation in environment tests - Add empty OPENAI_API_KEY override detection - Classify ProviderModelNotFoundError as warning during model discovery - Make model discovery best-effort when no model is configured Co-Authored-By: Claude Opus 4.6 --- .../opencode-local/src/cli/format-event.ts | 26 +++--- .../opencode-local/src/server/execute.ts | 2 +- .../opencode-local/src/server/parse.test.ts | 2 +- .../opencode-local/src/server/parse.ts | 7 +- .../opencode-local/src/server/test.ts | 83 +++++++++++++++---- .../opencode-local/src/ui/parse-stdout.ts | 15 +++- 6 files changed, 100 insertions(+), 35 deletions(-) diff --git a/packages/adapters/opencode-local/src/cli/format-event.ts b/packages/adapters/opencode-local/src/cli/format-event.ts index 00d0ec76..58d2038d 100644 --- a/packages/adapters/opencode-local/src/cli/format-event.ts +++ b/packages/adapters/opencode-local/src/cli/format-event.ts @@ -74,20 +74,25 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void { if (type === "tool_use") { const part = asRecord(parsed.part); const tool = asString(part?.tool, "tool"); + const callID = asString(part?.callID); const state = asRecord(part?.state); const status = asString(state?.status); - const summary = `tool_${status || "event"}: ${tool}`; const isError = status === "error"; - console.log((isError ? pc.red : pc.yellow)(summary)); - const input = state?.input; - if (input !== undefined) { - try { - console.log(pc.gray(JSON.stringify(input, null, 2))); - } catch { - console.log(pc.gray(String(input))); + const metadata = asRecord(state?.metadata); + + console.log(pc.yellow(`tool_call: ${tool}${callID ? ` (${callID})` : ""}`)); + + if (status) { + const metaParts = [`status=${status}`]; + if (metadata) { + for (const [key, value] of Object.entries(metadata)) { + if (value !== undefined && value !== null) metaParts.push(`${key}=${value}`); + } } + console.log((isError ? pc.red : pc.gray)(`tool_result ${metaParts.join(" ")}`)); } - const output = asString(state?.output) || asString(state?.error); + + const output = (asString(state?.output) || asString(state?.error)).trim(); if (output) console.log((isError ? pc.red : pc.gray)(output)); return; } @@ -101,7 +106,8 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void { const cached = asNumber(cache?.read, 0); const cost = asNumber(part?.cost, 0); const reason = asString(part?.reason, "step"); - console.log(pc.blue(`step finished (${reason}) tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`)); + console.log(pc.blue(`step finished: reason=${reason}`)); + console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`)); return; } diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 338646b3..970896af 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -340,7 +340,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { inputTokens: 120, cachedInputTokens: 20, outputTokens: 50, - costUsd: 0.0025, }); + expect(parsed.costUsd).toBeCloseTo(0.0025, 6); expect(parsed.errorMessage).toContain("model unavailable"); }); diff --git a/packages/adapters/opencode-local/src/server/parse.ts b/packages/adapters/opencode-local/src/server/parse.ts index 5cbfa46c..96af0ed1 100644 --- a/packages/adapters/opencode-local/src/server/parse.ts +++ b/packages/adapters/opencode-local/src/server/parse.ts @@ -27,8 +27,8 @@ export function parseOpenCodeJsonl(stdout: string) { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, - costUsd: 0, }; + let costUsd = 0; for (const rawLine of stdout.split(/\r?\n/)) { const line = rawLine.trim(); @@ -56,7 +56,7 @@ export function parseOpenCodeJsonl(stdout: string) { usage.inputTokens += asNumber(tokens.input, 0); usage.cachedInputTokens += asNumber(cache.read, 0); usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0); - usage.costUsd += asNumber(part.cost, 0); + costUsd += asNumber(part.cost, 0); continue; } @@ -81,6 +81,7 @@ export function parseOpenCodeJsonl(stdout: string) { sessionId, summary: messages.join("\n\n").trim(), usage, + costUsd, errorMessage: errors.length > 0 ? errors.join("\n") : null, }; } @@ -92,7 +93,7 @@ export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): b .filter(Boolean) .join("\n"); - return /unknown\s+session|session\s+.*\s+not\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test( + return /unknown\s+session|session\b.*\bnot\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test( haystack, ); } diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 569f0d75..ac40d456 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -59,7 +59,7 @@ export async function testEnvironment( const cwd = asString(config.cwd, process.cwd()); try { - await ensureAbsoluteDirectory(cwd, { createIfMissing: false }); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); checks.push({ code: "opencode_cwd_valid", level: "info", @@ -79,6 +79,17 @@ export async function testEnvironment( for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } + + const openaiKeyOverride = "OPENAI_API_KEY" in envConfig ? asString(envConfig.OPENAI_API_KEY, "") : null; + if (openaiKeyOverride !== null && openaiKeyOverride.trim() === "") { + checks.push({ + code: "opencode_openai_api_key_missing", + level: "warn", + message: "OPENAI_API_KEY override is empty.", + hint: "The OPENAI_API_KEY override is empty. Set a valid key or remove the override.", + }); + } + const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); @@ -111,7 +122,9 @@ export async function testEnvironment( checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); let modelValidationPassed = false; - if (canRunProbe) { + const configuredModel = asString(config.model, "").trim(); + + if (canRunProbe && configuredModel) { try { const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); if (discovered.length > 0) { @@ -129,24 +142,52 @@ export async function testEnvironment( }); } } catch (err) { - checks.push({ - code: "opencode_models_discovery_failed", - level: "error", - message: err instanceof Error ? err.message : "OpenCode model discovery failed.", - hint: "Run `opencode models` manually to verify provider auth and config.", - }); + const errMsg = err instanceof Error ? err.message : String(err); + if (/ProviderModelNotFoundError/i.test(errMsg)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + detail: errMsg, + hint: "Run `opencode models` and choose an available provider/model ID.", + }); + } else { + checks.push({ + code: "opencode_models_discovery_failed", + level: "error", + message: errMsg || "OpenCode model discovery failed.", + hint: "Run `opencode models` manually to verify provider auth and config.", + }); + } + } + } else if (canRunProbe && !configuredModel) { + try { + const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); + if (discovered.length > 0) { + checks.push({ + code: "opencode_models_discovered", + level: "info", + message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, + }); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + if (/ProviderModelNotFoundError/i.test(errMsg)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + detail: errMsg, + hint: "Run `opencode models` and choose an available provider/model ID.", + }); + } } } - const configuredModel = asString(config.model, "").trim(); - if (!configuredModel) { - checks.push({ - code: "opencode_model_required", - level: "error", - message: "OpenCode requires a configured model in provider/model format.", - hint: "Set adapterConfig.model using an ID from `opencode models`.", - }); - } else if (canRunProbe) { + const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable"); + if (!configuredModel && !modelUnavailable) { + // No model configured – skip model requirement if no model-related checks exist + } else if (configuredModel && canRunProbe) { try { await ensureOpenCodeModelConfiguredAndAvailable({ model: configuredModel, @@ -226,6 +267,14 @@ export async function testEnvironment( hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", }), }); + } else if (/ProviderModelNotFoundError/i.test(authEvidence)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + ...(detail ? { detail } : {}), + hint: "Run `opencode models` and choose an available provider/model ID.", + }); } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { checks.push({ code: "opencode_hello_probe_auth_required", diff --git a/packages/adapters/opencode-local/src/ui/parse-stdout.ts b/packages/adapters/opencode-local/src/ui/parse-stdout.ts index dc48e5d1..2060125a 100644 --- a/packages/adapters/opencode-local/src/ui/parse-stdout.ts +++ b/packages/adapters/opencode-local/src/ui/parse-stdout.ts @@ -56,19 +56,28 @@ function parseToolUse(parsed: Record, ts: string): TranscriptEn const status = asString(state?.status); if (status !== "completed" && status !== "error") return [callEntry]; - const output = + const rawOutput = asString(state?.output) || asString(state?.error) || asString(part.title) || `${toolName} ${status}`; + const metadata = asRecord(state?.metadata); + const headerParts: string[] = [`status: ${status}`]; + if (metadata) { + for (const [key, value] of Object.entries(metadata)) { + if (value !== undefined && value !== null) headerParts.push(`${key}: ${value}`); + } + } + const content = `${headerParts.join("\n")}\n\n${rawOutput}`.trim(); + return [ callEntry, { kind: "tool_result", ts, - toolUseId: asString(part.id, toolName), - content: output, + toolUseId: asString(part.callID) || asString(part.id, toolName), + content, isError: status === "error", }, ]; From fa7acd2482631a417a0a74f189737eb89482790e Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 7 Mar 2026 14:12:22 -0500 Subject: [PATCH 007/874] Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- packages/adapters/opencode-local/src/server/test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index ac40d456..8c203266 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -59,7 +59,7 @@ export async function testEnvironment( const cwd = asString(config.cwd, process.cwd()); try { - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + await ensureAbsoluteDirectory(cwd, { createIfMissing: false }); checks.push({ code: "opencode_cwd_valid", level: "info", From fb684f25e93be97d5dc10b90a6a1fe3fb25336d2 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 7 Mar 2026 19:15:10 +0000 Subject: [PATCH 008/874] Address PR feedback: keep testEnvironment non-destructive, warn on swallowed errors - Update cwd test to expect an error for missing directories (matches createIfMissing: false accepted from review) - Add warn-level check for non-ProviderModelNotFoundError failures during best-effort model discovery when no model is configured Co-Authored-By: Claude Opus 4.6 --- packages/adapters/opencode-local/src/server/test.ts | 7 +++++++ .../opencode-local-adapter-environment.test.ts | 10 ++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 8c203266..5bb7aa36 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -180,6 +180,13 @@ export async function testEnvironment( detail: errMsg, hint: "Run `opencode models` and choose an available provider/model ID.", }); + } else { + checks.push({ + code: "opencode_models_discovery_failed", + level: "warn", + message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).", + hint: "Run `opencode models` manually to verify provider auth and config.", + }); } } } diff --git a/server/src/__tests__/opencode-local-adapter-environment.test.ts b/server/src/__tests__/opencode-local-adapter-environment.test.ts index c539d771..736dd9f8 100644 --- a/server/src/__tests__/opencode-local-adapter-environment.test.ts +++ b/server/src/__tests__/opencode-local-adapter-environment.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { testEnvironment } from "@paperclipai/adapter-opencode-local/server"; describe("opencode_local environment diagnostics", () => { - it("creates a missing working directory when cwd is absolute", async () => { + it("reports a missing working directory as an error when cwd is absolute", async () => { const cwd = path.join( os.tmpdir(), `paperclip-opencode-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`, @@ -23,11 +23,9 @@ describe("opencode_local environment diagnostics", () => { }, }); - expect(result.checks.some((check) => check.code === "opencode_cwd_valid")).toBe(true); - expect(result.checks.some((check) => check.level === "error")).toBe(false); - const stats = await fs.stat(cwd); - expect(stats.isDirectory()).toBe(true); - await fs.rm(path.dirname(cwd), { recursive: true, force: true }); + expect(result.checks.some((check) => check.code === "opencode_cwd_invalid")).toBe(true); + expect(result.checks.some((check) => check.level === "error")).toBe(true); + expect(result.status).toBe("fail"); }); it("treats an empty OPENAI_API_KEY override as missing", async () => { From ba080cb4dd9f19be1c58d95d0304754fcfc16804 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 19:52:29 -0600 Subject: [PATCH 009/874] Fix stale work section overflowing on mobile in inbox Add min-w-0 and overflow-hidden to the stale work row flex containers so the title truncates properly on narrow screens. Add shrink-0 to identifier and assignee spans to prevent them from being compressed. Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/Inbox.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 25d1da06..2a1e3917 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -841,26 +841,26 @@ export function Inbox() { {staleIssues.map((issue) => (
- + {issue.identifier ?? issue.id.slice(0, 8)} - {issue.title} + {issue.title} {issue.assigneeAgentId && (() => { const name = agentName(issue.assigneeAgentId); return name ? ( ) : ( - + {issue.assigneeAgentId.slice(0, 8)} ); From 7661fae4b3865960d707c09d032f10f35b0e6bca Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 19:55:32 -0600 Subject: [PATCH 010/874] Fix inbox row irregular heights on mobile from unread badge - Give unread dot container fixed h-5 so rows are consistent height regardless of badge presence - Use flex-wrap on mobile so title gets its own line with line-clamp-2 - On sm+ screens, keep single-line truncated layout - Move timestamp to ml-auto with sm:order-last for clean wrapping Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/Inbox.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 2a1e3917..ff72947e 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -898,9 +898,9 @@ export function Inbox() { return (
- + {(isUnread || isFading) && (
); From 6e86f69f95bd9917a58299b48174669f55877455 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:01:16 -0600 Subject: [PATCH 011/874] Unify issue rows with GitHub-style mobile layout across app On mobile, all issue rows now show title first (up to 2 lines), with metadata (icons, identifier, timestamp) on a second line below. Desktop layout is preserved as single-line rows. Affected locations: Inbox recent issues, Inbox stale work, Dashboard recent tasks, and IssuesList. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 225 ++++++++++++++++--------------- ui/src/pages/Dashboard.tsx | 40 +++--- ui/src/pages/Inbox.tsx | 76 ++++++----- 3 files changed, 180 insertions(+), 161 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 6335f02c..4abd2cea 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -608,41 +608,26 @@ export function IssuesList({ - {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} -
-
{ e.preventDefault(); e.stopPropagation(); }}> - onUpdateIssue(issue.id, { status: s })} - /> -
- - {issue.identifier ?? issue.id.slice(0, 8)} + {/* Title line - first on mobile, middle on desktop */} + + {issue.title} - {issue.title} - {(issue.labels ?? []).length > 0 && ( -
- {(issue.labels ?? []).slice(0, 3).map((label) => ( - - {label.name} - - ))} - {(issue.labels ?? []).length > 3 && ( - +{(issue.labels ?? []).length - 3} - )} -
- )} -
+ + {/* Metadata line - second on mobile, first on desktop */} + + {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} + + { e.preventDefault(); e.stopPropagation(); }}> + onUpdateIssue(issue.id, { status: s })} + /> + + + {issue.identifier ?? issue.id.slice(0, 8)} + {liveIssueIds?.has(issue.id) && ( @@ -652,90 +637,116 @@ export function IssuesList({ Live )} -
- { - setAssigneePickerIssueId(open ? issue.id : null); - if (!open) setAssigneeSearch(""); - }} + · + + {formatDate(issue.createdAt)} + + + + {/* Desktop-only trailing content */} + + {(issue.labels ?? []).length > 0 && ( + + {(issue.labels ?? []).slice(0, 3).map((label) => ( + + {label.name} + + ))} + {(issue.labels ?? []).length > 3 && ( + +{(issue.labels ?? []).length - 3} + )} + + )} + { + setAssigneePickerIssueId(open ? issue.id : null); + if (!open) setAssigneeSearch(""); + }} + > + + + + e.stopPropagation()} + onPointerDownOutside={() => setAssigneeSearch("")} > - + setAssigneeSearch(e.target.value)} + autoFocus + /> +
- - e.stopPropagation()} - onPointerDownOutside={() => setAssigneeSearch("")} - > - setAssigneeSearch(e.target.value)} - autoFocus - /> -
- - {(agents ?? []) - .filter((agent) => { - if (!assigneeSearch.trim()) return true; - return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); - }) - .map((agent) => ( - - ))} -
-
- -
- + {(agents ?? []) + .filter((agent) => { + if (!assigneeSearch.trim()) return true; + return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); + }) + .map((agent) => ( + + ))} +
+ + + {formatDate(issue.createdAt)} -
+
))} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 823d57df..e4b8747e 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -313,26 +313,28 @@ export function Dashboard() { -
-
-
- - -
-

- {issue.title} - {issue.assigneeAgentId && (() => { - const name = agentName(issue.assigneeAgentId); - return name - ? - : null; - })()} -

-
- - {timeAgo(issue.updatedAt)} +
+ + {issue.title} + + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.assigneeAgentId && (() => { + const name = agentName(issue.assigneeAgentId); + return name + ? + : null; + })()} + · + + {timeAgo(issue.updatedAt)} +
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index ff72947e..e55b3866 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -841,38 +841,39 @@ export function Inbox() { {staleIssues.map((issue) => (
+ - - - - - {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.title} - {issue.title} - {issue.assigneeAgentId && - (() => { - const name = agentName(issue.assigneeAgentId); - return name ? ( - - ) : ( - - {issue.assigneeAgentId.slice(0, 8)} - - ); - })()} - - updated {timeAgo(issue.updatedAt)} + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.assigneeAgentId && + (() => { + const name = agentName(issue.assigneeAgentId); + return name ? ( + + ) : null; + })()} + · + + updated {timeAgo(issue.updatedAt)} +
); From ce8fe38ffc8f9804a182460ace866b9b98c9a73d Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:04:32 -0600 Subject: [PATCH 012/874] Unify mobile issue row layout across issues, inbox, and dashboard Add PriorityIcon and timeAgo to IssuesList mobile rows to match the pattern used in Inbox and Dashboard. Align Dashboard row padding to match Inbox. All mobile issue rows now show: title (2-line clamp), then priority + status + identifier + relative time. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 6 ++++-- ui/src/pages/Dashboard.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 4abd2cea..498752b4 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -7,6 +7,7 @@ import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { groupBy } from "../lib/groupBy"; import { formatDate, cn } from "../lib/utils"; +import { timeAgo } from "../lib/timeAgo"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { EmptyState } from "./EmptyState"; @@ -619,13 +620,14 @@ export function IssuesList({ {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} + { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} /> - + {issue.identifier ?? issue.id.slice(0, 8)} {liveIssueIds?.has(issue.id) && ( @@ -639,7 +641,7 @@ export function IssuesList({ )} · - {formatDate(issue.createdAt)} + {timeAgo(issue.updatedAt)} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index e4b8747e..23296bba 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -313,7 +313,7 @@ export function Dashboard() {
From a96556b8f4530ad6e8915405f3d8ae855c21b31e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:04:41 -0600 Subject: [PATCH 013/874] Move unread badge inline with metadata row in inbox Moves the unread dot from a separate left column into the metadata line (alongside status/identifier), with an empty placeholder for read items to keep spacing consistent. Reduces left padding on mobile inbox rows to reclaim space. Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/Inbox.tsx | 54 +++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index e55b3866..8b8a52b5 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -897,12 +897,16 @@ export function Inbox() { const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); return ( -
- - {(isUnread || isFading) && ( + + {issue.title} + + + {(isUnread || isFading) ? ( + ) : ( + )} + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + + · + + + {issue.lastExternalCommentAt + ? `commented ${timeAgo(issue.lastExternalCommentAt)}` + : `updated ${timeAgo(issue.updatedAt)}`} + - - - {issue.title} - - - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - - · - - - {issue.lastExternalCommentAt - ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}`} - - - -
+ ); })}
From 45473b3e726c35b5e552c711e37f339fd321ad70 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:07:39 -0600 Subject: [PATCH 014/874] Move scroll-to-bottom button to issue detail and run pages Removed the scroll-to-bottom button from IssuesList (wrong location) and created a shared ScrollToBottom component. Added it to IssueDetail and RunDetail pages. On mobile, the button sits above the bottom nav to avoid overlap. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 29 +------------------- ui/src/components/ScrollToBottom.tsx | 40 ++++++++++++++++++++++++++++ ui/src/pages/AgentDetail.tsx | 2 ++ ui/src/pages/IssueDetail.tsx | 2 ++ 4 files changed, 45 insertions(+), 28 deletions(-) create mode 100644 ui/src/components/ScrollToBottom.tsx diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 498752b4..e9f7ac8d 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -18,7 +18,7 @@ import { Input } from "@/components/ui/input"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; -import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search, ArrowDown } from "lucide-react"; +import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react"; import { KanbanBoard } from "./KanbanBoard"; import type { Issue } from "@paperclipai/shared"; @@ -234,24 +234,6 @@ export function IssuesList({ const activeFilterCount = countActiveFilters(viewState); - const [showScrollBottom, setShowScrollBottom] = useState(false); - useEffect(() => { - const el = document.getElementById("main-content"); - if (!el) return; - const check = () => { - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - setShowScrollBottom(distanceFromBottom > 300); - }; - check(); - el.addEventListener("scroll", check, { passive: true }); - return () => el.removeEventListener("scroll", check); - }, [filtered.length]); - - const scrollToBottom = useCallback(() => { - const el = document.getElementById("main-content"); - if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); - }, []); - const groupedContent = useMemo(() => { if (viewState.groupBy === "none") { return [{ key: "__all", label: null as string | null, items: filtered }]; @@ -755,15 +737,6 @@ export function IssuesList({ )) )} - {showScrollBottom && ( - - )}
); } diff --git a/ui/src/components/ScrollToBottom.tsx b/ui/src/components/ScrollToBottom.tsx new file mode 100644 index 00000000..4ea8a494 --- /dev/null +++ b/ui/src/components/ScrollToBottom.tsx @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useState } from "react"; +import { ArrowDown } from "lucide-react"; + +/** + * Floating scroll-to-bottom button that appears when the user is far from the + * bottom of the `#main-content` scroll container. Hides when within 300px of + * the bottom. Positioned to avoid the mobile bottom nav. + */ +export function ScrollToBottom() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const el = document.getElementById("main-content"); + if (!el) return; + const check = () => { + const distance = el.scrollHeight - el.scrollTop - el.clientHeight; + setVisible(distance > 300); + }; + check(); + el.addEventListener("scroll", check, { passive: true }); + return () => el.removeEventListener("scroll", check); + }, []); + + const scroll = useCallback(() => { + const el = document.getElementById("main-content"); + if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + }, []); + + if (!visible) return null; + + return ( + + ); +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 06c3a2f4..596cb98d 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -24,6 +24,7 @@ import { CopyText } from "../components/CopyText"; import { EntityRow } from "../components/EntityRow"; import { Identity } from "../components/Identity"; import { PageSkeleton } from "../components/PageSkeleton"; +import { ScrollToBottom } from "../components/ScrollToBottom"; import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; @@ -1747,6 +1748,7 @@ function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agen {/* Log viewer */} +
); } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 90c94888..a0266c16 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -18,6 +18,7 @@ import { CommentThread } from "../components/CommentThread"; import { IssueProperties } from "../components/IssueProperties"; import { LiveRunWidget } from "../components/LiveRunWidget"; import type { MentionOption } from "../components/MarkdownEditor"; +import { ScrollToBottom } from "../components/ScrollToBottom"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { StatusBadge } from "../components/StatusBadge"; @@ -926,6 +927,7 @@ export function IssueDetail() { +
); } From 0f75c35392190e5c90b30e95c90fe96386be8128 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:12:26 -0600 Subject: [PATCH 015/874] Fix unread dot making inbox rows taller than read rows Replace + ) : ( - + )} From 31b5ff1c619afc20a2f5f8a23b724a2652698eab Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Mar 2026 08:21:07 -0500 Subject: [PATCH 016/874] Add bottom padding to new issue description editor for iOS keyboard The description text was being covered by the property chips bar when typing long content on iOS with the virtual keyboard open. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/NewIssueDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 99106f9f..4401821f 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -831,7 +831,7 @@ export function NewIssueDialog() { placeholder="Add description..." bordered={false} mentions={mentionOptions} - contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")} + contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")} imageUploadHandler={async (file) => { const asset = await uploadDescriptionImage.mutateAsync(file); return asset.contentPath; From 2a7043d67761456d92d0b4be8fa9c5b121e9e4b6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Mar 2026 10:56:17 -0500 Subject: [PATCH 017/874] GitHub-style mobile issue rows: status left column, hide priority, unread dot right - Move status icon to left column on mobile across issues list, inbox, and dashboard - Hide priority icon on mobile (only show on desktop) - Move unread indicator dot to right side vertically centered on mobile inbox - Stale work section: show status icon instead of clock on mobile - Desktop layout unchanged Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 67 +++++++++------- ui/src/pages/Dashboard.tsx | 42 ++++++---- ui/src/pages/Inbox.tsx | 129 ++++++++++++++++++++----------- 3 files changed, 149 insertions(+), 89 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index e9f7ac8d..3770d10d 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -591,39 +591,50 @@ export function IssuesList({ - {/* Title line - first on mobile, middle on desktop */} - - {issue.title} + {/* Status icon - left column on mobile, inline on desktop */} + { e.preventDefault(); e.stopPropagation(); }}> + onUpdateIssue(issue.id, { status: s })} + /> - {/* Metadata line - second on mobile, first on desktop */} - - {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} - - - { e.preventDefault(); e.stopPropagation(); }}> - onUpdateIssue(issue.id, { status: s })} - /> + {/* Right column on mobile: title + metadata stacked */} + + {/* Title line */} + + {issue.title} - - {issue.identifier ?? issue.id.slice(0, 8)} - - {liveIssueIds?.has(issue.id) && ( - - - - - - Live + + {/* Metadata line */} + + {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} + + + { e.preventDefault(); e.stopPropagation(); }}> + onUpdateIssue(issue.id, { status: s })} + /> + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {liveIssueIds?.has(issue.id) && ( + + + + + + Live + + )} + · + + {timeAgo(issue.updatedAt)} - )} - · - - {timeAgo(issue.updatedAt)} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 23296bba..f782555e 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -315,25 +315,33 @@ export function Dashboard() { to={`/issues/${issue.identifier ?? issue.id}`} className="px-4 py-3 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block" > -
- - {issue.title} - - - +
+ {/* Status icon - left column on mobile */} + - - {issue.identifier ?? issue.id.slice(0, 8)} + + + {/* Right column on mobile: title + metadata stacked */} + + + {issue.title} - {issue.assigneeAgentId && (() => { - const name = agentName(issue.assigneeAgentId); - return name - ? - : null; - })()} - · - - {timeAgo(issue.updatedAt)} + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.assigneeAgentId && (() => { + const name = agentName(issue.assigneeAgentId); + return name + ? + : null; + })()} + · + + {timeAgo(issue.updatedAt)} +
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 66fe71c4..4091e36e 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -841,9 +841,14 @@ export function Inbox() { {staleIssues.map((issue) => (
- + {/* Status icon - left column on mobile; Clock icon on desktop */} + + + + + - - + + {issue.identifier ?? issue.id.slice(0, 8)} @@ -900,54 +905,90 @@ export function Inbox() { - - {issue.title} + {/* Status icon - left column on mobile, inline on desktop */} + + - - {(isUnread || isFading) ? ( - { - e.preventDefault(); - e.stopPropagation(); - markReadMutation.mutate(issue.id); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { + + {/* Right column on mobile: title + metadata stacked */} + + + {issue.title} + + + {(isUnread || isFading) ? ( + { e.preventDefault(); e.stopPropagation(); markReadMutation.mutate(issue.id); - } - }} - className="inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" - aria-label="Mark as read" - > - + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + markReadMutation.mutate(issue.id); + } + }} + className="hidden sm:inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" + aria-label="Mark as read" + > + + + ) : ( + + )} + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + + · + + + {issue.lastExternalCommentAt + ? `commented ${timeAgo(issue.lastExternalCommentAt)}` + : `updated ${timeAgo(issue.updatedAt)}`} - ) : ( - - )} - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - - · - - - {issue.lastExternalCommentAt - ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}`} + + {/* Unread dot - right side, vertically centered (mobile only; desktop keeps inline) */} + {(isUnread || isFading) && ( + { + e.preventDefault(); + e.stopPropagation(); + markReadMutation.mutate(issue.id); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + markReadMutation.mutate(issue.id); + } + }} + className="shrink-0 self-center cursor-pointer sm:hidden" + aria-label="Mark as read" + > + + + )} ); })} From d58f2692816817baa73ff40fe8d40d90cf72fa00 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Mar 2026 10:58:39 -0500 Subject: [PATCH 018/874] Fix mobile status icon vertical alignment: remove pt-0.5 to center with text Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 2 +- ui/src/pages/Dashboard.tsx | 2 +- ui/src/pages/Inbox.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 3770d10d..f9b4dcca 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -594,7 +594,7 @@ export function IssuesList({ className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1" > {/* Status icon - left column on mobile, inline on desktop */} - { e.preventDefault(); e.stopPropagation(); }}> + { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index f782555e..e1f9b9b0 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -317,7 +317,7 @@ export function Dashboard() { >
{/* Status icon - left column on mobile */} - + diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 4091e36e..990f30ca 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -844,7 +844,7 @@ export function Inbox() { className="group/stale relative flex items-start gap-2 overflow-hidden px-3 py-3 transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4" > {/* Status icon - left column on mobile; Clock icon on desktop */} - + @@ -908,7 +908,7 @@ export function Inbox() { className="flex min-w-0 cursor-pointer items-start gap-2 px-3 py-3 no-underline text-inherit transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4" > {/* Status icon - left column on mobile, inline on desktop */} - + From e35e2c43432002004162a376668b270428bdeffd Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Mar 2026 11:00:02 -0500 Subject: [PATCH 019/874] Fine-tune mobile status icon alignment on issues page: add 1px top padding Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index f9b4dcca..10d0709b 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -594,7 +594,7 @@ export function IssuesList({ className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1" > {/* Status icon - left column on mobile, inline on desktop */} - { e.preventDefault(); e.stopPropagation(); }}> + { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} From 57406dbc90e97b062c3a3d7f57b8f0b08be302b8 Mon Sep 17 00:00:00 2001 From: AiMagic5000 Date: Sun, 8 Mar 2026 13:47:59 -0700 Subject: [PATCH 020/874] fix(docker): run production server as non-root node user Switch the production stage to the built-in node user from node:lts-trixie-slim, fixing two runtime failures: 1. Claude CLI rejects --dangerously-skip-permissions when the process UID is 0, making the claude-local adapter unusable. 2. The server crashed at startup (EACCES) because /paperclip was root-owned and the process could not write logs or instance data. Changes vs the naive fix: - Use COPY --chown=node:node instead of a separate RUN chown -R, avoiding a duplicate image layer that would double the size of the /app tree in the final image. - Consolidate mkdir /paperclip + chown into the same RUN layer as the npm global install (already runs as root) to keep layer count minimal. - Add USER node before CMD so the process runs unprivileged. The VOLUME declaration comes after chown so freshly-mounted anonymous volumes inherit the correct node:node ownership. Fixes #344 --- Dockerfile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index ee566109..3fe1f2b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,8 +32,10 @@ RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" & FROM base AS production WORKDIR /app -COPY --from=build /app /app -RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai +COPY --chown=node:node --from=build /app /app +RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \ + && mkdir -p /paperclip \ + && chown node:node /paperclip ENV NODE_ENV=production \ HOME=/paperclip \ @@ -49,4 +51,5 @@ ENV NODE_ENV=production \ VOLUME ["/paperclip"] EXPOSE 3100 +USER node CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"] From ad55af04ccc4801221a4c94cd006884f4b52b31a Mon Sep 17 00:00:00 2001 From: Dale Stubblefield Date: Sun, 8 Mar 2026 22:00:51 -0500 Subject: [PATCH 021/874] fix: disable secure cookies for HTTP deployments Fixes login failing silently on authenticated + private deployments served over plain HTTP (e.g. Tailscale, LAN). Users can sign up and sign in, but the session cookie is rejected by the browser so they are immediately redirected back to the login page. Better Auth defaults to __Secure- prefixed cookies with the Secure flag when NODE_ENV=production. Browsers silently reject Secure cookies on non-HTTPS origins. This detects when PAPERCLIP_PUBLIC_URL uses http:// and sets useSecureCookies: false so session cookies work without HTTPS. --- server/src/auth/better-auth.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts index 786d3a4b..d338eeb8 100644 --- a/server/src/auth/better-auth.ts +++ b/server/src/auth/better-auth.ts @@ -70,6 +70,9 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins? const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET ?? "paperclip-dev-secret"; const effectiveTrustedOrigins = trustedOrigins ?? deriveAuthTrustedOrigins(config); + const publicUrl = process.env.PAPERCLIP_PUBLIC_URL ?? baseUrl; + const isHttpOnly = publicUrl ? publicUrl.startsWith("http://") : false; + const authConfig = { baseURL: baseUrl, secret, @@ -88,6 +91,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins? requireEmailVerification: false, disableSignUp: config.authDisableSignUp, }, + ...(isHttpOnly ? { advanced: { useSecureCookies: false } } : {}), }; if (!baseUrl) { From 5e18ccace7d670defb9f94d5bb6b304b78c12987 Mon Sep 17 00:00:00 2001 From: Dominic O'Carroll <99632940+domocarroll@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:45:09 +1000 Subject: [PATCH 022/874] fix: route heartbeat cost recording through costService Heartbeat runs recorded costs via direct SQL inserts into costEvents and agents.spentMonthlyCents, bypassing costService.createEvent(). This skipped: - companies.spentMonthlyCents update (company budget never incremented) - Agent auto-pause when budget exceeded (enforcement gap) Now calls costService(db).createEvent() which handles all three: insert cost event, update agent spend, update company spend, and auto-pause agent when budgetMonthlyCents is exceeded. --- server/src/services/heartbeat.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index dbba40b2..81d02137 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -9,7 +9,6 @@ import { agentWakeupRequests, heartbeatRunEvents, heartbeatRuns, - costEvents, issues, projectWorkspaces, } from "@paperclipai/db"; @@ -21,6 +20,7 @@ import { getServerAdapter, runningProcesses } from "../adapters/index.js"; import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec } from "../adapters/index.js"; import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; +import { costService } from "./costs.js"; import { secretService } from "./secrets.js"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; @@ -977,8 +977,8 @@ export function heartbeatService(db: Db) { .where(eq(agentRuntimeState.agentId, agent.id)); if (additionalCostCents > 0 || hasTokenUsage) { - await db.insert(costEvents).values({ - companyId: agent.companyId, + const costs = costService(db); + await costs.createEvent(agent.companyId, { agentId: agent.id, provider: result.provider ?? "unknown", model: result.model ?? "unknown", @@ -988,16 +988,6 @@ export function heartbeatService(db: Db) { occurredAt: new Date(), }); } - - if (additionalCostCents > 0) { - await db - .update(agents) - .set({ - spentMonthlyCents: sql`${agents.spentMonthlyCents} + ${additionalCostCents}`, - updatedAt: new Date(), - }) - .where(eq(agents.id, agent.id)); - } } async function startNextQueuedRunForAgent(agentId: string) { From 1a75e6d15cffd84c97987386bd7b7bb490f11e31 Mon Sep 17 00:00:00 2001 From: Daniil Okhlopkov <5613295+ohld@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:56:40 +0900 Subject: [PATCH 023/874] fix: default dangerouslySkipPermissions to true for unattended agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents run unattended and cannot respond to interactive permission prompts from Claude Code. When dangerouslySkipPermissions is false (the previous default), Claude Code blocks file operations with "Claude requested permissions to write to /path, but you haven't granted it yet" — making agents unable to edit files. The OnboardingWizard already sets this to true for claude_local agents (OnboardingWizard.tsx:277), but agents created or edited outside the wizard inherit the default of false, breaking them. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/agent-config-defaults.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/agent-config-defaults.ts b/ui/src/components/agent-config-defaults.ts index 4072de01..14ade2fd 100644 --- a/ui/src/components/agent-config-defaults.ts +++ b/ui/src/components/agent-config-defaults.ts @@ -8,7 +8,7 @@ export const defaultCreateValues: CreateConfigValues = { model: "", thinkingEffort: "", chrome: false, - dangerouslySkipPermissions: false, + dangerouslySkipPermissions: true, search: false, dangerouslyBypassSandbox: false, command: "", From 77e04407b9b412ae051bd8b3805288e43d069ef9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:21:33 -0500 Subject: [PATCH 024/874] fix(publish): always bundle ui-dist into server package --- scripts/prepare-server-ui-dist.sh | 21 +++++++++++++++++++++ scripts/release.sh | 4 +--- server/package.json | 3 +++ 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100755 scripts/prepare-server-ui-dist.sh diff --git a/scripts/prepare-server-ui-dist.sh b/scripts/prepare-server-ui-dist.sh new file mode 100755 index 00000000..d43807b3 --- /dev/null +++ b/scripts/prepare-server-ui-dist.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +# prepare-server-ui-dist.sh — Build the UI and copy it into server/ui-dist. +# This keeps @paperclipai/server publish artifacts self-contained for static UI serving. + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +UI_DIST="$REPO_ROOT/ui/dist" +SERVER_UI_DIST="$REPO_ROOT/server/ui-dist" + +echo " -> Building @paperclipai/ui..." +pnpm --dir "$REPO_ROOT" --filter @paperclipai/ui build + +if [ ! -f "$UI_DIST/index.html" ]; then + echo "Error: UI build output missing at $UI_DIST/index.html" + exit 1 +fi + +rm -rf "$SERVER_UI_DIST" +cp -r "$UI_DIST" "$SERVER_UI_DIST" +echo " -> Copied ui/dist to server/ui-dist" diff --git a/scripts/release.sh b/scripts/release.sh index 6827e0fa..3668d87c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -283,9 +283,7 @@ pnpm --filter @paperclipai/adapter-openclaw-gateway build pnpm --filter @paperclipai/server build # Build UI and bundle into server package for static serving -pnpm --filter @paperclipai/ui build -rm -rf "$REPO_ROOT/server/ui-dist" -cp -r "$REPO_ROOT/ui/dist" "$REPO_ROOT/server/ui-dist" +bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh" # Bundle skills into packages that need them (adapters + server) for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do diff --git a/server/package.json b/server/package.json index 3e74286b..5c37c211 100644 --- a/server/package.json +++ b/server/package.json @@ -24,7 +24,10 @@ "scripts": { "dev": "tsx src/index.ts", "dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", + "prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh", "build": "tsc", + "prepack": "pnpm run prepare:ui-dist", + "postpack": "rm -rf ui-dist", "clean": "rm -rf dist", "start": "node dist/index.js", "typecheck": "tsc --noEmit" From ee7fddf8d5fcd76d7ffc93164f972e717fb15c25 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:22:34 -0500 Subject: [PATCH 025/874] fix: convert lockfile refresh to PR-based flow for protected master The refresh-lockfile workflow was pushing directly to master, which fails with branch protection rules. Convert to use peter-evans/create-pull-request to create a PR instead. Exempt the bot's branch from the lockfile policy check. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pr-policy.yml | 1 + .github/workflows/refresh-lockfile.yml | 42 +++++++++----------------- 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml index eb515eda..16953380 100644 --- a/.github/workflows/pr-policy.yml +++ b/.github/workflows/pr-policy.yml @@ -32,6 +32,7 @@ jobs: node-version: 20 - name: Block manual lockfile edits + if: github.head_ref != 'chore/refresh-lockfile' run: | changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")" if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index 079fdd4e..b0cfb78a 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -11,11 +11,12 @@ concurrency: cancel-in-progress: false jobs: - refresh_and_verify: + refresh: runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 10 permissions: contents: write + pull-requests: write steps: - name: Checkout repository @@ -40,6 +41,7 @@ jobs: run: | changed="$(git status --porcelain)" if [ -z "$changed" ]; then + echo "Lockfile is already up to date." exit 0 fi if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then @@ -48,29 +50,15 @@ jobs: exit 1 fi - - name: Commit refreshed lockfile - run: | - if git diff --quiet -- pnpm-lock.yaml; then - exit 0 - fi - git config user.name "lockfile-bot" - git config user.email "lockfile-bot@users.noreply.github.com" - git add pnpm-lock.yaml - git commit -m "chore(lockfile): refresh pnpm-lock.yaml" - git push || { - echo "Push failed because master moved during lockfile refresh." - echo "A later refresh run should recompute the lockfile from the newer master state." - exit 1 - } + - name: Create pull request + uses: peter-evans/create-pull-request@v7 + with: + commit-message: "chore(lockfile): refresh pnpm-lock.yaml" + branch: chore/refresh-lockfile + delete-branch: true + title: "chore(lockfile): refresh pnpm-lock.yaml" + body: | + Auto-generated lockfile refresh after dependencies changed on `master`. - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Typecheck - run: pnpm -r typecheck - - - name: Run tests - run: pnpm test:run - - - name: Build - run: pnpm build + This PR only updates `pnpm-lock.yaml` — no source changes. + labels: lockfile-bot From f32b76f21343ef415a6389351d6189f8d7d395e6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:26:31 -0500 Subject: [PATCH 026/874] fix: replace third-party action with gh CLI for lockfile PR creation Replace peter-evans/create-pull-request with plain gh CLI commands to avoid third-party supply chain risk. Uses only GitHub's own tooling (GITHUB_TOKEN + gh CLI) to create the lockfile refresh PR. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/refresh-lockfile.yml | 38 ++++++++++++++++++-------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index b0cfb78a..604f394f 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -50,15 +50,31 @@ jobs: exit 1 fi - - name: Create pull request - uses: peter-evans/create-pull-request@v7 - with: - commit-message: "chore(lockfile): refresh pnpm-lock.yaml" - branch: chore/refresh-lockfile - delete-branch: true - title: "chore(lockfile): refresh pnpm-lock.yaml" - body: | - Auto-generated lockfile refresh after dependencies changed on `master`. + - name: Create or update pull request + env: + GH_TOKEN: ${{ github.token }} + run: | + if git diff --quiet -- pnpm-lock.yaml; then + echo "Lockfile unchanged, nothing to do." + exit 0 + fi - This PR only updates `pnpm-lock.yaml` — no source changes. - labels: lockfile-bot + BRANCH="chore/refresh-lockfile" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + git add pnpm-lock.yaml + git commit -m "chore(lockfile): refresh pnpm-lock.yaml" + git push -f origin "$BRANCH" + + # Create PR if one doesn't already exist for this branch + existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') + if [ -n "$existing" ]; then + echo "PR #$existing already exists for $BRANCH, updated branch." + else + gh pr create \ + --head "$BRANCH" \ + --title "chore(lockfile): refresh pnpm-lock.yaml" \ + --body "Auto-generated lockfile refresh after dependencies changed on \`master\`.\n\nThis PR only updates \`pnpm-lock.yaml\` — no source changes." + echo "Created new PR." + fi From 035e1a9333965f33547ddd10a00aa57f16dc76fe Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:33:49 -0500 Subject: [PATCH 027/874] fix: use lockfile-bot identity and remove force push in refresh workflow - Use lockfile-bot name/email instead of github-actions[bot] - Remove force push: close any stale PR and delete branch first, then create a fresh branch and PR each time Co-Authored-By: Claude Opus 4.6 --- .github/workflows/refresh-lockfile.yml | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index 604f394f..4da9d047 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -60,21 +60,21 @@ jobs: fi BRANCH="chore/refresh-lockfile" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -B "$BRANCH" - git add pnpm-lock.yaml - git commit -m "chore(lockfile): refresh pnpm-lock.yaml" - git push -f origin "$BRANCH" + git config user.name "lockfile-bot" + git config user.email "lockfile-bot@users.noreply.github.com" - # Create PR if one doesn't already exist for this branch + # Close any stale PR and delete the remote branch so we start fresh existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') if [ -n "$existing" ]; then - echo "PR #$existing already exists for $BRANCH, updated branch." - else - gh pr create \ - --head "$BRANCH" \ - --title "chore(lockfile): refresh pnpm-lock.yaml" \ - --body "Auto-generated lockfile refresh after dependencies changed on \`master\`.\n\nThis PR only updates \`pnpm-lock.yaml\` — no source changes." - echo "Created new PR." + gh pr close "$existing" --delete-branch || true fi + + git checkout -b "$BRANCH" + git add pnpm-lock.yaml + git commit -m "chore(lockfile): refresh pnpm-lock.yaml" + git push origin "$BRANCH" + + gh pr create \ + --head "$BRANCH" \ + --title "chore(lockfile): refresh pnpm-lock.yaml" \ + --body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml." From f2a0a0b80452165387f519a30abfdd28dc6b40c5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:38:49 -0500 Subject: [PATCH 028/874] fix: restore force push in lockfile refresh workflow Simplify the PR-based flow: force push to update the branch if it already exists, and only create a new PR when one doesn't exist yet. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/refresh-lockfile.yml | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index 4da9d047..a879e5bc 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -63,18 +63,19 @@ jobs: git config user.name "lockfile-bot" git config user.email "lockfile-bot@users.noreply.github.com" - # Close any stale PR and delete the remote branch so we start fresh - existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') - if [ -n "$existing" ]; then - gh pr close "$existing" --delete-branch || true - fi - - git checkout -b "$BRANCH" + git checkout -B "$BRANCH" git add pnpm-lock.yaml git commit -m "chore(lockfile): refresh pnpm-lock.yaml" - git push origin "$BRANCH" + git push --force origin "$BRANCH" - gh pr create \ - --head "$BRANCH" \ - --title "chore(lockfile): refresh pnpm-lock.yaml" \ - --body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml." + # Create PR if one doesn't already exist + existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') + if [ -z "$existing" ]; then + gh pr create \ + --head "$BRANCH" \ + --title "chore(lockfile): refresh pnpm-lock.yaml" \ + --body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml." + echo "Created new PR." + else + echo "PR #$existing already exists, branch updated via force push." + fi From 38cb2bf3c44f0f0454e51b06c42b3eaf66364356 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:43:47 -0500 Subject: [PATCH 029/874] fix: add missing disableSignUp to auth config objects in CLI All auth config literals in the CLI were missing the required disableSignUp field after it was added to authConfigSchema. Co-Authored-By: Claude Opus 4.6 --- cli/src/__tests__/allowed-hostname.test.ts | 1 + cli/src/commands/configure.ts | 1 + cli/src/commands/onboard.ts | 1 + cli/src/prompts/server.ts | 4 +++- 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cli/src/__tests__/allowed-hostname.test.ts b/cli/src/__tests__/allowed-hostname.test.ts index 92dfbf42..572689c4 100644 --- a/cli/src/__tests__/allowed-hostname.test.ts +++ b/cli/src/__tests__/allowed-hostname.test.ts @@ -42,6 +42,7 @@ function writeBaseConfig(configPath: string) { }, auth: { baseUrlMode: "auto", + disableSignUp: false, }, storage: { provider: "local_disk", diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index d072fee9..969ead97 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -61,6 +61,7 @@ function defaultConfig(): PaperclipConfig { }, auth: { baseUrlMode: "auto", + disableSignUp: false, }, storage: defaultStorageConfig(), secrets: defaultSecretsConfig(), diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 0e70d9cf..e3f17001 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -185,6 +185,7 @@ function quickstartDefaultsFromEnv(): { }, auth: { baseUrlMode: authBaseUrlMode, + disableSignUp: false, ...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}), }, storage: { diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index c2ab4218..00611560 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -113,7 +113,7 @@ export async function promptServer(opts?: { } const port = Number(portStr) || 3100; - let auth: AuthConfig = { baseUrlMode: "auto" }; + let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false }; if (deploymentMode === "authenticated" && exposure === "public") { const urlInput = await p.text({ message: "Public base URL", @@ -139,11 +139,13 @@ export async function promptServer(opts?: { } auth = { baseUrlMode: "explicit", + disableSignUp: false, publicBaseUrl: urlInput.trim().replace(/\/+$/, ""), }; } else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) { auth = { baseUrlMode: "explicit", + disableSignUp: false, publicBaseUrl: currentAuth.publicBaseUrl, }; } From d7b98a72b4aa6303b179edead3dfa0b1ba73ebc3 Mon Sep 17 00:00:00 2001 From: online5880 Date: Mon, 9 Mar 2026 21:52:06 +0900 Subject: [PATCH 030/874] fix: support Windows command wrappers for local adapters --- package.json | 3 +- packages/adapter-utils/src/server-utils.ts | 247 +++++++++++------- pnpm-lock.yaml | 53 ++-- server/package.json | 2 +- .../codex-local-adapter-environment.test.ts | 43 +++ 5 files changed, 217 insertions(+), 131 deletions(-) diff --git a/package.json b/package.json index 45c02b8b..8b35d0ba 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "dev": "node scripts/dev-runner.mjs watch", - "dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch", + "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch", "dev:once": "node scripts/dev-runner.mjs dev", "dev:server": "pnpm --filter @paperclipai/server dev", "dev:ui": "pnpm --filter @paperclipai/ui dev", @@ -29,6 +29,7 @@ }, "devDependencies": { "@changesets/cli": "^2.30.0", + "cross-env": "^10.1.0", "esbuild": "^0.27.3", "typescript": "^5.7.3", "vitest": "^3.0.5" diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 76efba86..8a296191 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -15,6 +15,11 @@ interface RunningProcess { graceSec: number; } +interface SpawnTarget { + command: string; + args: string[]; +} + type ChildProcessWithEvents = ChildProcess & { on(event: "error", listener: (err: Error) => void): ChildProcess; on( @@ -125,6 +130,78 @@ export function defaultPathForPlatform() { return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"; } +function windowsPathExts(env: NodeJS.ProcessEnv): string[] { + return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean); +} + +async function pathExists(candidate: string) { + try { + await fs.access(candidate, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +async function resolveCommandPath(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise { + const hasPathSeparator = command.includes("/") || command.includes("\\"); + if (hasPathSeparator) { + const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command); + return (await pathExists(absolute)) ? absolute : null; + } + + const pathValue = env.PATH ?? env.Path ?? ""; + const delimiter = process.platform === "win32" ? ";" : ":"; + const dirs = pathValue.split(delimiter).filter(Boolean); + const exts = process.platform === "win32" ? windowsPathExts(env) : [""]; + const hasExtension = process.platform === "win32" && path.extname(command).length > 0; + + for (const dir of dirs) { + const candidates = + process.platform === "win32" + ? hasExtension + ? [path.join(dir, command)] + : exts.map((ext) => path.join(dir, `${command}${ext}`)) + : [path.join(dir, command)]; + for (const candidate of candidates) { + if (await pathExists(candidate)) return candidate; + } + } + + return null; +} + +function quoteForCmd(arg: string) { + if (!arg.length) return '""'; + const escaped = arg.replace(/"/g, '""').replace(/%/g, "%%"); + return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped; +} + +async function resolveSpawnTarget( + command: string, + args: string[], + cwd: string, + env: NodeJS.ProcessEnv, +): Promise { + const resolved = await resolveCommandPath(command, cwd, env); + const executable = resolved ?? command; + + if (process.platform !== "win32") { + return { command: executable, args }; + } + + if (/\.(cmd|bat)$/i.test(executable)) { + const shell = env.ComSpec || process.env.ComSpec || "cmd.exe"; + const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" "); + return { + command: shell, + args: ["/d", "/s", "/c", commandLine], + }; + } + + return { command: executable, args }; +} + export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { if (typeof env.PATH === "string" && env.PATH.length > 0) return env; if (typeof env.Path === "string" && env.Path.length > 0) return env; @@ -169,36 +246,12 @@ export async function ensureAbsoluteDirectory( } export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) { - const hasPathSeparator = command.includes("/") || command.includes("\\"); - if (hasPathSeparator) { + const resolved = await resolveCommandPath(command, cwd, env); + if (resolved) return; + if (command.includes("/") || command.includes("\\")) { const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command); - try { - await fs.access(absolute, fsConstants.X_OK); - } catch { - throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`); - } - return; + throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`); } - - const pathValue = env.PATH ?? env.Path ?? ""; - const delimiter = process.platform === "win32" ? ";" : ":"; - const dirs = pathValue.split(delimiter).filter(Boolean); - const windowsExt = process.platform === "win32" - ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") - : [""]; - - for (const dir of dirs) { - for (const ext of windowsExt) { - const candidate = path.join(dir, process.platform === "win32" ? `${command}${ext}` : command); - try { - await fs.access(candidate, fsConstants.X_OK); - return; - } catch { - // continue scanning PATH - } - } - } - throw new Error(`Command not found in PATH: "${command}"`); } @@ -220,78 +273,82 @@ export async function runChildProcess( return new Promise((resolve, reject) => { const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env }); - const child = spawn(command, args, { - cwd: opts.cwd, - env: mergedEnv, - shell: false, - stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], - }) as ChildProcessWithEvents; + void resolveSpawnTarget(command, args, opts.cwd, mergedEnv) + .then((target) => { + const child = spawn(target.command, target.args, { + cwd: opts.cwd, + env: mergedEnv, + shell: false, + stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], + }) as ChildProcessWithEvents; - if (opts.stdin != null && child.stdin) { - child.stdin.write(opts.stdin); - child.stdin.end(); - } + if (opts.stdin != null && child.stdin) { + child.stdin.write(opts.stdin); + child.stdin.end(); + } - runningProcesses.set(runId, { child, graceSec: opts.graceSec }); + runningProcesses.set(runId, { child, graceSec: opts.graceSec }); - let timedOut = false; - let stdout = ""; - let stderr = ""; - let logChain: Promise = Promise.resolve(); + let timedOut = false; + let stdout = ""; + let stderr = ""; + let logChain: Promise = Promise.resolve(); - const timeout = - opts.timeoutSec > 0 - ? setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - setTimeout(() => { - if (!child.killed) { - child.kill("SIGKILL"); - } - }, Math.max(1, opts.graceSec) * 1000); - }, opts.timeoutSec * 1000) - : null; + const timeout = + opts.timeoutSec > 0 + ? setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => { + if (!child.killed) { + child.kill("SIGKILL"); + } + }, Math.max(1, opts.graceSec) * 1000); + }, opts.timeoutSec * 1000) + : null; - child.stdout?.on("data", (chunk: unknown) => { - const text = String(chunk); - stdout = appendWithCap(stdout, text); - logChain = logChain - .then(() => opts.onLog("stdout", text)) - .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")); - }); - - child.stderr?.on("data", (chunk: unknown) => { - const text = String(chunk); - stderr = appendWithCap(stderr, text); - logChain = logChain - .then(() => opts.onLog("stderr", text)) - .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")); - }); - - child.on("error", (err: Error) => { - if (timeout) clearTimeout(timeout); - runningProcesses.delete(runId); - const errno = (err as NodeJS.ErrnoException).code; - const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? ""; - const msg = - errno === "ENOENT" - ? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).` - : `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`; - reject(new Error(msg)); - }); - - child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { - if (timeout) clearTimeout(timeout); - runningProcesses.delete(runId); - void logChain.finally(() => { - resolve({ - exitCode: code, - signal, - timedOut, - stdout, - stderr, + child.stdout?.on("data", (chunk: unknown) => { + const text = String(chunk); + stdout = appendWithCap(stdout, text); + logChain = logChain + .then(() => opts.onLog("stdout", text)) + .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")); }); - }); - }); + + child.stderr?.on("data", (chunk: unknown) => { + const text = String(chunk); + stderr = appendWithCap(stderr, text); + logChain = logChain + .then(() => opts.onLog("stderr", text)) + .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")); + }); + + child.on("error", (err: Error) => { + if (timeout) clearTimeout(timeout); + runningProcesses.delete(runId); + const errno = (err as NodeJS.ErrnoException).code; + const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? ""; + const msg = + errno === "ENOENT" + ? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).` + : `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`; + reject(new Error(msg)); + }); + + child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { + if (timeout) clearTimeout(timeout); + runningProcesses.delete(runId); + void logChain.finally(() => { + resolve({ + exitCode: code, + signal, + timedOut, + stdout, + stderr, + }); + }); + }); + }) + .catch(reject); }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff4f3e35..80270207 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@25.2.3) + cross-env: + specifier: ^10.1.0 + version: 10.1.0 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -35,9 +38,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -139,22 +139,6 @@ importers: specifier: ^5.7.3 version: 5.9.3 - packages/adapters/openclaw: - dependencies: - '@paperclipai/adapter-utils': - specifier: workspace:* - version: link:../../adapter-utils - picocolors: - specifier: ^1.1.1 - version: 1.1.1 - devDependencies: - '@types/node': - specifier: ^24.6.0 - version: 24.12.0 - typescript: - specifier: ^5.7.3 - version: 5.9.3 - packages/adapters/openclaw-gateway: dependencies: '@paperclipai/adapter-utils': @@ -261,9 +245,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -379,9 +360,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -1011,6 +989,9 @@ packages: cpu: [x64] os: [win32] + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -3441,6 +3422,11 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -6743,6 +6729,8 @@ snapshots: '@embedded-postgres/windows-x64@18.1.0-beta.16': optional: true + '@epic-web/invariant@1.0.0': {} + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -8942,14 +8930,6 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 @@ -9253,6 +9233,11 @@ snapshots: crelt@1.0.6: {} + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -11722,7 +11707,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 diff --git a/server/package.json b/server/package.json index 3e74286b..2b3e1ed0 100644 --- a/server/package.json +++ b/server/package.json @@ -23,7 +23,7 @@ ], "scripts": { "dev": "tsx src/index.ts", - "dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", + "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", "build": "tsc", "clean": "rm -rf dist", "start": "node dist/index.js", diff --git a/server/src/__tests__/codex-local-adapter-environment.test.ts b/server/src/__tests__/codex-local-adapter-environment.test.ts index 9814334d..bf42e47d 100644 --- a/server/src/__tests__/codex-local-adapter-environment.test.ts +++ b/server/src/__tests__/codex-local-adapter-environment.test.ts @@ -29,4 +29,47 @@ describe("codex_local environment diagnostics", () => { expect(stats.isDirectory()).toBe(true); await fs.rm(path.dirname(cwd), { recursive: true, force: true }); }); + + it("runs the hello probe when Codex is available via a Windows .cmd wrapper", async () => { + if (process.platform !== "win32") return; + + const root = path.join( + os.tmpdir(), + `paperclip-codex-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + const binDir = path.join(root, "bin"); + const cwd = path.join(root, "workspace"); + const fakeCodex = path.join(binDir, "codex.cmd"); + const script = [ + "@echo off", + "echo {\"type\":\"thread.started\",\"thread_id\":\"test-thread\"}", + "echo {\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"hello\"}}", + "echo {\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":1,\"cached_input_tokens\":0,\"output_tokens\":1}}", + "exit /b 0", + "", + ].join("\r\n"); + + try { + await fs.mkdir(binDir, { recursive: true }); + await fs.writeFile(fakeCodex, script, "utf8"); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "codex_local", + config: { + command: "codex", + cwd, + env: { + OPENAI_API_KEY: "test-key", + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }, + }); + + expect(result.status).toBe("pass"); + expect(result.checks.some((check) => check.code === "codex_hello_probe_passed")).toBe(true); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); }); From ccd501ea02f35c6adb6378860c8c214d100ec93e Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 08:00:08 -0500 Subject: [PATCH 031/874] feat: add Playwright e2e tests for onboarding wizard flow Scaffolds end-to-end testing with Playwright for the onboarding wizard. Runs in skip_llm mode by default (UI-only, no LLM costs). Set PAPERCLIP_E2E_SKIP_LLM=false for full heartbeat verification. - tests/e2e/playwright.config.ts: Playwright config with webServer - tests/e2e/onboarding.spec.ts: 4-step wizard flow test - .github/workflows/e2e.yml: manual workflow_dispatch CI workflow - package.json: test:e2e and test:e2e:headed scripts Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e.yml | 44 +++++++++ .gitignore | 6 +- package.json | 5 +- pnpm-lock.yaml | 47 +++++++-- tests/e2e/onboarding.spec.ts | 172 +++++++++++++++++++++++++++++++++ tests/e2e/playwright.config.ts | 35 +++++++ 6 files changed, 298 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 tests/e2e/onboarding.spec.ts create mode 100644 tests/e2e/playwright.config.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..8d154627 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,44 @@ +name: E2E Tests + +on: + workflow_dispatch: + inputs: + skip_llm: + description: "Skip LLM-dependent assertions (default: true)" + type: boolean + default: true + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + PAPERCLIP_E2E_SKIP_LLM: ${{ inputs.skip_llm && 'true' || 'false' }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: npx playwright install --with-deps chromium + + - name: Run e2e tests + run: pnpm run test:e2e + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: | + tests/e2e/playwright-report/ + tests/e2e/test-results/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 9d9f5e35..066fcc68 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,8 @@ tmp/ *.tmp .vscode/ .claude/settings.local.json -.paperclip-local/ \ No newline at end of file +.paperclip-local/ + +# Playwright +tests/e2e/test-results/ +tests/e2e/playwright-report/ \ No newline at end of file diff --git a/package.json b/package.json index 45c02b8b..e19fb785 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,13 @@ "docs:dev": "cd docs && npx mintlify dev", "smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh", "smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh", - "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh" + "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", + "test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts", + "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed" }, "devDependencies": { "@changesets/cli": "^2.30.0", + "@playwright/test": "^1.58.2", "esbuild": "^0.27.3", "typescript": "^5.7.3", "vitest": "^3.0.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff4f3e35..f2885e8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@25.2.3) + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -35,9 +38,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -261,9 +261,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -379,9 +376,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -1696,6 +1690,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -4012,6 +4011,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4798,6 +4802,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -7394,6 +7408,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -9872,6 +9890,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10923,6 +10944,14 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts new file mode 100644 index 00000000..f1dbd0f5 --- /dev/null +++ b/tests/e2e/onboarding.spec.ts @@ -0,0 +1,172 @@ +import { test, expect } from "@playwright/test"; + +/** + * E2E: Onboarding wizard flow (skip_llm mode). + * + * Walks through the 4-step OnboardingWizard: + * Step 1 — Name your company + * Step 2 — Create your first agent (adapter selection + config) + * Step 3 — Give it something to do (task creation) + * Step 4 — Ready to launch (summary + open issue) + * + * By default this runs in skip_llm mode: we do NOT assert that an LLM + * heartbeat fires. Set PAPERCLIP_E2E_SKIP_LLM=false to enable LLM-dependent + * assertions (requires a valid ANTHROPIC_API_KEY). + */ + +const SKIP_LLM = process.env.PAPERCLIP_E2E_SKIP_LLM !== "false"; + +const COMPANY_NAME = `E2E-Test-${Date.now()}`; +const AGENT_NAME = "CEO"; +const TASK_TITLE = "E2E test task"; + +test.describe("Onboarding wizard", () => { + test("completes full wizard flow", async ({ page }) => { + // Navigate to root — should auto-open onboarding when no companies exist + await page.goto("/"); + + // If the wizard didn't auto-open (company already exists), click the button + const wizardHeading = page.locator("h3", { hasText: "Name your company" }); + const newCompanyBtn = page.getByRole("button", { name: "New Company" }); + + // Wait for either the wizard or the start page + await expect( + wizardHeading.or(newCompanyBtn) + ).toBeVisible({ timeout: 15_000 }); + + if (await newCompanyBtn.isVisible()) { + await newCompanyBtn.click(); + } + + // ----------------------------------------------------------- + // Step 1: Name your company + // ----------------------------------------------------------- + await expect(wizardHeading).toBeVisible({ timeout: 5_000 }); + await expect(page.locator("text=Step 1 of 4")).toBeVisible(); + + const companyNameInput = page.locator('input[placeholder="Acme Corp"]'); + await companyNameInput.fill(COMPANY_NAME); + + // Click Next + const nextButton = page.getByRole("button", { name: "Next" }); + await nextButton.click(); + + // ----------------------------------------------------------- + // Step 2: Create your first agent + // ----------------------------------------------------------- + await expect( + page.locator("h3", { hasText: "Create your first agent" }) + ).toBeVisible({ timeout: 10_000 }); + await expect(page.locator("text=Step 2 of 4")).toBeVisible(); + + // Agent name should default to "CEO" + const agentNameInput = page.locator('input[placeholder="CEO"]'); + await expect(agentNameInput).toHaveValue(AGENT_NAME); + + // Claude Code adapter should be selected by default + await expect( + page.locator("button", { hasText: "Claude Code" }).locator("..") + ).toBeVisible(); + + // Select the "Process" adapter to avoid needing a real CLI tool installed + await page.locator("button", { hasText: "Process" }).click(); + + // Fill in process adapter fields + const commandInput = page.locator('input[placeholder="e.g. node, python"]'); + await commandInput.fill("echo"); + const argsInput = page.locator( + 'input[placeholder="e.g. script.js, --flag"]' + ); + await argsInput.fill("hello"); + + // Click Next (process adapter skips environment test) + await page.getByRole("button", { name: "Next" }).click(); + + // ----------------------------------------------------------- + // Step 3: Give it something to do + // ----------------------------------------------------------- + await expect( + page.locator("h3", { hasText: "Give it something to do" }) + ).toBeVisible({ timeout: 10_000 }); + await expect(page.locator("text=Step 3 of 4")).toBeVisible(); + + // Clear default title and set our test title + const taskTitleInput = page.locator( + 'input[placeholder="e.g. Research competitor pricing"]' + ); + await taskTitleInput.clear(); + await taskTitleInput.fill(TASK_TITLE); + + // Click Next + await page.getByRole("button", { name: "Next" }).click(); + + // ----------------------------------------------------------- + // Step 4: Ready to launch + // ----------------------------------------------------------- + await expect( + page.locator("h3", { hasText: "Ready to launch" }) + ).toBeVisible({ timeout: 10_000 }); + await expect(page.locator("text=Step 4 of 4")).toBeVisible(); + + // Verify summary displays our created entities + await expect(page.locator("text=" + COMPANY_NAME)).toBeVisible(); + await expect(page.locator("text=" + AGENT_NAME)).toBeVisible(); + await expect(page.locator("text=" + TASK_TITLE)).toBeVisible(); + + // Click "Open Issue" + await page.getByRole("button", { name: "Open Issue" }).click(); + + // Should navigate to the issue page + await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 }); + + // ----------------------------------------------------------- + // Verify via API that entities were created + // ----------------------------------------------------------- + const baseUrl = page.url().split("/").slice(0, 3).join("/"); + + // List companies and find ours + const companiesRes = await page.request.get(`${baseUrl}/api/companies`); + expect(companiesRes.ok()).toBe(true); + const companies = await companiesRes.json(); + const company = companies.find( + (c: { name: string }) => c.name === COMPANY_NAME + ); + expect(company).toBeTruthy(); + + // List agents for our company + const agentsRes = await page.request.get( + `${baseUrl}/api/companies/${company.id}/agents` + ); + expect(agentsRes.ok()).toBe(true); + const agents = await agentsRes.json(); + const ceoAgent = agents.find( + (a: { name: string }) => a.name === AGENT_NAME + ); + expect(ceoAgent).toBeTruthy(); + expect(ceoAgent.role).toBe("ceo"); + expect(ceoAgent.adapterType).toBe("process"); + + // List issues for our company + const issuesRes = await page.request.get( + `${baseUrl}/api/companies/${company.id}/issues` + ); + expect(issuesRes.ok()).toBe(true); + const issues = await issuesRes.json(); + const task = issues.find( + (i: { title: string }) => i.title === TASK_TITLE + ); + expect(task).toBeTruthy(); + expect(task.assigneeAgentId).toBe(ceoAgent.id); + + if (!SKIP_LLM) { + // LLM-dependent: wait for the heartbeat to transition the issue + await expect(async () => { + const res = await page.request.get( + `${baseUrl}/api/issues/${task.id}` + ); + const issue = await res.json(); + expect(["in_progress", "done"]).toContain(issue.status); + }).toPass({ timeout: 120_000, intervals: [5_000] }); + } + }); +}); diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 00000000..5ae1b677 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from "@playwright/test"; + +const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3100); +const BASE_URL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: ".", + testMatch: "**/*.spec.ts", + timeout: 60_000, + retries: 0, + use: { + baseURL: BASE_URL, + headless: true, + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], + // The webServer directive starts `paperclipai run` before tests. + // Expects `pnpm paperclipai` to be runnable from repo root. + webServer: { + command: `pnpm paperclipai run --yes`, + url: `${BASE_URL}/api/health`, + reuseExistingServer: !!process.env.CI, + timeout: 120_000, + stdout: "pipe", + stderr: "pipe", + }, + outputDir: "./test-results", + reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]], +}); From f4a9788f2d5c69b14ba0a646ac68a5078fc338db Mon Sep 17 00:00:00 2001 From: online5880 Date: Mon, 9 Mar 2026 22:08:50 +0900 Subject: [PATCH 032/874] fix: tighten Windows adapter command handling --- packages/adapter-utils/src/server-utils.ts | 4 ++-- pnpm-lock.yaml | 13 ++++++++++++- server/package.json | 1 + .../codex-local-adapter-environment.test.ts | 6 +++--- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 8a296191..3d273cd9 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -136,7 +136,7 @@ function windowsPathExts(env: NodeJS.ProcessEnv): string[] { async function pathExists(candidate: string) { try { - await fs.access(candidate, fsConstants.X_OK); + await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK); return true; } catch { return false; @@ -173,7 +173,7 @@ async function resolveCommandPath(command: string, cwd: string, env: NodeJS.Proc function quoteForCmd(arg: string) { if (!arg.length) return '""'; - const escaped = arg.replace(/"/g, '""').replace(/%/g, "%%"); + const escaped = arg.replace(/"/g, '""'); return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80270207..ff1442a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -321,6 +321,9 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 supertest: specifier: ^7.0.0 version: 7.2.2 @@ -8930,6 +8933,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 @@ -11707,7 +11718,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 diff --git a/server/package.json b/server/package.json index 2b3e1ed0..73fda4bd 100644 --- a/server/package.json +++ b/server/package.json @@ -61,6 +61,7 @@ "@types/node": "^24.6.0", "@types/supertest": "^6.0.2", "@types/ws": "^8.18.1", + "cross-env": "^10.1.0", "supertest": "^7.0.0", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/server/src/__tests__/codex-local-adapter-environment.test.ts b/server/src/__tests__/codex-local-adapter-environment.test.ts index bf42e47d..a9201c98 100644 --- a/server/src/__tests__/codex-local-adapter-environment.test.ts +++ b/server/src/__tests__/codex-local-adapter-environment.test.ts @@ -4,6 +4,8 @@ import os from "node:os"; import path from "node:path"; import { testEnvironment } from "@paperclipai/adapter-codex-local/server"; +const itWindows = process.platform === "win32" ? it : it.skip; + describe("codex_local environment diagnostics", () => { it("creates a missing working directory when cwd is absolute", async () => { const cwd = path.join( @@ -30,9 +32,7 @@ describe("codex_local environment diagnostics", () => { await fs.rm(path.dirname(cwd), { recursive: true, force: true }); }); - it("runs the hello probe when Codex is available via a Windows .cmd wrapper", async () => { - if (process.platform !== "win32") return; - + itWindows("runs the hello probe when Codex is available via a Windows .cmd wrapper", async () => { const root = path.join( os.tmpdir(), `paperclip-codex-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`, From e6e41dba9daa215479ef67c14f17ac6987af1538 Mon Sep 17 00:00:00 2001 From: lockfile-bot Date: Mon, 9 Mar 2026 13:27:18 +0000 Subject: [PATCH 033/874] chore(lockfile): refresh pnpm-lock.yaml --- pnpm-lock.yaml | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff4f3e35..9536ff75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -139,22 +136,6 @@ importers: specifier: ^5.7.3 version: 5.9.3 - packages/adapters/openclaw: - dependencies: - '@paperclipai/adapter-utils': - specifier: workspace:* - version: link:../../adapter-utils - picocolors: - specifier: ^1.1.1 - version: 1.1.1 - devDependencies: - '@types/node': - specifier: ^24.6.0 - version: 24.12.0 - typescript: - specifier: ^5.7.3 - version: 5.9.3 - packages/adapters/openclaw-gateway: dependencies: '@paperclipai/adapter-utils': @@ -261,9 +242,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -379,9 +357,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway From a7cfd9f24b865bd2a1d08a520de5075a3767e68f Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 08:49:42 -0500 Subject: [PATCH 034/874] chore: formalize release workflow --- .github/workflows/release.yml | 132 ++++++ doc/PUBLISHING.md | 257 ++++------- doc/RELEASING.md | 437 ++++++++++++++++++ package.json | 2 + scripts/create-github-release.sh | 86 ++++ scripts/release.sh | 714 ++++++++++++++++-------------- scripts/rollback-latest.sh | 111 +++++ skills/release-changelog/SKILL.md | 351 +++------------ skills/release/SKILL.md | 432 ++++++------------ 9 files changed, 1431 insertions(+), 1091 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 doc/RELEASING.md create mode 100755 scripts/create-github-release.sh create mode 100755 scripts/rollback-latest.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..492b02b5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,132 @@ +name: Release + +on: + workflow_dispatch: + inputs: + channel: + description: Release channel + required: true + type: choice + default: canary + options: + - canary + - stable + bump: + description: Semantic version bump + required: true + type: choice + default: patch + options: + - patch + - minor + - major + dry_run: + description: Preview the release without publishing + required: true + type: boolean + default: true + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Typecheck + run: pnpm -r typecheck + + - name: Run tests + run: pnpm test:run + + - name: Build + run: pnpm build + + publish: + if: github.ref == 'refs/heads/master' + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: npm-release + permissions: + contents: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Run release script + env: + GITHUB_ACTIONS: "true" + run: | + args=("${{ inputs.bump }}") + if [ "${{ inputs.channel }}" = "canary" ]; then + args+=("--canary") + fi + if [ "${{ inputs.dry_run }}" = "true" ]; then + args+=("--dry-run") + fi + ./scripts/release.sh "${args[@]}" + + - name: Push stable release commit and tag + if: inputs.channel == 'stable' && !inputs.dry_run + run: git push origin HEAD:master --follow-tags + + - name: Create GitHub Release + if: inputs.channel == 'stable' && !inputs.dry_run + env: + GH_TOKEN: ${{ github.token }} + run: | + version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')" + if [ -z "$version" ]; then + echo "Error: no v* tag points at HEAD after stable release." >&2 + exit 1 + fi + ./scripts/create-github-release.sh "$version" diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index 29ac7291..fad105d6 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -1,196 +1,119 @@ # Publishing to npm -This document covers how to build and publish the `paperclipai` CLI package to npm. +Low-level reference for how Paperclip packages are built for npm. -## Prerequisites +For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This document is only about packaging internals and the scripts that produce publishable artifacts. -- Node.js 20+ -- pnpm 9.15+ -- An npm account with publish access to the `paperclipai` package -- Logged in to npm: `npm login` +## Current Release Entry Points -## One-Command Publish +Use these scripts instead of older one-off publish commands: -The fastest way to publish — bumps version, builds, publishes, restores, commits, and tags in one shot: +- [`scripts/release.sh`](../scripts/release.sh) for canary and stable npm publishes +- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback +- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after a stable push -```bash -./scripts/bump-and-publish.sh patch # 0.1.1 → 0.1.2 -./scripts/bump-and-publish.sh minor # 0.1.1 → 0.2.0 -./scripts/bump-and-publish.sh major # 0.1.1 → 1.0.0 -./scripts/bump-and-publish.sh 2.0.0 # set explicit version -./scripts/bump-and-publish.sh patch --dry-run # everything except npm publish -``` +## Why the CLI needs special packaging -The script runs all 6 steps below in order. It requires a clean working tree and an active `npm login` session (unless `--dry-run`). After it finishes, push: +The CLI package, `paperclipai`, imports code from workspace packages such as: -```bash -git push && git push origin v -``` +- `@paperclipai/server` +- `@paperclipai/db` +- `@paperclipai/shared` +- adapter packages under `packages/adapters/` -## Manual Step-by-Step +Those workspace references use `workspace:*` during development. npm cannot install those references directly for end users, so the release build has to transform the CLI into a publishable standalone package. -If you prefer to run each step individually: +## `build-npm.sh` -### Quick Reference - -```bash -# Bump version -./scripts/version-bump.sh patch # 0.1.0 → 0.1.1 - -# Build -./scripts/build-npm.sh - -# Preview what will be published -cd cli && npm pack --dry-run - -# Publish -cd cli && npm publish --access public - -# Restore dev package.json -mv cli/package.dev.json cli/package.json -``` - -## Step-by-Step - -### 1. Bump the version - -```bash -./scripts/version-bump.sh -``` - -This updates the version in two places: - -- `cli/package.json` — the source of truth -- `cli/src/index.ts` — the Commander `.version()` call - -Examples: - -```bash -./scripts/version-bump.sh patch # 0.1.0 → 0.1.1 -./scripts/version-bump.sh minor # 0.1.0 → 0.2.0 -./scripts/version-bump.sh major # 0.1.0 → 1.0.0 -./scripts/version-bump.sh 1.2.3 # set explicit version -``` - -### 2. Build +Run: ```bash ./scripts/build-npm.sh ``` -The build script runs five steps: +This script does six things: -1. **Forbidden token check** — scans tracked files for tokens listed in `.git/hooks/forbidden-tokens.txt`. If the file is missing (e.g. on a contributor's machine), the check passes silently. The script never prints which tokens it's searching for. -2. **TypeScript type-check** — runs `pnpm -r typecheck` across all workspace packages. -3. **esbuild bundle** — bundles the CLI entry point (`cli/src/index.ts`) and all workspace package code (`@paperclipai/*`) into a single file at `cli/dist/index.js`. External npm dependencies (express, postgres, etc.) are kept as regular imports. -4. **Generate publishable package.json** — replaces `cli/package.json` with a version that has real npm dependency ranges instead of `workspace:*` references (see [package.dev.json](#packagedevjson) below). -5. **Summary** — prints the bundle size and next steps. +1. Runs the forbidden token check unless `--skip-checks` is supplied +2. Runs `pnpm -r typecheck` +3. Bundles the CLI entrypoint with esbuild into `cli/dist/index.js` +4. Verifies the bundled entrypoint with `node --check` +5. Rewrites `cli/package.json` into a publishable npm manifest and stores the dev copy as `cli/package.dev.json` +6. Copies the repo `README.md` into `cli/README.md` for npm package metadata -To skip the forbidden token check (e.g. in CI without the token list): +`build-npm.sh` is used by the release script so that npm users install a real package rather than unresolved workspace dependencies. + +## Publishable CLI layout + +During development, [`cli/package.json`](../cli/package.json) contains workspace references. + +During release preparation: + +- `cli/package.json` becomes a publishable manifest with external npm dependency ranges +- `cli/package.dev.json` stores the development manifest temporarily +- `cli/dist/index.js` contains the bundled CLI entrypoint +- `cli/README.md` is copied in for npm metadata + +After release finalization, the release script restores the development manifest and removes the temporary README copy. + +## Package discovery + +The release tooling scans the workspace for public packages under: + +- `packages/` +- `server/` +- `cli/` + +`ui/` remains ignored for npm publishing because it is private. + +This matters because all public packages are versioned and published together as one release unit. + +## Canary packaging model + +Canaries are published as semver prereleases such as: + +- `1.2.3-canary.0` +- `1.2.3-canary.1` + +They are published under the npm dist-tag `canary`. + +This means: + +- `npx paperclipai@canary onboard` can install them explicitly +- `npx paperclipai onboard` continues to resolve `latest` +- the stable changelog can stay at `releases/v1.2.3.md` + +## Stable packaging model + +Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`. + +The stable publish flow also creates the local release commit and git tag. Pushing the commit/tag and creating the GitHub Release happen afterward as separate maintainer steps. + +## Rollback model + +Rollback does not unpublish packages. + +Instead, the maintainer should move the `latest` dist-tag back to the previous good stable version with: ```bash -./scripts/build-npm.sh --skip-checks +./scripts/rollback-latest.sh ``` -### 3. Preview (optional) +That keeps history intact while restoring the default install path quickly. -See what npm will publish: +## Notes for CI -```bash -cd cli && npm pack --dry-run -``` +The repo includes a manual GitHub Actions release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). -### 4. Publish +Recommended CI release setup: -```bash -cd cli && npm publish --access public -``` +- use npm trusted publishing via GitHub OIDC +- require approval through the `npm-release` environment +- run releases from `master` +- use canary first, then stable -### 5. Restore dev package.json +## Related Files -After publishing, restore the workspace-aware `package.json`: - -```bash -mv cli/package.dev.json cli/package.json -``` - -### 6. Commit and tag - -```bash -git add cli/package.json cli/src/index.ts -git commit -m "chore: bump version to X.Y.Z" -git tag vX.Y.Z -``` - -## package.dev.json - -During development, `cli/package.json` contains `workspace:*` references like: - -```json -{ - "dependencies": { - "@paperclipai/server": "workspace:*", - "@paperclipai/db": "workspace:*" - } -} -``` - -These tell pnpm to resolve those packages from the local monorepo. This is great for development but **npm doesn't understand `workspace:*`** — publishing with these references would cause install failures for users. - -The build script solves this with a two-file swap: - -1. **Before building:** `cli/package.json` has `workspace:*` refs (the dev version). -2. **During build (`build-npm.sh` step 4):** - - The dev `package.json` is copied to `package.dev.json` as a backup. - - `generate-npm-package-json.mjs` reads every workspace package's `package.json`, collects all their external npm dependencies, and writes a new `cli/package.json` with those real dependency ranges — no `workspace:*` refs. -3. **After publishing:** you restore the dev version with `mv package.dev.json package.json`. - -The generated publishable `package.json` looks like: - -```json -{ - "name": "paperclipai", - "version": "0.1.0", - "bin": { "paperclipai": "./dist/index.js" }, - "dependencies": { - "express": "^5.1.0", - "postgres": "^3.4.5", - "commander": "^13.1.0" - } -} -``` - -`package.dev.json` is listed in `.gitignore` — it only exists temporarily on disk during the build/publish cycle. - -## How the bundle works - -The CLI is a monorepo package that imports code from `@paperclipai/server`, `@paperclipai/db`, `@paperclipai/shared`, and several adapter packages. These workspace packages don't exist on npm. - -**esbuild** bundles all workspace TypeScript code into a single `dist/index.js` file (~250kb). External npm packages (express, postgres, zod, etc.) are left as normal `import` statements — they get installed by npm when a user runs `npx paperclipai onboard`. - -The esbuild configuration lives at `cli/esbuild.config.mjs`. It automatically reads every workspace package's `package.json` to determine which dependencies are external (real npm packages) vs. internal (workspace code to bundle). - -## Forbidden token enforcement - -The build process includes the same forbidden-token check used by the git pre-commit hook. This catches any accidentally committed tokens before they reach npm. - -- Token list: `.git/hooks/forbidden-tokens.txt` (one token per line, `#` comments supported) -- The file lives inside `.git/` and is never committed -- If the file is missing, the check passes — contributors without the list can still build -- The script never prints which tokens are being searched for -- Matches are printed so you know which files to fix, but not which token triggered it - -Run the check standalone: - -```bash -pnpm check:tokens -``` - -## npm scripts reference - -| Script | Command | Description | -|---|---|---| -| `bump-and-publish` | `pnpm bump-and-publish ` | One-command bump + build + publish + commit + tag | -| `build:npm` | `pnpm build:npm` | Full build (check + typecheck + bundle + package.json) | -| `version:bump` | `pnpm version:bump ` | Bump CLI version | -| `check:tokens` | `pnpm check:tokens` | Run forbidden token check only | +- [`scripts/build-npm.sh`](../scripts/build-npm.sh) +- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs) +- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs) +- [`doc/RELEASING.md`](RELEASING.md) diff --git a/doc/RELEASING.md b/doc/RELEASING.md new file mode 100644 index 00000000..cab82cbe --- /dev/null +++ b/doc/RELEASING.md @@ -0,0 +1,437 @@ +# Releasing Paperclip + +Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface. + +This document is intentionally practical: + +- TL;DR command sequences are at the top. +- Detailed checklists come next. +- Motivation, failure handling, and rollback playbooks follow after that. + +## Release Surfaces + +Every Paperclip release has four separate surfaces: + +1. **Verification** — the exact git SHA must pass typecheck, tests, and build. +2. **npm** — `paperclipai` and the public workspace packages are published. +3. **GitHub** — the stable release gets a git tag and a GitHub Release. +4. **Website / announcements** — the stable changelog is published externally and announced. + +Treat those as related but separate. npm can succeed while the GitHub Release is still pending. GitHub can be correct while the website changelog is stale. A maintainer release is done only when all four surfaces are handled. + +## TL;DR + +### Canary release + +Use this when you want an installable prerelease without changing `latest`. + +```bash +# 0. Start clean +git status --short + +# 1. Verify the candidate SHA +pnpm -r typecheck +pnpm test:run +pnpm build + +# 2. Draft or update the stable changelog +# releases/vX.Y.Z.md + +# 3. Preview the canary release +./scripts/release.sh patch --canary --dry-run + +# 4. Publish the canary +./scripts/release.sh patch --canary + +# 5. Smoke test what users will actually install +PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh + +# Users install with: +npx paperclipai@canary onboard +``` + +Result: + +- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary` +- `latest` is unchanged +- no git tag is created +- no GitHub Release is created +- the working tree returns to clean after the script finishes + +### Stable release + +Use this only after the canary SHA is good enough to become the public default. + +```bash +# 0. Start from the vetted commit +git checkout master +git pull +git status --short + +# 1. Verify again on the exact release SHA +pnpm -r typecheck +pnpm test:run +pnpm build + +# 2. Confirm the stable changelog exists +ls releases/v*.md + +# 3. Preview the stable publish +./scripts/release.sh patch --dry-run + +# 4. Publish the stable release to npm and create the local release commit + tag +./scripts/release.sh patch + +# 5. Push the release commit and tag +git push origin HEAD:master --follow-tags + +# 6. Create or update the GitHub Release from the pushed tag +./scripts/create-github-release.sh X.Y.Z +``` + +Result: + +- npm gets stable `X.Y.Z` under dist-tag `latest` +- a local git commit and tag `vX.Y.Z` are created +- after push, GitHub gets the matching Release +- the website and announcement steps still need to be handled manually + +### Emergency rollback + +If `latest` is broken after publish, repoint it to the last known good stable version first, then work on the fix. + +```bash +# Preview +./scripts/rollback-latest.sh X.Y.Z --dry-run + +# Roll back latest for every public package +./scripts/rollback-latest.sh X.Y.Z +``` + +This does **not** unpublish anything. It only moves the `latest` dist-tag back to the last good stable release. + +### GitHub Actions release + +There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). It is designed for npm trusted publishing via GitHub OIDC instead of long-lived npm tokens. + +Use it from the Actions tab: + +1. Choose `Release` +2. Choose `channel`: `canary` or `stable` +3. Choose `bump`: `patch`, `minor`, or `major` +4. Choose whether this is a `dry_run` +5. Run it from `master` + +The workflow: + +- reruns `typecheck`, `test:run`, and `build` +- gates publish behind the `npm-release` environment +- can publish canaries without touching `latest` +- can publish stable, push the release commit and tag, and create the GitHub Release + +## Release Checklist + +### Before any publish + +- [ ] The working tree is clean, including untracked files +- [ ] The target branch and SHA are the ones you actually want to release +- [ ] The required verification gate passed on that exact SHA +- [ ] The bump type is correct for the user-visible impact +- [ ] The stable changelog file exists or is ready to be written at `releases/vX.Y.Z.md` +- [ ] You know which previous stable version you would roll back to if needed + +### Before a canary + +- [ ] You are intentionally testing something that should be installable before it becomes default +- [ ] You are comfortable with users installing it via `npx paperclipai@canary onboard` +- [ ] You understand that each canary is a new immutable npm version such as `1.2.3-canary.1` + +### Before a stable + +- [ ] The candidate has already passed smoke testing +- [ ] The changelog should be the stable version only, for example `v1.2.3` +- [ ] You are ready to push the release commit and tag immediately after npm publish +- [ ] You are ready to create the GitHub Release immediately after the push +- [ ] You have a post-release website / announcement plan + +### After a stable + +- [ ] `npm view paperclipai@latest version` matches the new stable version +- [ ] The git tag exists on GitHub +- [ ] The GitHub Release exists and uses `releases/vX.Y.Z.md` +- [ ] The website changelog is updated +- [ ] Any announcement copy matches the shipped release, not the canary + +## Verification Gate + +The repository standard is: + +```bash +pnpm -r typecheck +pnpm test:run +pnpm build +``` + +This matches [`.github/workflows/pr-verify.yml`](../.github/workflows/pr-verify.yml). Run it before claiming a release candidate is ready. + +## Versioning Policy + +### Stable versions + +Stable releases use normal semver: + +- `patch` for bug fixes +- `minor` for additive features, endpoints, and additive migrations +- `major` for destructive migrations, removed APIs, or other breaking behavior + +### Canary versions + +Canaries are semver prereleases of the **intended stable version**: + +- `1.2.3-canary.0` +- `1.2.3-canary.1` +- `1.2.3-canary.2` + +That gives you three useful properties: + +1. Users can install the prerelease explicitly with `@canary` +2. `latest` stays safe +3. The stable changelog can remain just `v1.2.3` + +We do **not** create separate changelog files for canary versions. + +## Changelog Policy + +The maintainer changelog source of truth is: + +- `releases/vX.Y.Z.md` + +That file is for the eventual stable release. It should not include `-canary` in the filename or heading. + +Recommended structure: + +- `Breaking Changes` when needed +- `Highlights` +- `Improvements` +- `Fixes` +- `Upgrade Guide` when needed + +Package-level `CHANGELOG.md` files are generated as part of the release mechanics. They are not the main release narrative. + +## Detailed Workflow + +### 1. Decide the bump + +Review the range since the last stable tag: + +```bash +LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) +git log "${LAST_TAG}..HEAD" --oneline --no-merges +git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/ +git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/ +git log "${LAST_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true +``` + +Use the higher bump if there is any doubt. + +### 2. Write the stable changelog first + +Create or update: + +```bash +releases/vX.Y.Z.md +``` + +This is deliberate. The release notes should describe the stable story, not the canary mechanics. + +### 3. Publish one or more canaries + +Run: + +```bash +./scripts/release.sh --canary +``` + +What the script does: + +1. Verifies the working tree is clean +2. Computes the intended stable version from the last stable tag +3. Computes the next canary ordinal from npm +4. Versions the public packages to `X.Y.Z-canary.N` +5. Builds the workspace and publishable CLI +6. Publishes to npm under dist-tag `canary` +7. Cleans up the temporary versioning state so your branch returns to clean + +This means the script is safe to repeat as many times as needed while iterating: + +- `1.2.3-canary.0` +- `1.2.3-canary.1` +- `1.2.3-canary.2` + +The target stable release can still remain `1.2.3`. + +### 4. Smoke test the canary + +Run the actual install path in Docker: + +```bash +PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +``` + +Minimum checks: + +- [ ] `npx paperclipai@canary onboard` installs +- [ ] onboarding completes without crashes +- [ ] the server boots +- [ ] the UI loads +- [ ] basic company creation and dashboard load work + +### 5. Publish stable from the vetted commit + +Once the candidate SHA is good, run the stable flow on that exact commit: + +```bash +./scripts/release.sh +``` + +What the script does: + +1. Verifies the working tree is clean +2. Versions the public packages to the stable semver +3. Builds the workspace and CLI publish bundle +4. Publishes to npm under `latest` +5. Restores temporary publish artifacts +6. Creates the local release commit and git tag + +What it does **not** do: + +- it does not push for you +- it does not update the website +- it does not announce the release for you + +### 6. Push the release and create the GitHub Release + +After a stable publish succeeds: + +```bash +git push origin HEAD:master --follow-tags +./scripts/create-github-release.sh X.Y.Z +``` + +The GitHub release notes come from: + +- `releases/vX.Y.Z.md` + +### 7. Complete the external surfaces + +After GitHub is correct: + +- publish the changelog on the website +- write the announcement copy +- ensure public docs and install guidance point to the stable version + +## GitHub Actions and npm Trusted Publishing + +If you want GitHub to own the actual npm publish, use [`.github/workflows/release.yml`](../.github/workflows/release.yml) together with npm trusted publishing. + +Recommended setup: + +1. Configure the GitHub Actions workflow as a trusted publisher for **every public package** on npm +2. Use the `npm-release` GitHub environment with required reviewers +3. Run stable publishes from `master` only +4. Keep the workflow manual via `workflow_dispatch` + +Why this is the right shape: + +- no long-lived npm token needs to live in GitHub secrets +- reviewers can approve the publish step at the environment gate +- the workflow reruns verification on the release SHA before publish +- stable and canary use the same mechanics + +## Failure Playbooks + +### If the canary fails before publish + +Nothing shipped. Fix the code and rerun the canary workflow. + +### If the canary publishes but the smoke test fails + +Do **not** publish stable. + +Instead: + +1. Fix the issue +2. Publish another canary +3. Re-run smoke testing + +The canary version number will increase, but the stable target version can remain the same. + +### If the stable npm publish succeeds but push fails + +This is a partial release. npm is already live. + +Do this immediately: + +1. Fix the git issue +2. Push the release commit and tag from the same checkout +3. Create the GitHub Release + +Do **not** publish the same version again. + +### If the stable release is bad after `latest` moves + +Use the rollback script first: + +```bash +./scripts/rollback-latest.sh +``` + +Then: + +1. open an incident note or maintainer comment +2. fix forward on a new patch release +3. update the changelog / release notes if the user-facing guidance changed + +### If the GitHub Release is wrong + +Edit it by re-running: + +```bash +./scripts/create-github-release.sh X.Y.Z +``` + +This updates the release notes if the GitHub Release already exists. + +### If the website changelog is wrong + +Fix the website independently. Do not republish npm just to repair the website surface. + +## Rollback Strategy + +The default rollback strategy is **dist-tag rollback, then fix forward**. + +Why: + +- npm versions are immutable +- users need `npx paperclipai onboard` to recover quickly +- moving `latest` back is faster and safer than trying to delete history + +Rollback procedure: + +1. identify the last known good stable version +2. run `./scripts/rollback-latest.sh ` +3. verify `npm view paperclipai@latest version` +4. fix forward with a new stable release + +## Scripts Reference + +- [`scripts/release.sh`](../scripts/release.sh) — stable and canary npm publish flow +- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after push +- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable release +- [`scripts/docker-onboard-smoke.sh`](../scripts/docker-onboard-smoke.sh) — Docker smoke test for the installed CLI + +## Related Docs + +- [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals +- [skills/release/SKILL.md](../skills/release/SKILL.md) — agent release coordination workflow +- [skills/release-changelog/SKILL.md](../skills/release-changelog/SKILL.md) — stable changelog drafting workflow diff --git a/package.json b/package.json index e19fb785..737438ec 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts", "build:npm": "./scripts/build-npm.sh", "release": "./scripts/release.sh", + "release:github": "./scripts/create-github-release.sh", + "release:rollback": "./scripts/rollback-latest.sh", "changeset": "changeset", "version-packages": "changeset version", "check:tokens": "node scripts/check-forbidden-tokens.mjs", diff --git a/scripts/create-github-release.sh b/scripts/create-github-release.sh new file mode 100755 index 00000000..4d1d0789 --- /dev/null +++ b/scripts/create-github-release.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +dry_run=false +version="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/create-github-release.sh [--dry-run] + +Examples: + ./scripts/create-github-release.sh 1.2.3 + ./scripts/create-github-release.sh 1.2.3 --dry-run + +Notes: + - Run this after pushing the release commit and tag. + - If the release already exists, this script updates its title and notes. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) dry_run=true ;; + -h|--help) + usage + exit 0 + ;; + *) + if [ -n "$version" ]; then + echo "Error: only one version may be provided." >&2 + exit 1 + fi + version="$1" + ;; + esac + shift +done + +if [ -z "$version" ]; then + usage + exit 1 +fi + +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: version must be a stable semver like 1.2.3." >&2 + exit 1 +fi + +tag="v$version" +notes_file="$REPO_ROOT/releases/${tag}.md" + +if ! command -v gh >/dev/null 2>&1; then + echo "Error: gh CLI is required to create GitHub releases." >&2 + exit 1 +fi + +if [ ! -f "$notes_file" ]; then + echo "Error: release notes file not found at $notes_file." >&2 + exit 1 +fi + +if ! git -C "$REPO_ROOT" rev-parse "$tag" >/dev/null 2>&1; then + echo "Error: local git tag $tag does not exist." >&2 + exit 1 +fi + +if [ "$dry_run" = true ]; then + echo "[dry-run] gh release create $tag --title $tag --notes-file $notes_file" + exit 0 +fi + +if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then + echo "Error: remote tag $tag was not found on origin. Push the release commit and tag first." >&2 + exit 1 +fi + +if gh release view "$tag" >/dev/null 2>&1; then + gh release edit "$tag" --title "$tag" --notes-file "$notes_file" + echo "Updated GitHub Release $tag" +else + gh release create "$tag" --title "$tag" --notes-file "$notes_file" + echo "Created GitHub Release $tag" +fi diff --git a/scripts/release.sh b/scripts/release.sh index 3668d87c..4908912c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,420 +1,460 @@ #!/usr/bin/env bash set -euo pipefail -# release.sh — One-command version bump, build, and publish via Changesets. +# release.sh — Prepare and publish a Paperclip release. # -# Usage: -# ./scripts/release.sh patch # 0.2.0 → 0.2.1 -# ./scripts/release.sh minor # 0.2.0 → 0.3.0 -# ./scripts/release.sh major # 0.2.0 → 1.0.0 -# ./scripts/release.sh patch --dry-run # everything except npm publish -# ./scripts/release.sh patch --canary # publish under @canary tag, no commit/tag -# ./scripts/release.sh patch --canary --dry-run -# ./scripts/release.sh --promote 0.2.8 # promote canary to @latest + commit/tag -# ./scripts/release.sh --promote 0.2.8 --dry-run +# Stable release: +# ./scripts/release.sh patch +# ./scripts/release.sh minor --dry-run # -# Steps (normal): -# 1. Preflight checks (clean tree, npm login) -# 2. Auto-create a changeset for all public packages -# 3. Run changeset version (bumps versions, generates CHANGELOGs) -# 4. Build all packages -# 5. Build CLI bundle (esbuild) -# 6. Publish to npm via changeset publish (unless --dry-run) -# 7. Commit and tag +# Canary release: +# ./scripts/release.sh patch --canary +# ./scripts/release.sh minor --canary --dry-run # -# --canary: Steps 1-5 unchanged, Step 6 publishes with --tag canary, Step 7 skipped. -# --promote: Skips Steps 1-6, promotes canary to latest, then commits and tags. +# Canary releases publish prerelease versions such as 1.2.3-canary.0 under the +# npm dist-tag "canary". Stable releases publish 1.2.3 under "latest". REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" CLI_DIR="$REPO_ROOT/cli" - -# ── Helper: create GitHub Release ──────────────────────────────────────────── -create_github_release() { - local version="$1" - local is_dry_run="$2" - local release_notes="$REPO_ROOT/releases/v${version}.md" - - if [ "$is_dry_run" = true ]; then - echo " [dry-run] gh release create v$version" - return - fi - - if ! command -v gh &>/dev/null; then - echo " ⚠ gh CLI not found — skipping GitHub Release" - return - fi - - local gh_args=(gh release create "v$version" --title "v$version") - if [ -f "$release_notes" ]; then - gh_args+=(--notes-file "$release_notes") - else - gh_args+=(--generate-notes) - fi - - if "${gh_args[@]}"; then - echo " ✓ Created GitHub Release v$version" - else - echo " ⚠ GitHub Release creation failed (non-fatal)" - fi -} - -# ── Parse args ──────────────────────────────────────────────────────────────── +TEMP_CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md" +TEMP_PRE_FILE="$REPO_ROOT/.changeset/pre.json" dry_run=false canary=false -promote=false -promote_version="" bump_type="" +cleanup_on_exit=false + +usage() { + cat <<'EOF' +Usage: + ./scripts/release.sh [--canary] [--dry-run] + +Examples: + ./scripts/release.sh patch + ./scripts/release.sh minor --dry-run + ./scripts/release.sh patch --canary + ./scripts/release.sh minor --canary --dry-run + +Notes: + - Canary publishes prerelease versions like 1.2.3-canary.0 under the npm + dist-tag "canary". + - Stable publishes 1.2.3 under the npm dist-tag "latest". + - Dry runs leave the working tree clean. +EOF +} + while [ $# -gt 0 ]; do case "$1" in --dry-run) dry_run=true ;; --canary) canary=true ;; + -h|--help) + usage + exit 0 + ;; --promote) - promote=true - shift - if [ $# -eq 0 ] || [[ "$1" == --* ]]; then - echo "Error: --promote requires a version argument (e.g. --promote 0.2.8)" + echo "Error: --promote was removed. Re-run a stable release from the vetted commit instead." + exit 1 + ;; + *) + if [ -n "$bump_type" ]; then + echo "Error: only one bump type may be provided." exit 1 fi - promote_version="$1" + bump_type="$1" ;; - *) bump_type="$1" ;; esac shift done -if [ "$promote" = true ] && [ "$canary" = true ]; then - echo "Error: --canary and --promote cannot be used together" +if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then + usage exit 1 fi -if [ "$promote" = false ]; then - if [ -z "$bump_type" ]; then - echo "Usage: $0 [--dry-run] [--canary]" - echo " $0 --promote [--dry-run]" - exit 1 - fi - - if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then - echo "Error: bump type must be patch, minor, or major (got '$bump_type')" - exit 1 - fi -fi - -# ── Promote mode (skips Steps 1-6) ─────────────────────────────────────────── - -if [ "$promote" = true ]; then - NEW_VERSION="$promote_version" - echo "" - echo "==> Promote mode: promoting v$NEW_VERSION from canary to latest..." - - # Get all publishable package names - PACKAGES=$(node -e " -const { readFileSync } = require('fs'); -const { resolve } = require('path'); -const root = '$REPO_ROOT'; -const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway', - 'server', 'cli']; -const names = []; -for (const d of dirs) { - try { - const pkg = JSON.parse(readFileSync(resolve(root, d, 'package.json'), 'utf8')); - if (!pkg.private) names.push(pkg.name); - } catch {} +info() { + echo "$@" } -console.log(names.join('\n')); -") - echo "" - echo " Promoting packages to @latest:" - while IFS= read -r pkg; do - if [ "$dry_run" = true ]; then - echo " [dry-run] npm dist-tag add ${pkg}@${NEW_VERSION} latest" - else - npm dist-tag add "${pkg}@${NEW_VERSION}" latest - echo " ✓ ${pkg}@${NEW_VERSION} → latest" - fi - done <<< "$PACKAGES" +fail() { + echo "Error: $*" >&2 + exit 1 +} - # Restore CLI dev package.json if present +restore_publish_artifacts() { if [ -f "$CLI_DIR/package.dev.json" ]; then mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json" - echo " ✓ Restored workspace dependencies in cli/package.json" fi - # Remove the README copied for npm publishing - if [ -f "$CLI_DIR/README.md" ]; then - rm "$CLI_DIR/README.md" - fi - - # Remove temporary build artifacts + rm -f "$CLI_DIR/README.md" rm -rf "$REPO_ROOT/server/ui-dist" + for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do rm -rf "$REPO_ROOT/$pkg_dir/skills" done - - # Stage release files, commit, and tag - echo "" - echo " Committing and tagging v$NEW_VERSION..." - if [ "$dry_run" = true ]; then - echo " [dry-run] git add + commit + tag v$NEW_VERSION" - else - git add \ - .changeset/ \ - '**/CHANGELOG.md' \ - '**/package.json' \ - cli/src/index.ts - git commit -m "chore: release v$NEW_VERSION" - git tag "v$NEW_VERSION" - echo " ✓ Committed and tagged v$NEW_VERSION" - fi - - create_github_release "$NEW_VERSION" "$dry_run" - - echo "" - if [ "$dry_run" = true ]; then - echo "Dry run complete for promote v$NEW_VERSION." - echo " - Would promote all packages to @latest" - echo " - Would commit and tag v$NEW_VERSION" - echo " - Would create GitHub Release" - else - echo "Promoted all packages to @latest at v$NEW_VERSION" - echo "" - echo "Verify: npm view paperclipai@latest version" - echo "" - echo "To push:" - echo " git push && git push origin v$NEW_VERSION" - fi - exit 0 -fi - -# ── Step 1: Preflight checks ───────────────────────────────────────────────── - -echo "" -echo "==> Step 1/7: Preflight checks..." - -if [ "$dry_run" = false ]; then - if ! npm whoami &>/dev/null; then - echo "Error: Not logged in to npm. Run 'npm login' first." - exit 1 - fi - echo " ✓ Logged in to npm as $(npm whoami)" -fi - -if ! git -C "$REPO_ROOT" diff --quiet || ! git -C "$REPO_ROOT" diff --cached --quiet; then - echo "Error: Working tree has uncommitted changes. Commit or stash them first." - exit 1 -fi -echo " ✓ Working tree is clean" - -# ── Step 2: Auto-create changeset ──────────────────────────────────────────── - -echo "" -echo "==> Step 2/7: Creating changeset ($bump_type bump for all packages)..." - -# Get all publishable (non-private) package names -PACKAGES=$(node -e " -const { readdirSync, readFileSync } = require('fs'); -const { resolve } = require('path'); -const root = '$REPO_ROOT'; -const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8'); -const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway', - 'server', 'cli']; -const names = []; -for (const d of dirs) { - try { - const pkg = JSON.parse(readFileSync(resolve(root, d, 'package.json'), 'utf8')); - if (!pkg.private) names.push(pkg.name); - } catch {} } -console.log(names.join('\n')); -") -# Write a changeset file -CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md" +cleanup_release_state() { + restore_publish_artifacts + + rm -f "$TEMP_CHANGESET_FILE" "$TEMP_PRE_FILE" + + if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then + git -C "$REPO_ROOT" restore --source=HEAD --staged --worktree . + rm -f "$TEMP_CHANGESET_FILE" "$TEMP_PRE_FILE" + fi +} + +if [ "$cleanup_on_exit" = true ]; then + trap cleanup_release_state EXIT +fi + +set_cleanup_trap() { + cleanup_on_exit=true + trap cleanup_release_state EXIT +} + +require_clean_worktree() { + if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then + fail "working tree is not clean. Commit, stash, or remove changes before releasing." + fi +} + +require_npm_publish_auth() { + if [ "$dry_run" = true ]; then + return + fi + + if npm whoami >/dev/null 2>&1; then + info " ✓ Logged in to npm as $(npm whoami)" + return + fi + + if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing" + return + fi + + fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow." +} + +list_public_package_info() { + node - "$REPO_ROOT" <<'NODE' +const fs = require('fs'); +const path = require('path'); + +const root = process.argv[2]; +const roots = ['packages', 'server', 'ui', 'cli']; +const seen = new Set(); +const rows = []; + +function walk(relDir) { + const absDir = path.join(root, relDir); + const pkgPath = path.join(absDir, 'package.json'); + + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (!pkg.private) { + rows.push([relDir, pkg.name]); + } + return; + } + + if (!fs.existsSync(absDir)) { + return; + } + + for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue; + walk(path.join(relDir, entry.name)); + } +} + +for (const rel of roots) { + walk(rel); +} + +rows.sort((a, b) => a[0].localeCompare(b[0])); + +for (const [dir, name] of rows) { + const key = `${dir}\t${name}`; + if (seen.has(key)) continue; + seen.add(key); + process.stdout.write(`${dir}\t${name}\n`); +} +NODE +} + +compute_bumped_version() { + node - "$1" "$2" <<'NODE' +const current = process.argv[2]; +const bump = process.argv[3]; +const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/); + +if (!match) { + throw new Error(`invalid semver version: ${current}`); +} + +let [major, minor, patch] = match.slice(1).map(Number); + +if (bump === 'patch') { + patch += 1; +} else if (bump === 'minor') { + minor += 1; + patch = 0; +} else if (bump === 'major') { + major += 1; + minor = 0; + patch = 0; +} else { + throw new Error(`unsupported bump type: ${bump}`); +} + +process.stdout.write(`${major}.${minor}.${patch}`); +NODE +} + +next_canary_version() { + local stable_version="$1" + local versions_json + + versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')" + + node - "$stable_version" "$versions_json" <<'NODE' +const stable = process.argv[2]; +const versionsArg = process.argv[3]; + +let versions = []; +try { + const parsed = JSON.parse(versionsArg); + versions = Array.isArray(parsed) ? parsed : [parsed]; +} catch { + versions = []; +} + +const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`); +let max = -1; + +for (const version of versions) { + const match = version.match(pattern); + if (!match) continue; + max = Math.max(max, Number(match[1])); +} + +process.stdout.write(`${stable}-canary.${max + 1}`); +NODE +} + +replace_version_string() { + local from_version="$1" + local to_version="$2" + + node - "$REPO_ROOT" "$from_version" "$to_version" <<'NODE' +const fs = require('fs'); +const path = require('path'); + +const root = process.argv[2]; +const fromVersion = process.argv[3]; +const toVersion = process.argv[4]; + +const roots = ['packages', 'server', 'ui', 'cli']; +const targets = new Set(['package.json', 'CHANGELOG.md']); +const extraFiles = [path.join('cli', 'src', 'index.ts')]; + +function rewriteFile(filePath) { + if (!fs.existsSync(filePath)) return; + const current = fs.readFileSync(filePath, 'utf8'); + if (!current.includes(fromVersion)) return; + fs.writeFileSync(filePath, current.split(fromVersion).join(toVersion)); +} + +function walk(relDir) { + const absDir = path.join(root, relDir); + if (!fs.existsSync(absDir)) return; + + for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue; + walk(path.join(relDir, entry.name)); + continue; + } + + if (targets.has(entry.name)) { + rewriteFile(path.join(absDir, entry.name)); + } + } +} + +for (const rel of roots) { + walk(rel); +} + +for (const relFile of extraFiles) { + rewriteFile(path.join(root, relFile)); +} +NODE +} + +LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)" +CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}" +if [ -z "$CURRENT_STABLE_VERSION" ]; then + CURRENT_STABLE_VERSION="0.0.0" +fi + +TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")" +TARGET_PUBLISH_VERSION="$TARGET_STABLE_VERSION" + +if [ "$canary" = true ]; then + TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" +fi + +PUBLIC_PACKAGE_INFO="$(list_public_package_info)" +PUBLIC_PACKAGE_NAMES="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)" +PUBLIC_PACKAGE_DIRS="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f1)" + +if [ -z "$PUBLIC_PACKAGE_INFO" ]; then + fail "no public packages were found in the workspace." +fi + +info "" +info "==> Release plan" +info " Last stable tag: ${LAST_STABLE_TAG:-}" +info " Current stable version: $CURRENT_STABLE_VERSION" +if [ "$canary" = true ]; then + info " Target stable version: $TARGET_STABLE_VERSION" + info " Canary version: $TARGET_PUBLISH_VERSION" +else + info " Stable version: $TARGET_STABLE_VERSION" +fi + +info "" +info "==> Step 1/7: Preflight checks..." +require_clean_worktree +info " ✓ Working tree is clean" +require_npm_publish_auth + +if [ "$dry_run" = true ] || [ "$canary" = true ]; then + set_cleanup_trap +fi + +info "" +info "==> Step 2/7: Creating release changeset..." { echo "---" - while IFS= read -r pkg; do - echo "\"$pkg\": $bump_type" - done <<< "$PACKAGES" + while IFS= read -r pkg_name; do + [ -z "$pkg_name" ] && continue + echo "\"$pkg_name\": $bump_type" + done <<< "$PUBLIC_PACKAGE_NAMES" echo "---" echo "" - echo "Version bump ($bump_type)" -} > "$CHANGESET_FILE" + if [ "$canary" = true ]; then + echo "Canary release preparation for $TARGET_STABLE_VERSION" + else + echo "Stable release preparation for $TARGET_STABLE_VERSION" + fi +} > "$TEMP_CHANGESET_FILE" +info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages" -echo " ✓ Created changeset for $(echo "$PACKAGES" | wc -l | xargs) packages" - -# ── Step 3: Version packages ───────────────────────────────────────────────── - -echo "" -echo "==> Step 3/7: Running changeset version..." +info "" +info "==> Step 3/7: Versioning packages..." cd "$REPO_ROOT" +if [ "$canary" = true ]; then + npx changeset pre enter canary +fi npx changeset version -echo " ✓ Versions bumped and CHANGELOGs generated" -# Read the new version from the CLI package -NEW_VERSION=$(node -e "console.log(require('$CLI_DIR/package.json').version)") -echo " New version: $NEW_VERSION" - -# Update the version string in cli/src/index.ts -CURRENT_VERSION_IN_SRC=$(sed -n 's/.*\.version("\([^"]*\)".*/\1/p' "$CLI_DIR/src/index.ts" | head -1) -if [ -n "$CURRENT_VERSION_IN_SRC" ] && [ "$CURRENT_VERSION_IN_SRC" != "$NEW_VERSION" ]; then - sed -i '' "s/\.version(\"$CURRENT_VERSION_IN_SRC\")/\.version(\"$NEW_VERSION\")/" "$CLI_DIR/src/index.ts" - echo " ✓ Updated cli/src/index.ts version to $NEW_VERSION" +if [ "$canary" = true ]; then + BASE_CANARY_VERSION="${TARGET_STABLE_VERSION}-canary.0" + if [ "$TARGET_PUBLISH_VERSION" != "$BASE_CANARY_VERSION" ]; then + replace_version_string "$BASE_CANARY_VERSION" "$TARGET_PUBLISH_VERSION" + fi fi -# ── Step 4: Build packages ─────────────────────────────────────────────────── +VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")" +if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then + fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE." +fi +info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION" -echo "" -echo "==> Step 4/7: Building all packages..." +info "" +info "==> Step 4/7: Building workspace artifacts..." cd "$REPO_ROOT" - -# Build packages in dependency order (excluding CLI) -pnpm --filter @paperclipai/shared build -pnpm --filter @paperclipai/adapter-utils build -pnpm --filter @paperclipai/db build -pnpm --filter @paperclipai/adapter-claude-local build -pnpm --filter @paperclipai/adapter-codex-local build -pnpm --filter @paperclipai/adapter-opencode-local build -pnpm --filter @paperclipai/adapter-openclaw-gateway build -pnpm --filter @paperclipai/server build - -# Build UI and bundle into server package for static serving +pnpm build bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh" - -# Bundle skills into packages that need them (adapters + server) for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do rm -rf "$REPO_ROOT/$pkg_dir/skills" cp -r "$REPO_ROOT/skills" "$REPO_ROOT/$pkg_dir/skills" done -echo " ✓ All packages built (including UI + skills)" +info " ✓ Workspace build complete" -# ── Step 5: Build CLI bundle ───────────────────────────────────────────────── - -echo "" -echo "==> Step 5/7: Building CLI bundle..." -cd "$REPO_ROOT" +info "" +info "==> Step 5/7: Building publishable CLI bundle..." "$REPO_ROOT/scripts/build-npm.sh" --skip-checks -echo " ✓ CLI bundled" - -# ── Step 6: Publish ────────────────────────────────────────────────────────── +info " ✓ CLI bundle ready" +info "" if [ "$dry_run" = true ]; then - echo "" - if [ "$canary" = true ]; then - echo "==> Step 6/7: Skipping publish (--dry-run, --canary)" - else - echo "==> Step 6/7: Skipping publish (--dry-run)" - fi - echo "" - echo " Preview what would be published:" - for dir in packages/shared packages/adapter-utils packages/db \ - packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw-gateway \ - server cli; do - echo " --- $dir ---" - cd "$REPO_ROOT/$dir" + info "==> Step 6/7: Previewing publish payloads (--dry-run)..." + while IFS= read -r pkg_dir; do + [ -z "$pkg_dir" ] && continue + info " --- $pkg_dir ---" + cd "$REPO_ROOT/$pkg_dir" npm pack --dry-run 2>&1 | tail -3 - done + done <<< "$PUBLIC_PACKAGE_DIRS" cd "$REPO_ROOT" if [ "$canary" = true ]; then - echo "" - echo " [dry-run] Would publish with: npx changeset publish --tag canary" + info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary" + else + info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest" fi else - echo "" if [ "$canary" = true ]; then - echo "==> Step 6/7: Publishing to npm (canary)..." - cd "$REPO_ROOT" + info "==> Step 6/7: Publishing canary to npm..." npx changeset publish --tag canary - echo " ✓ Published all packages under @canary tag" + info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary" else - echo "==> Step 6/7: Publishing to npm..." - cd "$REPO_ROOT" + info "==> Step 6/7: Publishing stable release to npm..." npx changeset publish - echo " ✓ Published all packages" + info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest" fi fi -# ── Step 7: Restore CLI dev package.json and commit ────────────────────────── - -echo "" -if [ "$canary" = true ]; then - echo "==> Step 7/7: Skipping commit and tag (canary mode — promote later)..." +info "" +if [ "$dry_run" = true ]; then + info "==> Step 7/7: Cleaning up dry-run state..." + info " ✓ Dry run leaves the working tree unchanged" +elif [ "$canary" = true ]; then + info "==> Step 7/7: Cleaning up canary state..." + info " ✓ Canary state will be discarded after publish" else - echo "==> Step 7/7: Restoring dev package.json, committing, and tagging..." -fi -cd "$REPO_ROOT" + info "==> Step 7/7: Finalizing stable release commit..." + restore_publish_artifacts -# Restore the dev package.json (build-npm.sh backs it up) -if [ -f "$CLI_DIR/package.dev.json" ]; then - mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json" - echo " ✓ Restored workspace dependencies in cli/package.json" + git -C "$REPO_ROOT" add -u .changeset packages server cli + if [ -f "$REPO_ROOT/releases/v${TARGET_STABLE_VERSION}.md" ]; then + git -C "$REPO_ROOT" add "releases/v${TARGET_STABLE_VERSION}.md" + fi + + git -C "$REPO_ROOT" commit -m "chore: release v$TARGET_STABLE_VERSION" + git -C "$REPO_ROOT" tag "v$TARGET_STABLE_VERSION" + info " ✓ Created commit and tag v$TARGET_STABLE_VERSION" fi -# Remove the README copied for npm publishing -if [ -f "$CLI_DIR/README.md" ]; then - rm "$CLI_DIR/README.md" -fi - -# Remove temporary build artifacts before committing (these are only needed during publish) -rm -rf "$REPO_ROOT/server/ui-dist" -for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do - rm -rf "$REPO_ROOT/$pkg_dir/skills" -done - -if [ "$canary" = false ]; then - # Stage only release-related files (avoid sweeping unrelated changes with -A) - git add \ - .changeset/ \ - '**/CHANGELOG.md' \ - '**/package.json' \ - cli/src/index.ts - git commit -m "chore: release v$NEW_VERSION" - git tag "v$NEW_VERSION" - echo " ✓ Committed and tagged v$NEW_VERSION" -fi - -if [ "$canary" = false ]; then - create_github_release "$NEW_VERSION" "$dry_run" -fi - -# ── Done ────────────────────────────────────────────────────────────────────── - -echo "" -if [ "$canary" = true ]; then - if [ "$dry_run" = true ]; then - echo "Dry run complete for canary v$NEW_VERSION." - echo " - Versions bumped, built, and previewed" - echo " - Dev package.json restored" - echo " - No commit or tag (canary mode)" - echo "" - echo "To actually publish canary, run:" - echo " ./scripts/release.sh $bump_type --canary" +info "" +if [ "$dry_run" = true ]; then + if [ "$canary" = true ]; then + info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}." else - echo "Published canary at v$NEW_VERSION" - echo "" - echo "Verify: npm view paperclipai@canary version" - echo "" - echo "To promote to latest:" - echo " ./scripts/release.sh --promote $NEW_VERSION" + info "Dry run complete for stable v${TARGET_STABLE_VERSION}." fi -elif [ "$dry_run" = true ]; then - echo "Dry run complete for v$NEW_VERSION." - echo " - Versions bumped, built, and previewed" - echo " - Dev package.json restored" - echo " - Commit and tag created (locally)" - echo " - Would create GitHub Release" - echo "" - echo "To actually publish, run:" - echo " ./scripts/release.sh $bump_type" +elif [ "$canary" = true ]; then + info "Published canary ${TARGET_PUBLISH_VERSION}." + info "Install with: npx paperclipai@canary onboard" + info "Stable version remains: $CURRENT_STABLE_VERSION" else - echo "Published all packages at v$NEW_VERSION" - echo "" - echo "To push:" - echo " git push && git push origin v$NEW_VERSION" - echo "" - echo "GitHub Release: https://github.com/cryppadotta/paperclip/releases/tag/v$NEW_VERSION" + info "Published stable v${TARGET_STABLE_VERSION}." + info "Next steps:" + info " git push origin HEAD:master --follow-tags" + info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION" fi diff --git a/scripts/rollback-latest.sh b/scripts/rollback-latest.sh new file mode 100755 index 00000000..a00da984 --- /dev/null +++ b/scripts/rollback-latest.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +dry_run=false +version="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/rollback-latest.sh [--dry-run] + +Examples: + ./scripts/rollback-latest.sh 1.2.2 + ./scripts/rollback-latest.sh 1.2.2 --dry-run + +Notes: + - This repoints the npm dist-tag "latest" for every public package. + - It does not unpublish anything. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) dry_run=true ;; + -h|--help) + usage + exit 0 + ;; + *) + if [ -n "$version" ]; then + echo "Error: only one version may be provided." >&2 + exit 1 + fi + version="$1" + ;; + esac + shift +done + +if [ -z "$version" ]; then + usage + exit 1 +fi + +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: version must be a stable semver like 1.2.2." >&2 + exit 1 +fi + +if [ "$dry_run" = false ] && ! npm whoami >/dev/null 2>&1; then + echo "Error: npm publish rights are required. Run 'npm login' first." >&2 + exit 1 +fi + +list_public_package_names() { + node - "$REPO_ROOT" <<'NODE' +const fs = require('fs'); +const path = require('path'); + +const root = process.argv[2]; +const roots = ['packages', 'server', 'ui', 'cli']; +const seen = new Set(); + +function walk(relDir) { + const absDir = path.join(root, relDir); + const pkgPath = path.join(absDir, 'package.json'); + + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (!pkg.private && !seen.has(pkg.name)) { + seen.add(pkg.name); + process.stdout.write(`${pkg.name}\n`); + } + return; + } + + if (!fs.existsSync(absDir)) { + return; + } + + for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue; + walk(path.join(relDir, entry.name)); + } +} + +for (const rel of roots) { + walk(rel); +} +NODE +} + +package_names="$(list_public_package_names)" + +if [ -z "$package_names" ]; then + echo "Error: no public packages were found in the workspace." >&2 + exit 1 +fi + +while IFS= read -r package_name; do + [ -z "$package_name" ] && continue + if [ "$dry_run" = true ]; then + echo "[dry-run] npm dist-tag add ${package_name}@${version} latest" + else + npm dist-tag add "${package_name}@${version}" latest + echo "Updated latest -> ${package_name}@${version}" + fi +done <<< "$package_names" diff --git a/skills/release-changelog/SKILL.md b/skills/release-changelog/SKILL.md index d28fa931..b70b97f5 100644 --- a/skills/release-changelog/SKILL.md +++ b/skills/release-changelog/SKILL.md @@ -1,363 +1,140 @@ --- name: release-changelog description: > - Generate user-facing release changelogs for Paperclip. Reads git history, - merged PRs, and changeset files since the last release tag. Detects breaking - changes, categorizes changes, and outputs structured markdown to - releases/v{version}.md. Use when preparing a release or when asked to - generate a changelog. + Generate the stable Paperclip release changelog at releases/v{version}.md by + reading commits, changesets, and merged PR context since the last stable tag. --- # Release Changelog Skill -Generate a user-facing changelog for a new Paperclip release. This skill reads -the commit history, changeset files, and merged PRs since the last release tag, -detects breaking changes, categorizes everything, and writes a structured -release notes file. +Generate the user-facing changelog for the **stable** Paperclip release. -**Output:** `releases/v{version}.md` in the repo root. -**Review required:** Always present the draft for human sign-off before -finalizing. Never auto-publish. +Output: ---- +- `releases/v{version}.md` + +Important rule: + +- even if there are canary releases such as `1.2.3-canary.0`, the changelog file stays `releases/v1.2.3.md` ## Step 0 — Idempotency Check -Before generating anything, check if a changelog already exists for this version: +Before generating anything, check whether the file already exists: ```bash ls releases/v{version}.md 2>/dev/null ``` -**If the file already exists:** +If it exists: -1. Read the existing changelog and present it to the reviewer. -2. Ask: "A changelog for v{version} already exists. Do you want to (a) keep it - as-is, (b) regenerate from scratch, or (c) update specific sections?" -3. If the reviewer says keep it → **stop here**. Do not overwrite. This skill is - done. -4. If the reviewer says regenerate → back up the existing file to - `releases/v{version}.md.prev`, then proceed from Step 1. -5. If the reviewer says update → read the existing file, proceed through Steps - 1-4 to gather fresh data, then merge changes into the existing file rather - than replacing it wholesale. Preserve any manual edits the reviewer previously - made. +1. read it first +2. present it to the reviewer +3. ask whether to keep it, regenerate it, or update specific sections +4. never overwrite it silently -**If the file does not exist:** Proceed normally from Step 1. +## Step 1 — Determine the Stable Range -**Critical rule:** This skill NEVER triggers a version bump. It only reads git -history and writes a markdown file. The `release.sh` script is the only thing -that bumps versions, and it is called separately by the `release` coordination -skill. Running this skill multiple times is always safe — worst case it -overwrites a draft changelog (with reviewer permission). - ---- - -## Step 1 — Determine the Release Range - -Find the last release tag and the planned version: +Find the last stable tag: ```bash -# Last release tag (most recent semver tag) -git tag --sort=-version:refname | head -1 -# e.g. v0.2.7 - -# All commits since that tag -git log v0.2.7..HEAD --oneline --no-merges +git tag --list 'v*' --sort=-version:refname | head -1 +git log v{last}..HEAD --oneline --no-merges ``` -If no tag exists yet, use the initial commit as the base. +The planned stable version comes from one of: -The new version number comes from one of: -- An explicit argument (e.g. "generate changelog for v0.3.0") -- The bump type (patch/minor/major) applied to the last tag -- The version already set in `cli/package.json` if `scripts/release.sh` has been run +- an explicit maintainer request +- the chosen bump type applied to the last stable tag +- the release plan already agreed in `doc/RELEASING.md` ---- +Do not derive the changelog version from a canary tag or prerelease suffix. -## Step 2 — Gather Raw Change Data +## Step 2 — Gather the Raw Inputs -Collect changes from three sources, in priority order: +Collect release data from: -### 2a. Git Commits +1. git commits since the last stable tag +2. `.changeset/*.md` files +3. merged PRs via `gh` when available + +Useful commands: ```bash git log v{last}..HEAD --oneline --no-merges -git log v{last}..HEAD --format="%H %s" --no-merges # full SHAs for file diffs -``` - -### 2b. Changeset Files - -Look for unconsumed changesets in `.changeset/`: - -```bash +git log v{last}..HEAD --format="%H %s" --no-merges ls .changeset/*.md | grep -v README.md -``` - -Each changeset file has YAML frontmatter with package names and bump types -(`patch`, `minor`, `major`), followed by a description. Parse these — the bump -type is a strong categorization signal, and the description may contain -user-facing summaries. - -### 2c. Merged PRs (when available) - -If GitHub access is available via `gh`: - -```bash gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels ``` -PR titles and bodies are often the best source of user-facing descriptions. -Prefer PR descriptions over raw commit messages when both are available. - ---- - ## Step 3 — Detect Breaking Changes -Scan for breaking changes using these signals. **Any match flags the release as -containing breaking changes**, which affects version bump requirements and -changelog structure. +Look for: -### 3a. Migration Files +- destructive migrations +- removed or changed API fields/endpoints +- renamed or removed config keys +- `major` changesets +- `BREAKING:` or `BREAKING CHANGE:` commit signals -Check for new migration files since the last tag: +Key commands: ```bash git diff --name-only v{last}..HEAD -- packages/db/src/migrations/ -``` - -- **New migration files exist** = DB migration required in upgrade. -- Inspect migration content: look for `DROP`, `ALTER ... DROP`, `RENAME` to - distinguish destructive vs. additive migrations. -- Additive-only migrations (new tables, new nullable columns, new indexes) are - safe but should still be mentioned. -- Destructive migrations (column drops, type changes, table drops) = breaking. - -### 3b. Schema Changes - -```bash git diff v{last}..HEAD -- packages/db/src/schema/ -``` - -Look for: -- Removed or renamed columns/tables -- Changed column types -- Removed default values or nullable constraints -- These indicate breaking DB changes even if no explicit migration file exists - -### 3c. API Route Changes - -```bash git diff v{last}..HEAD -- server/src/routes/ server/src/api/ +git log v{last}..HEAD --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true ``` -Look for: -- Removed endpoints -- Changed request/response shapes (removed fields, type changes) -- Changed authentication requirements +If the requested bump is lower than the minimum required bump, flag that before the release proceeds. -### 3d. Config Changes +## Step 4 — Categorize for Users -```bash -git diff v{last}..HEAD -- cli/src/config/ packages/*/src/*config* -``` +Use these stable changelog sections: -Look for renamed, removed, or restructured configuration keys. +- `Breaking Changes` +- `Highlights` +- `Improvements` +- `Fixes` +- `Upgrade Guide` when needed -### 3e. Changeset Severity +Exclude purely internal refactors, CI changes, and docs-only work unless they materially affect users. -Any `.changeset/*.md` file with a `major` bump = explicitly flagged breaking. +Guidelines: -### 3f. Commit Conventions +- group related commits into one user-facing entry +- write from the user perspective +- keep highlights short and concrete +- spell out upgrade actions for breaking changes -Scan commit messages for: -- `BREAKING:` or `BREAKING CHANGE:` prefix -- `!` after the type in conventional commits (e.g. `feat!:`, `fix!:`) +## Step 5 — Write the File -### Version Bump Rules - -| Condition | Minimum Bump | -|---|---| -| Destructive migration (DROP, RENAME) | `major` | -| Removed API endpoints or fields | `major` | -| Any `major` changeset or `BREAKING:` commit | `major` | -| New (additive) migration | `minor` | -| New features (`feat:` commits, `minor` changesets) | `minor` | -| Bug fixes only | `patch` | - -If the planned bump is lower than the minimum required, **warn the reviewer** -and recommend the correct bump level. - ---- - -## Step 4 — Categorize Changes - -Assign every meaningful change to one of these categories: - -| Category | What Goes Here | Shows in User Notes? | -|---|---|---| -| **Breaking Changes** | Anything requiring user action to upgrade | Yes (top, with warning) | -| **Highlights** | New user-visible features, major behavioral changes | Yes (with 1-2 sentence descriptions) | -| **Improvements** | Enhancements to existing features | Yes (bullet list) | -| **Fixes** | Bug fixes | Yes (bullet list) | -| **Internal** | Refactoring, deps, CI, tests, docs | No (dev changelog only) | - -### Categorization Heuristics - -Use these signals to auto-categorize. When signals conflict, prefer the -higher-visibility category and flag for human review. - -| Signal | Category | -|---|---| -| Commit touches migration files, schema changes | Breaking Change (if destructive) | -| Changeset marked `major` | Breaking Change | -| Commit message has `BREAKING:` or `!:` | Breaking Change | -| New UI components, new routes, new API endpoints | Highlight | -| Commit message starts with `feat:` or `add:` | Highlight or Improvement | -| Changeset marked `minor` | Highlight | -| Commit message starts with `fix:` or `bug:` | Fix | -| Changeset marked `patch` | Fix or Improvement | -| Commit message starts with `chore:`, `refactor:`, `ci:`, `test:`, `docs:` | Internal | -| PR has detailed body with user-facing description | Use PR body as the description | - -### Writing Good Descriptions - -- **Highlights** get 1-2 sentence descriptions explaining the user benefit. - Write from the user's perspective ("You can now..." not "Added a component that..."). -- **Improvements and Fixes** are concise bullet points. -- **Breaking Changes** get detailed descriptions including what changed, - why, and what the user needs to do. -- Group related commits into a single changelog entry. Five commits implementing - one feature = one Highlight entry, not five bullets. -- Omit purely internal changes from user-facing notes entirely. - ---- - -## Step 5 — Write the Changelog - -Output the changelog to `releases/v{version}.md` using this template: +Template: ```markdown # v{version} > Released: {YYYY-MM-DD} -{If breaking changes detected, include this section:} - ## Breaking Changes -> **Action required before upgrading.** Read the Upgrade Guide below. - -- **{Breaking change title}** — {What changed and why. What the user needs to do.} - ## Highlights -- **{Feature name}** — {1-2 sentence description of what it does and why it matters.} - ## Improvements -- {Concise description of improvement} - ## Fixes -- {Concise description of fix} - ---- - -{If breaking changes detected, include this section:} - ## Upgrade Guide - -### Before You Update - -1. **Back up your database.** - - SQLite: `cp paperclip.db paperclip.db.backup` - - Postgres: `pg_dump -Fc paperclip > paperclip-pre-{version}.dump` -2. **Note your current version:** `paperclip --version` - -### After Updating - -{Specific steps: run migrations, update configs, etc.} - -### Rolling Back - -If something goes wrong: -1. Restore your database backup -2. `npm install @paperclipai/server@{previous-version}` ``` -### Template Rules +Omit empty sections except `Highlights`, `Improvements`, and `Fixes`, which should usually exist. -- Omit any empty section entirely (don't show "## Fixes" with no bullets). -- The Breaking Changes section always comes first when present. -- The Upgrade Guide always comes last when present. -- Use `**bold**` for feature/change names, regular text for descriptions. -- Keep the entire changelog scannable — a busy user should get the gist from - headings and bold text alone. +## Step 6 — Review Before Release ---- +Before handing it off: -## Step 6 — Present for Review +1. confirm the heading is the stable version only +2. confirm there is no `-canary` language in the title or filename +3. confirm any breaking changes have an upgrade path +4. present the draft for human sign-off -After generating the draft: - -1. **Show the full changelog** to the reviewer (CTO or whoever triggered the release). -2. **Flag ambiguous items** — commits you weren't sure how to categorize, or - items that might be breaking but aren't clearly signaled. -3. **Flag version bump mismatches** — if the planned bump is lower than what - the changes warrant. -4. **Wait for approval** before considering the changelog final. - -If the reviewer requests edits, update `releases/v{version}.md` accordingly. - -Do not proceed to publishing, website updates, or social announcements. Those -are handled by the `release` coordination skill (separate from this one). - ---- - -## Directory Convention - -Release changelogs live in `releases/` at the repo root: - -``` -releases/ - v0.2.7.md - v0.3.0.md - ... -``` - -Each file is named `v{version}.md` matching the git tag. This directory is -committed to the repo and serves as the source of truth for release history. - -The `releases/` directory should be created with a `.gitkeep` if it doesn't -exist yet. - ---- - -## Quick Reference - -```bash -# Full workflow summary: - -# 1. Find last tag -LAST_TAG=$(git tag --sort=-version:refname | head -1) - -# 2. Commits since last tag -git log $LAST_TAG..HEAD --oneline --no-merges - -# 3. Files changed (for breaking change detection) -git diff --name-only $LAST_TAG..HEAD - -# 4. Migration changes specifically -git diff --name-only $LAST_TAG..HEAD -- packages/db/src/migrations/ - -# 5. Schema changes -git diff $LAST_TAG..HEAD -- packages/db/src/schema/ - -# 6. Unconsumed changesets -ls .changeset/*.md | grep -v README.md - -# 7. Merged PRs (if gh available) -gh pr list --state merged --search "merged:>=$(git log -1 --format=%aI $LAST_TAG)" \ - --json number,title,body,labels -``` +This skill never publishes anything. It only prepares the stable changelog artifact. diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index 4c91fffd..65468704 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -1,402 +1,234 @@ --- name: release description: > - Coordinate a full Paperclip release across engineering, website publishing, - and social announcement. Use when CTO/CEO requests "do a release" or - "release vX.Y.Z". Runs pre-flight checks, generates changelog via - release-changelog, executes npm release, creates cross-project follow-up - tasks, and posts a release wrap-up. + Coordinate a full Paperclip release across engineering verification, npm, + GitHub, website publishing, and announcement follow-up. Use when leadership + asks to ship a release, not merely to discuss version bumps. --- # Release Coordination Skill -Run the full Paperclip release process as an organizational workflow, not just -an npm publish. +Run the full Paperclip release as a maintainer workflow, not just an npm publish. This skill coordinates: -- User-facing changelog generation (`release-changelog` skill) -- Canary publish to npm (`scripts/release.sh --canary`) -- Docker smoke test of the canary (`scripts/docker-onboard-smoke.sh`) -- Promotion to `latest` after canary is verified -- Website publishing task creation -- CMO announcement task creation -- Final release summary with links ---- +- stable changelog drafting via `release-changelog` +- prerelease canary publishing via `scripts/release.sh --canary` +- Docker smoke testing via `scripts/docker-onboard-smoke.sh` +- stable publishing via `scripts/release.sh` +- pushing the release commit and tag +- GitHub Release creation via `scripts/create-github-release.sh` +- website / announcement follow-up tasks ## Trigger Use this skill when leadership asks for: -- "do a release" -- "release {patch|minor|major}" -- "release vX.Y.Z" ---- +- "do a release" +- "ship the next patch/minor/major" +- "release vX.Y.Z" ## Preconditions Before proceeding, verify all of the following: 1. `skills/release-changelog/SKILL.md` exists and is usable. -2. The `release-changelog` dependency work is complete/reviewed before running this flow. -3. App repo working tree is clean. -4. There are commits since the last release tag. -5. You have release permissions (`npm whoami` succeeds for real publish). -6. If running via Paperclip, you have issue context for posting status updates. +2. The repo working tree is clean, including untracked files. +3. There are commits since the last stable tag. +4. The release SHA has passed the verification gate or is about to. +5. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing. +6. If running through Paperclip, you have issue context for status updates and follow-up task creation. If any precondition fails, stop and report the blocker. ---- - ## Inputs Collect these inputs up front: -- Release request source issue (if in Paperclip) -- Requested bump (`patch|minor|major`) or explicit version (`vX.Y.Z`) -- Whether this run is dry-run or live publish -- Company/project context for follow-up issue creation +- requested bump: `patch`, `minor`, or `major` +- whether this run is a dry run or live release +- whether the release is being run locally or from GitHub Actions +- release issue / company context for website and announcement follow-up ---- +## Step 0 — Release Model -## Step 0 — Idempotency Guards +Paperclip now uses this release model: -Each step in this skill is designed to be safely re-runnable. Before executing -any step, check whether it has already been completed: +1. Draft the **stable** changelog as `releases/vX.Y.Z.md` +2. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0` +3. Smoke test the canary via Docker +4. Publish the stable version `X.Y.Z` +5. Push the release commit and tag +6. Create the GitHub Release +7. Complete website and announcement surfaces -| Step | How to Check | If Already Done | -|---|---|---| -| Changelog | `releases/v{version}.md` exists | Read it, ask reviewer to confirm or update. Do NOT regenerate without asking. | -| Canary publish | `npm view paperclipai@{version}` succeeds | Skip canary publish. Proceed to smoke test. | -| Smoke test | Manual or scripted verification | If canary already verified, proceed to promote. | -| Promote | `git tag v{version}` exists | Skip promotion entirely. A tag means the version is already promoted to latest. | -| Website task | Search Paperclip issues for "Publish release notes for v{version}" | Skip creation. Link the existing task. | -| CMO task | Search Paperclip issues for "release announcement tweet for v{version}" | Skip creation. Link the existing task. | +Critical consequence: -**The golden rule:** If a git tag `v{version}` already exists, the release is -fully promoted. Only post-publish tasks (website, CMO, wrap-up) should proceed. -If the version exists on npm but there's no git tag, the canary was published but -not yet promoted — resume from smoke test. +- Canaries do **not** use promote-by-dist-tag anymore. +- The changelog remains stable-only. Do not create `releases/vX.Y.Z-canary.N.md`. -**Iterating on changelogs:** You can re-run this skill with an existing changelog -to refine it _before_ the npm publish step. The `release-changelog` skill has -its own idempotency check and will ask the reviewer what to do with an existing -file. This is the expected workflow for iterating on release notes. +## Step 1 — Decide the Stable Version ---- - -## Step 1 - Pre-flight and Version Decision - -Run pre-flight in the App repo root: +Use the last stable tag as the base: ```bash -LAST_TAG=$(git tag --sort=-version:refname | head -1) -git diff --quiet && git diff --cached --quiet -git log "${LAST_TAG}..HEAD" --oneline --no-merges | head -50 -``` - -Then detect minimum required bump: - -```bash -# migrations +LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) +git log "${LAST_TAG}..HEAD" --oneline --no-merges git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/ - -# schema deltas git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/ - -# breaking commit conventions git log "${LAST_TAG}..HEAD" --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true ``` Bump policy: -- Destructive migration/API removal/major changeset/breaking commit -> `major` -- Additive migrations or clear new features -> at least `minor` -- Fixes-only -> `patch` -If requested bump is lower than required minimum, escalate bump and explain why. +- destructive migrations, removed APIs, breaking config changes -> `major` +- additive migrations or clearly user-visible features -> at least `minor` +- fixes only -> `patch` ---- +If the requested bump is too low, escalate it and explain why. -## Step 2 - Generate Changelog Draft +## Step 2 — Draft the Stable Changelog -First, check if `releases/v{version}.md` already exists. If it does, the -`release-changelog` skill will detect this and ask the reviewer whether to keep, -regenerate, or update it. **Do not silently overwrite an existing changelog.** +Invoke `release-changelog` and generate: -Invoke the `release-changelog` skill and produce: -- `releases/v{version}.md` -- Sections ordered as: Breaking Changes (if any), Highlights, Improvements, Fixes, Upgrade Guide (if any) +- `releases/vX.Y.Z.md` -Required behavior: -- Present the draft for human review. -- Flag ambiguous categorization items. -- Flag bump mismatches before publish. -- Do not publish until reviewer confirms. +Rules: ---- +- review the draft with a human before publish +- preserve manual edits if the file already exists +- keep the heading and filename stable-only, for example `v1.2.3` +- do not create a separate canary changelog file -## Step 3 — Publish Canary +## Step 3 — Verify the Release SHA -The canary is the gatekeeper: every release goes to npm as a canary first. The -`latest` tag is never touched until the canary passes smoke testing. - -**Idempotency check:** Before publishing, check if this version already exists -on npm: +Run the standard gate: ```bash -# Check if canary is already published -npm view paperclipai@{version} version 2>/dev/null && echo "ALREADY_PUBLISHED" || echo "NOT_PUBLISHED" - -# Also check git tag -git tag -l "v{version}" +pnpm -r typecheck +pnpm test:run +pnpm build ``` -- If a git tag exists → the release is already fully promoted. Skip to Step 6. -- If the version exists on npm but no git tag → canary was published but not yet - promoted. Skip to Step 4 (smoke test). -- If neither exists → proceed with canary publish. +If the release will be run through GitHub Actions, the workflow can rerun this gate. Still report whether the local tree currently passes. -### Publishing the canary +## Step 4 — Publish a Canary -Use `release.sh` with the `--canary` flag (see script changes below): +Run: ```bash -# Dry run first ./scripts/release.sh {patch|minor|major} --canary --dry-run - -# Publish canary (after dry-run review) ./scripts/release.sh {patch|minor|major} --canary ``` -This publishes all packages to npm with the `canary` dist-tag. The `latest` tag -is **not** updated. Users running `npx paperclipai onboard` still get the -previous stable version. +What this means: -After publish, verify the canary is accessible: +- npm receives `X.Y.Z-canary.N` under dist-tag `canary` +- `latest` remains unchanged +- no git tag is created +- the script cleans the working tree afterward + +After publish, verify: ```bash npm view paperclipai@canary version -# Should show the new version ``` -**How `--canary` works in release.sh:** -- Steps 1-5 are the same (preflight, changeset, version, build, CLI bundle) -- Step 6 uses `npx changeset publish --tag canary` instead of `npx changeset publish` -- Step 7 does NOT commit or tag — the commit and tag happen later in the promote - step, only after smoke testing passes +The user install path is: -**Script changes required:** Add `--canary` support to `scripts/release.sh`: -- Parse `--canary` flag alongside `--dry-run` -- When `--canary`: pass `--tag canary` to `changeset publish` -- When `--canary`: skip the git commit and tag step (Step 7) -- When NOT `--canary`: behavior is unchanged (backwards compatible) +```bash +npx paperclipai@canary onboard +``` ---- +## Step 5 — Smoke Test the Canary -## Step 4 — Smoke Test the Canary - -Run the canary in a clean Docker environment to verify `npx paperclipai onboard` -works end-to-end. - -### Automated smoke test - -Use the existing Docker smoke test infrastructure with the canary version: +Run: ```bash PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh ``` -This builds a clean Ubuntu container, installs `paperclipai@canary` via npx, and -runs the onboarding flow. The UI is accessible at `http://localhost:3131`. +Confirm: -### What to verify +1. install succeeds +2. onboarding completes +3. server boots +4. UI loads +5. basic company/dashboard flow works -At minimum, confirm: +If smoke testing fails: -1. **Container starts** — no npm install errors, no missing dependencies -2. **Onboarding completes** — the wizard runs through without crashes -3. **Server boots** — UI is accessible at the expected port -4. **Basic operations** — can create a company, view the dashboard +- stop the stable release +- fix the issue +- publish another canary +- repeat the smoke test -For a more thorough check (stretch goal — can be automated later): +Each retry should create a higher canary ordinal, while the stable target version can stay the same. -5. **Browser automation** — script Playwright/Puppeteer to walk through onboard - in the Docker container's browser and verify key pages render +## Step 6 — Publish Stable -### If smoke test fails - -- Do NOT promote the canary. -- Fix the issue, publish a new canary (re-run Step 3 — idempotency guards allow - this since there's no git tag yet). -- Re-run the smoke test. - -### If smoke test passes - -Proceed to Step 5 (promote). - ---- - -## Step 5 — Promote Canary to Latest - -Once the canary passes smoke testing, promote it to `latest` so that -`npx paperclipai onboard` picks up the new version. - -### Promote on npm +Once the SHA is vetted, run: ```bash -# For each published package, move the dist-tag from canary to latest -npm dist-tag add paperclipai@{version} latest -npm dist-tag add @paperclipai/server@{version} latest -npm dist-tag add @paperclipai/cli@{version} latest -npm dist-tag add @paperclipai/shared@{version} latest -npm dist-tag add @paperclipai/db@{version} latest -npm dist-tag add @paperclipai/adapter-utils@{version} latest -npm dist-tag add @paperclipai/adapter-claude-local@{version} latest -npm dist-tag add @paperclipai/adapter-codex-local@{version} latest -npm dist-tag add @paperclipai/adapter-openclaw-gateway@{version} latest +./scripts/release.sh {patch|minor|major} --dry-run +./scripts/release.sh {patch|minor|major} ``` -**Script option:** Add `./scripts/release.sh --promote {version}` to automate -the dist-tag promotion for all packages. +Stable publish does this: -### Commit and tag +- publishes `X.Y.Z` to npm under `latest` +- creates the local release commit +- creates the local git tag `vX.Y.Z` -After promotion, finalize in git (this is what `release.sh` Step 7 normally -does, but was deferred during canary publish): +Stable publish does **not** push the release for you. + +## Step 7 — Push and Create GitHub Release + +After stable publish succeeds: ```bash -git add . -git commit -m "chore: release v{version}" -git tag "v{version}" +git push origin HEAD:master --follow-tags +./scripts/create-github-release.sh X.Y.Z ``` -### Verify promotion +Use the stable changelog file as the GitHub Release notes source. -```bash -npm view paperclipai@latest version -# Should now show the new version +## Step 8 — Finish the Other Surfaces -# Final sanity check -npx --yes paperclipai@latest --version -``` +Create or verify follow-up work for: ---- +- website changelog publishing +- launch post / social announcement +- any release summary in Paperclip issue context -## Step 6 - Create Cross-Project Follow-up Tasks - -**Idempotency check:** Before creating tasks, search for existing ones: - -``` -GET /api/companies/{companyId}/issues?q=release+notes+v{version} -GET /api/companies/{companyId}/issues?q=announcement+tweet+v{version} -``` - -If matching tasks already exist (check title contains the version), skip -creation and link the existing tasks instead. Do not create duplicates. - -Create at least two tasks in Paperclip (only if they don't already exist): - -1. Website task: publish changelog for `v{version}` -2. CMO task: draft announcement tweet for `v{version}` - -When creating tasks: -- Set `parentId` to the release issue id. -- Carry over `goalId` from the parent issue when present. -- Include `billingCode` for cross-team work when required by company policy. -- Mark website task `high` priority if release has breaking changes. - -Suggested payloads: - -```json -POST /api/companies/{companyId}/issues -{ - "projectId": "{websiteProjectId}", - "parentId": "{releaseIssueId}", - "goalId": "{goalId-or-null}", - "billingCode": "{billingCode-or-null}", - "title": "Publish release notes for v{version}", - "priority": "medium", - "status": "todo", - "description": "Publish /changelog entry for v{version}. Include full markdown from releases/v{version}.md and prominent upgrade guide if breaking changes exist." -} -``` - -```json -POST /api/companies/{companyId}/issues -{ - "projectId": "{workspaceProjectId}", - "parentId": "{releaseIssueId}", - "goalId": "{goalId-or-null}", - "billingCode": "{billingCode-or-null}", - "title": "Draft release announcement tweet for v{version}", - "priority": "medium", - "status": "todo", - "description": "Draft launch tweet with top 1-2 highlights, version number, and changelog URL. If breaking changes exist, include an explicit upgrade-guide callout." -} -``` - ---- - -## Step 7 - Wrap Up the Release Issue - -Post a concise markdown update linking: -- Release issue -- Changelog file (`releases/v{version}.md`) -- npm package URL (both `@canary` and `@latest` after promotion) -- Canary smoke test result (pass/fail, what was tested) -- Website task -- CMO task -- Final changelog URL (once website publishes) -- Tweet URL (once published) - -Completion rules: -- Keep issue `in_progress` until canary is promoted AND website + social tasks - are done. -- Mark `done` only when all required artifacts are published and linked. -- If waiting on another team, keep open with clear owner and next action. - ---- - -## Release Flow Summary - -The full release lifecycle is now: - -``` -1. Generate changelog → releases/v{version}.md (review + iterate) -2. Publish canary → npm @canary dist-tag (latest untouched) -3. Smoke test canary → Docker clean install verification -4. Promote to latest → npm @latest dist-tag + git tag + commit -5. Create follow-up tasks → website changelog + CMO tweet -6. Wrap up → link everything, close issue -``` - -At any point you can re-enter the flow — idempotency guards detect which steps -are already done and skip them. The changelog can be iterated before or after -canary publish. The canary can be re-published if the smoke test reveals issues -(just fix + re-run Step 3). Only after smoke testing passes does `latest` get -updated. - ---- - -## Paperclip API Notes (When Running in Agent Context) - -Use: -- `GET /api/companies/{companyId}/projects` to resolve website/workspace project IDs. -- `POST /api/companies/{companyId}/issues` to create follow-up tasks. -- `PATCH /api/issues/{issueId}` with comments for release progress. - -For issue-modifying calls, include: -- `Authorization: Bearer $PAPERCLIP_API_KEY` -- `X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID` - ---- +These should reference the stable release, not the canary. ## Failure Handling -If blocked, update the release issue explicitly with: -- what failed -- exact blocker -- who must act next -- whether any release artifacts were partially published +If the canary is bad: -Never silently fail mid-release. +- publish another canary, do not ship stable + +If stable npm publish succeeds but push or GitHub release creation fails: + +- fix the git/GitHub issue immediately from the same checkout +- do not republish the same version + +If `latest` is bad after stable publish: + +```bash +./scripts/rollback-latest.sh +``` + +Then fix forward with a new patch release. + +## Output + +When the skill completes, provide: + +- stable version and, if relevant, the final canary version tested +- verification status +- npm status +- git tag / GitHub Release status +- website / announcement follow-up status +- rollback recommendation if anything is still partially complete From df94c98494c63e7b634821a9f7ca5afdeac62033 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 09:06:45 -0500 Subject: [PATCH 035/874] chore: add release preflight workflow --- doc/PUBLISHING.md | 1 + doc/RELEASING.md | 73 ++++++++++---- package.json | 1 + scripts/release-preflight.sh | 182 +++++++++++++++++++++++++++++++++++ scripts/release.sh | 9 ++ skills/release/SKILL.md | 15 ++- 6 files changed, 260 insertions(+), 21 deletions(-) create mode 100755 scripts/release-preflight.sh diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index fad105d6..9326fd5b 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -8,6 +8,7 @@ For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This Use these scripts instead of older one-off publish commands: +- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) before any canary or stable release - [`scripts/release.sh`](../scripts/release.sh) for canary and stable npm publishes - [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback - [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after a stable push diff --git a/doc/RELEASING.md b/doc/RELEASING.md index cab82cbe..e18a3e6e 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -26,24 +26,20 @@ Treat those as related but separate. npm can succeed while the GitHub Release is Use this when you want an installable prerelease without changing `latest`. ```bash -# 0. Start clean -git status --short +# 0. Preflight the canary candidate +./scripts/release-preflight.sh canary patch -# 1. Verify the candidate SHA -pnpm -r typecheck -pnpm test:run -pnpm build +# 1. Draft or update the stable changelog for the intended stable version +VERSION=0.2.8 +claude -p "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." -# 2. Draft or update the stable changelog -# releases/vX.Y.Z.md - -# 3. Preview the canary release +# 2. Preview the canary release ./scripts/release.sh patch --canary --dry-run -# 4. Publish the canary +# 3. Publish the canary ./scripts/release.sh patch --canary -# 5. Smoke test what users will actually install +# 4. Smoke test what users will actually install PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh # Users install with: @@ -57,6 +53,7 @@ Result: - no git tag is created - no GitHub Release is created - the working tree returns to clean after the script finishes +- after stable `0.2.7`, a patch canary targets `0.2.8-canary.0`, never `0.2.7-canary.N` ### Stable release @@ -66,15 +63,13 @@ Use this only after the canary SHA is good enough to become the public default. # 0. Start from the vetted commit git checkout master git pull -git status --short -# 1. Verify again on the exact release SHA -pnpm -r typecheck -pnpm test:run -pnpm build +# 1. Preflight the stable candidate +./scripts/release-preflight.sh stable patch # 2. Confirm the stable changelog exists -ls releases/v*.md +VERSION=0.2.8 +ls "releases/v${VERSION}.md" # 3. Preview the stable publish ./scripts/release.sh patch --dry-run @@ -174,6 +169,15 @@ pnpm build This matches [`.github/workflows/pr-verify.yml`](../.github/workflows/pr-verify.yml). Run it before claiming a release candidate is ready. +For release work, prefer: + +```bash +./scripts/release-preflight.sh canary +./scripts/release-preflight.sh stable +``` + +That script runs the verification gate and prints the computed target versions before you publish anything. + ## Versioning Policy ### Stable versions @@ -200,6 +204,11 @@ That gives you three useful properties: We do **not** create separate changelog files for canary versions. +Concrete example: + +- if the latest stable release is `0.2.7`, a patch canary is `0.2.8-canary.0` +- `0.2.7-canary.0` is invalid, because `0.2.7` is already the shipped stable version + ## Changelog Policy The maintainer changelog source of truth is: @@ -222,7 +231,23 @@ Package-level `CHANGELOG.md` files are generated as part of the release mechanic ### 1. Decide the bump -Review the range since the last stable tag: +Run preflight first: + +```bash +./scripts/release-preflight.sh canary +# or +./scripts/release-preflight.sh stable +``` + +That command: + +- verifies the worktree is clean, including untracked files +- shows the last stable tag and computed next versions +- shows the commit range since the last stable tag +- highlights migration and breaking-change signals +- runs `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build` + +If you want the raw inputs separately, review the range since the last stable tag: ```bash LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) @@ -239,7 +264,8 @@ Use the higher bump if there is any doubt. Create or update: ```bash -releases/vX.Y.Z.md +VERSION=X.Y.Z +claude -p "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." ``` This is deliberate. The release notes should describe the stable story, not the canary mechanics. @@ -270,6 +296,12 @@ This means the script is safe to repeat as many times as needed while iterating: The target stable release can still remain `1.2.3`. +Guardrail: + +- the canary is always derived from the **next stable version** +- after stable `0.2.7`, the next patch canary is `0.2.8-canary.0` +- the scripts refuse to publish `0.2.7-canary.N` once `0.2.7` is already the stable release + ### 4. Smoke test the canary Run the actual install path in Docker: @@ -426,6 +458,7 @@ Rollback procedure: ## Scripts Reference - [`scripts/release.sh`](../scripts/release.sh) — stable and canary npm publish flow +- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — clean-tree, version-plan, and verification-gate preflight - [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after push - [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable release - [`scripts/docker-onboard-smoke.sh`](../scripts/docker-onboard-smoke.sh) — Docker smoke test for the installed CLI diff --git a/package.json b/package.json index 737438ec..68098ad8 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts", "build:npm": "./scripts/build-npm.sh", "release": "./scripts/release.sh", + "release:preflight": "./scripts/release-preflight.sh", "release:github": "./scripts/create-github-release.sh", "release:rollback": "./scripts/rollback-latest.sh", "changeset": "changeset", diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh new file mode 100755 index 00000000..575fbcc1 --- /dev/null +++ b/scripts/release-preflight.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +channel="" +bump_type="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/release-preflight.sh + +Examples: + ./scripts/release-preflight.sh canary patch + ./scripts/release-preflight.sh stable minor + +What it does: + - verifies the git worktree is clean, including untracked files + - shows the last stable tag and the target version(s) + - shows commits since the last stable tag + - highlights migration/schema/breaking-change signals + - runs the verification gate: + pnpm -r typecheck + pnpm test:run + pnpm build +EOF +} + +if [ $# -eq 1 ] && [[ "$1" =~ ^(-h|--help)$ ]]; then + usage + exit 0 +fi + +if [ $# -ne 2 ]; then + usage + exit 1 +fi + +channel="$1" +bump_type="$2" + +if [[ ! "$channel" =~ ^(canary|stable)$ ]]; then + usage + exit 1 +fi + +if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then + usage + exit 1 +fi + +compute_bumped_version() { + node - "$1" "$2" <<'NODE' +const current = process.argv[2]; +const bump = process.argv[3]; +const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/); + +if (!match) { + throw new Error(`invalid semver version: ${current}`); +} + +let [major, minor, patch] = match.slice(1).map(Number); + +if (bump === 'patch') { + patch += 1; +} else if (bump === 'minor') { + minor += 1; + patch = 0; +} else if (bump === 'major') { + major += 1; + minor = 0; + patch = 0; +} else { + throw new Error(`unsupported bump type: ${bump}`); +} + +process.stdout.write(`${major}.${minor}.${patch}`); +NODE +} + +next_canary_version() { + local stable_version="$1" + local versions_json + + versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')" + + node - "$stable_version" "$versions_json" <<'NODE' +const stable = process.argv[2]; +const versionsArg = process.argv[3]; + +let versions = []; +try { + const parsed = JSON.parse(versionsArg); + versions = Array.isArray(parsed) ? parsed : [parsed]; +} catch { + versions = []; +} + +const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`); +let max = -1; + +for (const version of versions) { + const match = version.match(pattern); + if (!match) continue; + max = Math.max(max, Number(match[1])); +} + +process.stdout.write(`${stable}-canary.${max + 1}`); +NODE +} + +LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)" +CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}" +if [ -z "$CURRENT_STABLE_VERSION" ]; then + CURRENT_STABLE_VERSION="0.0.0" +fi + +TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")" +TARGET_CANARY_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" + +if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then + echo "Error: working tree is not clean. Commit, stash, or remove changes before releasing." >&2 + exit 1 +fi + +if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then + echo "Error: next stable version matches the current stable version." >&2 + exit 1 +fi + +if [[ "$TARGET_CANARY_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then + echo "Error: canary target was derived from the current stable version, which is not allowed." >&2 + exit 1 +fi + +echo "" +echo "==> Release preflight" +echo " Channel: $channel" +echo " Bump: $bump_type" +echo " Last stable tag: ${LAST_STABLE_TAG:-}" +echo " Current stable version: $CURRENT_STABLE_VERSION" +echo " Next stable version: $TARGET_STABLE_VERSION" +if [ "$channel" = "canary" ]; then + echo " Next canary version: $TARGET_CANARY_VERSION" + echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N" +fi + +echo "" +echo "==> Working tree" +echo " ✓ Clean" + +echo "" +echo "==> Commits since last stable tag" +if [ -n "$LAST_STABLE_TAG" ]; then + git -C "$REPO_ROOT" log "${LAST_STABLE_TAG}..HEAD" --oneline --no-merges || true +else + git -C "$REPO_ROOT" log --oneline --no-merges || true +fi + +echo "" +echo "==> Migration / breaking change signals" +if [ -n "$LAST_STABLE_TAG" ]; then + echo "-- migrations --" + git -C "$REPO_ROOT" diff --name-only "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/migrations/ || true + echo "-- schema --" + git -C "$REPO_ROOT" diff "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/schema/ || true + echo "-- breaking commit messages --" + git -C "$REPO_ROOT" log "${LAST_STABLE_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true +else + echo "No stable tag exists yet. Review the full current tree manually." +fi + +echo "" +echo "==> Verification gate" +cd "$REPO_ROOT" +pnpm -r typecheck +pnpm test:run +pnpm build + +echo "" +echo "Preflight passed for $channel release." diff --git a/scripts/release.sh b/scripts/release.sh index 4908912c..1c05e19c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -309,6 +309,14 @@ if [ "$canary" = true ]; then TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" fi +if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then + fail "next stable version matches the current stable version. Refusing to publish." +fi + +if [[ "$TARGET_PUBLISH_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then + fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N." +fi + PUBLIC_PACKAGE_INFO="$(list_public_package_info)" PUBLIC_PACKAGE_NAMES="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)" PUBLIC_PACKAGE_DIRS="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f1)" @@ -324,6 +332,7 @@ info " Current stable version: $CURRENT_STABLE_VERSION" if [ "$canary" = true ]; then info " Target stable version: $TARGET_STABLE_VERSION" info " Canary version: $TARGET_PUBLISH_VERSION" + info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N" else info " Stable version: $TARGET_STABLE_VERSION" fi diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index 65468704..088ed7ba 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -69,7 +69,15 @@ Critical consequence: ## Step 1 — Decide the Stable Version -Use the last stable tag as the base: +Run release preflight first: + +```bash +./scripts/release-preflight.sh canary {patch|minor|major} +# or +./scripts/release-preflight.sh stable {patch|minor|major} +``` + +Then use the last stable tag as the base: ```bash LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) @@ -128,6 +136,11 @@ What this means: - no git tag is created - the script cleans the working tree afterward +Guard: + +- if the current stable is `0.2.7`, the next patch canary is `0.2.8-canary.0` +- the tooling must never publish `0.2.7-canary.N` after `0.2.7` is already stable + After publish, verify: ```bash From e1ddcbb71f212275e2ca7de67ef08e48a9e79212 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 09:13:49 -0500 Subject: [PATCH 036/874] fix: disable git pagers in release preflight --- scripts/release-preflight.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index 575fbcc1..fdba4ae1 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -2,6 +2,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +export GIT_PAGER=cat channel="" bump_type="" @@ -153,20 +154,20 @@ echo " ✓ Clean" echo "" echo "==> Commits since last stable tag" if [ -n "$LAST_STABLE_TAG" ]; then - git -C "$REPO_ROOT" log "${LAST_STABLE_TAG}..HEAD" --oneline --no-merges || true + git -C "$REPO_ROOT" --no-pager log "${LAST_STABLE_TAG}..HEAD" --oneline --no-merges || true else - git -C "$REPO_ROOT" log --oneline --no-merges || true + git -C "$REPO_ROOT" --no-pager log --oneline --no-merges || true fi echo "" echo "==> Migration / breaking change signals" if [ -n "$LAST_STABLE_TAG" ]; then echo "-- migrations --" - git -C "$REPO_ROOT" diff --name-only "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/migrations/ || true + git -C "$REPO_ROOT" --no-pager diff --name-only "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/migrations/ || true echo "-- schema --" - git -C "$REPO_ROOT" diff "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/schema/ || true + git -C "$REPO_ROOT" --no-pager diff "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/schema/ || true echo "-- breaking commit messages --" - git -C "$REPO_ROOT" log "${LAST_STABLE_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true + git -C "$REPO_ROOT" --no-pager log "${LAST_STABLE_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true else echo "No stable tag exists yet. Review the full current tree manually." fi From aa2b11d5288c225ff7b5da08036b31c8a5ee48e6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 09:21:56 -0500 Subject: [PATCH 037/874] feat: extend release preflight smoke options --- doc/RELEASING.md | 26 ++++++++ scripts/release-preflight.sh | 124 ++++++++++++++++++++++++++++++++--- 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/doc/RELEASING.md b/doc/RELEASING.md index e18a3e6e..7b6b69e8 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -42,6 +42,9 @@ claude -p "Use the release-changelog skill to draft or update releases/v${VERSIO # 4. Smoke test what users will actually install PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +# Optional: have preflight run the onboarding smoke immediately afterward +./scripts/release-preflight.sh canary patch --onboard-smoke --onboard-host-port 3232 --onboard-data-dir ./data/release-preflight-canary + # Users install with: npx paperclipai@canary onboard ``` @@ -105,6 +108,21 @@ If `latest` is broken after publish, repoint it to the last known good stable ve This does **not** unpublish anything. It only moves the `latest` dist-tag back to the last good stable release. +### Standalone onboarding smoke + +You already have a script for isolated onboarding verification: + +```bash +HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh +``` + +This is the best existing fit when you want: + +- a standalone Paperclip data dir +- a dedicated host port +- an end-to-end `npx paperclipai ... onboard` check + ### GitHub Actions release There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). It is designed for npm trusted publishing via GitHub OIDC instead of long-lived npm tokens. @@ -310,6 +328,14 @@ Run the actual install path in Docker: PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh ``` +If you want it tied directly to preflight, you can append: + +```bash +./scripts/release-preflight.sh canary --onboard-smoke +./scripts/release-preflight.sh canary --onboard-smoke --onboard-host-port 3232 --onboard-data-dir ./data/release-preflight-canary +./scripts/release-preflight.sh stable --onboard-smoke --onboard-host-port 3233 --onboard-data-dir ./data/release-preflight-stable +``` + Minimum checks: - [ ] `npx paperclipai@canary onboard` installs diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index fdba4ae1..4b68cbb3 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -6,15 +6,23 @@ export GIT_PAGER=cat channel="" bump_type="" +run_onboard_smoke=false +onboard_version="" +onboard_host_port="" +onboard_data_dir="" usage() { cat <<'EOF' Usage: - ./scripts/release-preflight.sh + ./scripts/release-preflight.sh [--onboard-smoke] + [--onboard-version ] + [--onboard-host-port ] + [--onboard-data-dir ] Examples: ./scripts/release-preflight.sh canary patch ./scripts/release-preflight.sh stable minor + ./scripts/release-preflight.sh canary minor --onboard-smoke --onboard-version canary --onboard-host-port 3232 What it does: - verifies the git worktree is clean, including untracked files @@ -25,22 +33,62 @@ What it does: pnpm -r typecheck pnpm test:run pnpm build + - optionally runs scripts/docker-onboard-smoke.sh afterward EOF } -if [ $# -eq 1 ] && [[ "$1" =~ ^(-h|--help)$ ]]; then - usage - exit 0 -fi +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --onboard-smoke) + run_onboard_smoke=true + ;; + --onboard-version) + shift + if [ $# -eq 0 ]; then + echo "Error: --onboard-version requires a value." >&2 + exit 1 + fi + onboard_version="$1" + ;; + --onboard-host-port) + shift + if [ $# -eq 0 ]; then + echo "Error: --onboard-host-port requires a value." >&2 + exit 1 + fi + onboard_host_port="$1" + ;; + --onboard-data-dir) + shift + if [ $# -eq 0 ]; then + echo "Error: --onboard-data-dir requires a value." >&2 + exit 1 + fi + onboard_data_dir="$1" + ;; + *) + if [ -z "$channel" ]; then + channel="$1" + elif [ -z "$bump_type" ]; then + bump_type="$1" + else + echo "Error: unexpected argument: $1" >&2 + exit 1 + fi + ;; + esac + shift +done -if [ $# -ne 2 ]; then +if [ -z "$channel" ] || [ -z "$bump_type" ]; then usage exit 1 fi -channel="$1" -bump_type="$2" - if [[ ! "$channel" =~ ^(canary|stable)$ ]]; then usage exit 1 @@ -120,6 +168,14 @@ fi TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")" TARGET_CANARY_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" +if [ "$run_onboard_smoke" = true ] && [ -z "$onboard_version" ]; then + if [ "$channel" = "canary" ]; then + onboard_version="canary" + else + onboard_version="latest" + fi +fi + if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then echo "Error: working tree is not clean. Commit, stash, or remove changes before releasing." >&2 exit 1 @@ -146,6 +202,16 @@ if [ "$channel" = "canary" ]; then echo " Next canary version: $TARGET_CANARY_VERSION" echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N" fi +if [ "$run_onboard_smoke" = true ]; then + echo " Post-check: onboarding smoke enabled" + echo " Onboarding smoke version/tag: $onboard_version" + if [ -n "$onboard_host_port" ]; then + echo " Onboarding smoke host port: $onboard_host_port" + fi + if [ -n "$onboard_data_dir" ]; then + echo " Onboarding smoke data dir: $onboard_data_dir" + fi +fi echo "" echo "==> Working tree" @@ -179,5 +245,45 @@ pnpm -r typecheck pnpm test:run pnpm build +echo "" +if [ "$run_onboard_smoke" = true ]; then + echo "==> Optional onboarding smoke" + smoke_cmd=(env "PAPERCLIPAI_VERSION=$onboard_version") + if [ -n "$onboard_host_port" ]; then + smoke_cmd+=("HOST_PORT=$onboard_host_port") + fi + if [ -n "$onboard_data_dir" ]; then + smoke_cmd+=("DATA_DIR=$onboard_data_dir") + fi + smoke_cmd+=("$REPO_ROOT/scripts/docker-onboard-smoke.sh") + printf ' Running:' + for arg in "${smoke_cmd[@]}"; do + printf ' %q' "$arg" + done + printf '\n' + "${smoke_cmd[@]}" + echo "" +fi + +echo "==> Release preflight summary" +echo " Channel: $channel" +echo " Bump: $bump_type" +echo " Last stable tag: ${LAST_STABLE_TAG:-}" +echo " Current stable version: $CURRENT_STABLE_VERSION" +echo " Next stable version: $TARGET_STABLE_VERSION" +if [ "$channel" = "canary" ]; then + echo " Next canary version: $TARGET_CANARY_VERSION" + echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N" +fi +if [ "$run_onboard_smoke" = true ]; then + echo " Onboarding smoke version/tag: $onboard_version" + if [ -n "$onboard_host_port" ]; then + echo " Onboarding smoke host port: $onboard_host_port" + fi + if [ -n "$onboard_data_dir" ]; then + echo " Onboarding smoke data dir: $onboard_data_dir" + fi +fi + echo "" echo "Preflight passed for $channel release." From 30ee59c3241d4a2ee1ec600dde5bf9c069750099 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 09:37:18 -0500 Subject: [PATCH 038/874] chore: simplify release preflight workflow --- doc/RELEASING.md | 24 ++++++++--- scripts/release-preflight.sh | 84 +----------------------------------- 2 files changed, 18 insertions(+), 90 deletions(-) diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 7b6b69e8..bd7314ce 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -42,9 +42,6 @@ claude -p "Use the release-changelog skill to draft or update releases/v${VERSIO # 4. Smoke test what users will actually install PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh -# Optional: have preflight run the onboarding smoke immediately afterward -./scripts/release-preflight.sh canary patch --onboard-smoke --onboard-host-port 3232 --onboard-data-dir ./data/release-preflight-canary - # Users install with: npx paperclipai@canary onboard ``` @@ -123,6 +120,14 @@ This is the best existing fit when you want: - a dedicated host port - an end-to-end `npx paperclipai ... onboard` check +If you want to exercise onboarding from a fresh local checkout rather than npm, use: + +```bash +./scripts/clean-onboard-git.sh +``` + +That is not a required release step every time, but it is a useful higher-confidence check when onboarding is the main risk area or when you need to verify what the current codebase does before publishing. + ### GitHub Actions release There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). It is designed for npm trusted publishing via GitHub OIDC instead of long-lived npm tokens. @@ -328,12 +333,17 @@ Run the actual install path in Docker: PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh ``` -If you want it tied directly to preflight, you can append: +Useful isolated variants: ```bash -./scripts/release-preflight.sh canary --onboard-smoke -./scripts/release-preflight.sh canary --onboard-smoke --onboard-host-port 3232 --onboard-data-dir ./data/release-preflight-canary -./scripts/release-preflight.sh stable --onboard-smoke --onboard-host-port 3233 --onboard-data-dir ./data/release-preflight-stable +HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh +``` + +If you want to smoke onboarding from the current codebase rather than npm, run: + +```bash +./scripts/clean-onboard-git.sh ``` Minimum checks: diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index 4b68cbb3..84faf5b2 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -6,23 +6,15 @@ export GIT_PAGER=cat channel="" bump_type="" -run_onboard_smoke=false -onboard_version="" -onboard_host_port="" -onboard_data_dir="" usage() { cat <<'EOF' Usage: - ./scripts/release-preflight.sh [--onboard-smoke] - [--onboard-version ] - [--onboard-host-port ] - [--onboard-data-dir ] + ./scripts/release-preflight.sh Examples: ./scripts/release-preflight.sh canary patch ./scripts/release-preflight.sh stable minor - ./scripts/release-preflight.sh canary minor --onboard-smoke --onboard-version canary --onboard-host-port 3232 What it does: - verifies the git worktree is clean, including untracked files @@ -33,7 +25,6 @@ What it does: pnpm -r typecheck pnpm test:run pnpm build - - optionally runs scripts/docker-onboard-smoke.sh afterward EOF } @@ -43,33 +34,6 @@ while [ $# -gt 0 ]; do usage exit 0 ;; - --onboard-smoke) - run_onboard_smoke=true - ;; - --onboard-version) - shift - if [ $# -eq 0 ]; then - echo "Error: --onboard-version requires a value." >&2 - exit 1 - fi - onboard_version="$1" - ;; - --onboard-host-port) - shift - if [ $# -eq 0 ]; then - echo "Error: --onboard-host-port requires a value." >&2 - exit 1 - fi - onboard_host_port="$1" - ;; - --onboard-data-dir) - shift - if [ $# -eq 0 ]; then - echo "Error: --onboard-data-dir requires a value." >&2 - exit 1 - fi - onboard_data_dir="$1" - ;; *) if [ -z "$channel" ]; then channel="$1" @@ -168,14 +132,6 @@ fi TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")" TARGET_CANARY_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" -if [ "$run_onboard_smoke" = true ] && [ -z "$onboard_version" ]; then - if [ "$channel" = "canary" ]; then - onboard_version="canary" - else - onboard_version="latest" - fi -fi - if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then echo "Error: working tree is not clean. Commit, stash, or remove changes before releasing." >&2 exit 1 @@ -202,16 +158,6 @@ if [ "$channel" = "canary" ]; then echo " Next canary version: $TARGET_CANARY_VERSION" echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N" fi -if [ "$run_onboard_smoke" = true ]; then - echo " Post-check: onboarding smoke enabled" - echo " Onboarding smoke version/tag: $onboard_version" - if [ -n "$onboard_host_port" ]; then - echo " Onboarding smoke host port: $onboard_host_port" - fi - if [ -n "$onboard_data_dir" ]; then - echo " Onboarding smoke data dir: $onboard_data_dir" - fi -fi echo "" echo "==> Working tree" @@ -246,25 +192,6 @@ pnpm test:run pnpm build echo "" -if [ "$run_onboard_smoke" = true ]; then - echo "==> Optional onboarding smoke" - smoke_cmd=(env "PAPERCLIPAI_VERSION=$onboard_version") - if [ -n "$onboard_host_port" ]; then - smoke_cmd+=("HOST_PORT=$onboard_host_port") - fi - if [ -n "$onboard_data_dir" ]; then - smoke_cmd+=("DATA_DIR=$onboard_data_dir") - fi - smoke_cmd+=("$REPO_ROOT/scripts/docker-onboard-smoke.sh") - printf ' Running:' - for arg in "${smoke_cmd[@]}"; do - printf ' %q' "$arg" - done - printf '\n' - "${smoke_cmd[@]}" - echo "" -fi - echo "==> Release preflight summary" echo " Channel: $channel" echo " Bump: $bump_type" @@ -275,15 +202,6 @@ if [ "$channel" = "canary" ]; then echo " Next canary version: $TARGET_CANARY_VERSION" echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N" fi -if [ "$run_onboard_smoke" = true ]; then - echo " Onboarding smoke version/tag: $onboard_version" - if [ -n "$onboard_host_port" ]; then - echo " Onboarding smoke host port: $onboard_host_port" - fi - if [ -n "$onboard_data_dir" ]; then - echo " Onboarding smoke data dir: $onboard_data_dir" - fi -fi echo "" echo "Preflight passed for $channel release." From 0781b7a15cd317aba2e2a74ccc0fdce5e90afdef Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 09:53:35 -0500 Subject: [PATCH 039/874] v0.3.0.md release changelog --- doc/RELEASING.md | 2 +- releases/v0.3.0.md | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 releases/v0.3.0.md diff --git a/doc/RELEASING.md b/doc/RELEASING.md index bd7314ce..8ffd80fd 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -31,7 +31,7 @@ Use this when you want an installable prerelease without changing `latest`. # 1. Draft or update the stable changelog for the intended stable version VERSION=0.2.8 -claude -p "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." +claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." # 2. Preview the canary release ./scripts/release.sh patch --canary --dry-run diff --git a/releases/v0.3.0.md b/releases/v0.3.0.md new file mode 100644 index 00000000..4e18ae6c --- /dev/null +++ b/releases/v0.3.0.md @@ -0,0 +1,47 @@ +# v0.3.0 + +> Released: 2026-03-09 + +## Highlights + +- **New adapters: Cursor, OpenCode, and Pi** — Paperclip now supports three additional local coding agents. Cursor and OpenCode integrate as first-class adapters with model discovery, run-log streaming, and skill injection. Pi adds a local RPC mode with cost tracking. All three appear in the onboarding wizard alongside Claude Code and Codex. +- **OpenClaw gateway adapter** — A new gateway-only OpenClaw flow replaces the legacy adapter. It uses strict SSE streaming, supports device-key pairing, and handles invite-based onboarding with join-token validation. +- **Inbox and unread semantics** — Issues now track per-user read state. Unread indicators appear in the inbox, dashboard, and browser tab (blue dot). The inbox badge includes join requests and approvals, and inbox ordering is alert-focused. +- **PWA support** — The UI ships as an installable Progressive Web App with a service worker and enhanced manifest. The service worker uses a network-first strategy to prevent stale content. +- **Agent creation wizard** — A new choice modal and full-page configuration flow make it easier to add agents. The sidebar AGENTS header now has a quick-add button. + +## Improvements + +- **Mermaid diagrams in markdown** — Fenced `mermaid` blocks render as diagrams in issue comments and descriptions. +- **Live run output** — Run detail pages stream output over WebSocket in real time, with coalesced deltas and deduplicated feed items. +- **Copy comment as Markdown** — Each comment header has a one-click copy-as-markdown button. +- **Retry failed runs** — Failed and timed-out runs now show a Retry button on the run detail page. +- **Project status clickable** — The status chip in the project properties pane is now clickable for quick updates. +- **Scroll-to-bottom button** — Issue detail and run pages show a floating scroll-to-bottom button when you scroll up. +- **Database backup CLI** — `paperclipai db:backup` lets you snapshot the database on demand, with optional automatic scheduling. +- **Disable sign-up** — A new `auth.disableSignUp` config option (and `AUTH_DISABLE_SIGNUP` env var) lets operators lock registration. +- **Deduplicated shortnames** — Agent and project shortnames are now auto-deduplicated on create and update instead of rejecting duplicates. +- **Human-readable role labels** — The agent list and properties pane show friendly role names. +- **Assignee picker sorting** — Recent selections appear first, then alphabetical. +- **Mobile layout polish** — Unified GitHub-style issue rows across issues, inbox, and dashboard. Improved popover scrolling, command palette centering, and property toggles on mobile. +- **Invite UX improvements** — Invite links auto-copy to clipboard, snippet-only flow in settings, 10-minute invite TTL, and clearer network-host guidance. +- **Permalink anchors on comments** — Each comment has a stable anchor link and a GET-by-ID API endpoint. +- **Docker deployment hardening** — Authenticated deployment mode by default, named data volume, `PAPERCLIP_PUBLIC_URL` and `PAPERCLIP_ALLOWED_HOSTNAMES` exposed in compose files, health-check DB wait, and Node 24 base image. +- **Updated model lists** — Added `claude-sonnet-4-6`, `claude-haiku-4-6`, and `gpt-5.4` to adapter model constants. +- **Playwright e2e tests** — New end-to-end test suite covering the onboarding wizard flow. + +## Fixes + +- **Secret redaction in run logs** — Env vars sourced from secrets are now redacted by provenance, with consistent `secretKeys` tracking. +- **SPA catch-all 500s** — The server serves cached `index.html` in the catch-all route and uses `root` in `sendFile`, preventing 500 errors on dotfile paths and SPA refreshes. +- **Unmatched API routes return 404 JSON** — Previously fell through to the SPA handler. +- **Agent wake logic** — Agents wake when issues move out of backlog, skip self-wake on own comments, and skip wakeup for backlog-status changes. Pending-approval agents are excluded from heartbeat timers. +- **Run log fd leak** — Fixed a file-descriptor leak in log append that caused `spawn EBADF` errors. +- **500 error logging** — Error logs now include the actual error message and request context instead of generic pino-http output. +- **Boolean env parsing** — `parseBooleanFromEnv` no longer silently treats common truthy values as false. +- **Onboarding env defaults** — `onboard` now correctly derives secrets from env vars and reports ignored exposure settings in `local_trusted` mode. +- **Windows path compatibility** — Migration paths use `fileURLToPath` for Windows-safe resolution. +- **Secure cookies on HTTP** — Disabled secure cookie flag for plain HTTP deployments to prevent auth failures. +- **URL encoding** — `buildUrl` splits path and query to prevent `%3F` encoding issues. +- **Auth trusted origins** — Effective trusted origins and allowed hostnames are now applied correctly in public mode. +- **UI stability** — Fixed blank screen when prompt templates are emptied, search URL sync causing re-renders, issue title overflow in inbox, and sidebar badge counts including approvals. From a47ea343ba99262456014082ec8a3a7e1b1774db Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 09:59:43 -0500 Subject: [PATCH 040/874] feat: add committed-ref onboarding smoke script --- doc/RELEASING.md | 11 +++++ scripts/clean-onboard-ref.sh | 86 ++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100755 scripts/clean-onboard-ref.sh diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 8ffd80fd..9ca74d11 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -128,6 +128,16 @@ If you want to exercise onboarding from a fresh local checkout rather than npm, That is not a required release step every time, but it is a useful higher-confidence check when onboarding is the main risk area or when you need to verify what the current codebase does before publishing. +If you want to exercise onboarding from the current committed ref in your local repo, use: + +```bash +./scripts/clean-onboard-ref.sh +PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh +./scripts/clean-onboard-ref.sh HEAD +``` + +This uses the current committed `HEAD` in a detached temp worktree. It does **not** include uncommitted local edits. + ### GitHub Actions release There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). It is designed for npm trusted publishing via GitHub OIDC instead of long-lived npm tokens. @@ -344,6 +354,7 @@ If you want to smoke onboarding from the current codebase rather than npm, run: ```bash ./scripts/clean-onboard-git.sh +./scripts/clean-onboard-ref.sh ``` Minimum checks: diff --git a/scripts/clean-onboard-ref.sh b/scripts/clean-onboard-ref.sh new file mode 100755 index 00000000..a42c5ea4 --- /dev/null +++ b/scripts/clean-onboard-ref.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TARGET_REF="${1:-HEAD}" + +usage() { + cat <<'EOF' +Usage: + ./scripts/clean-onboard-ref.sh [git-ref] + +Examples: + ./scripts/clean-onboard-ref.sh + ./scripts/clean-onboard-ref.sh HEAD + ./scripts/clean-onboard-ref.sh v0.2.7 + +Environment overrides: + KEEP_TEMP=1 Keep the temp directory and detached worktree for debugging + PC_TEST_ROOT=/tmp/custom Base temp directory to use + PC_DATA=/tmp/data Paperclip data dir to use + PAPERCLIP_HOST=127.0.0.1 Host passed to the onboarded server + PAPERCLIP_PORT=3232 Port passed to the onboarded server + +Notes: + - Defaults to the current committed ref (HEAD), not uncommitted local edits. + - Creates an isolated temp HOME, npm cache, data dir, and detached git worktree. +EOF +} + +if [ $# -gt 1 ]; then + usage + exit 1 +fi + +if [ $# -eq 1 ] && [[ "$1" =~ ^(-h|--help)$ ]]; then + usage + exit 0 +fi + +TARGET_COMMIT="$(git -C "$REPO_ROOT" rev-parse --verify "${TARGET_REF}^{commit}")" + +export KEEP_TEMP="${KEEP_TEMP:-0}" +export PC_TEST_ROOT="${PC_TEST_ROOT:-$(mktemp -d /tmp/paperclip-clean-ref.XXXXXX)}" +export PC_HOME="${PC_HOME:-$PC_TEST_ROOT/home}" +export PC_CACHE="${PC_CACHE:-$PC_TEST_ROOT/npm-cache}" +export PC_DATA="${PC_DATA:-$PC_TEST_ROOT/paperclip-data}" +export PC_REPO="${PC_REPO:-$PC_TEST_ROOT/repo}" +export PAPERCLIP_HOST="${PAPERCLIP_HOST:-127.0.0.1}" +export PAPERCLIP_PORT="${PAPERCLIP_PORT:-3100}" +export PAPERCLIP_OPEN_ON_LISTEN="${PAPERCLIP_OPEN_ON_LISTEN:-false}" + +cleanup() { + if [ "$KEEP_TEMP" = "1" ]; then + return + fi + + git -C "$REPO_ROOT" worktree remove --force "$PC_REPO" >/dev/null 2>&1 || true + rm -rf "$PC_TEST_ROOT" +} + +trap cleanup EXIT + +mkdir -p "$PC_HOME" "$PC_CACHE" "$PC_DATA" + +echo "TARGET_REF: $TARGET_REF" +echo "TARGET_COMMIT: $TARGET_COMMIT" +echo "PC_TEST_ROOT: $PC_TEST_ROOT" +echo "PC_HOME: $PC_HOME" +echo "PC_DATA: $PC_DATA" +echo "PC_REPO: $PC_REPO" +echo "PAPERCLIP_HOST: $PAPERCLIP_HOST" +echo "PAPERCLIP_PORT: $PAPERCLIP_PORT" + +git -C "$REPO_ROOT" worktree add --detach "$PC_REPO" "$TARGET_COMMIT" + +cd "$PC_REPO" +pnpm install + +env \ + HOME="$PC_HOME" \ + npm_config_cache="$PC_CACHE" \ + npm_config_userconfig="$PC_HOME/.npmrc" \ + HOST="$PAPERCLIP_HOST" \ + PORT="$PAPERCLIP_PORT" \ + PAPERCLIP_OPEN_ON_LISTEN="$PAPERCLIP_OPEN_ON_LISTEN" \ + pnpm paperclipai onboard --yes --data-dir "$PC_DATA" From 0a8b96cdb3cb79cfad98e2f0e9bc5b1c7b8c3f31 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:03:45 -0500 Subject: [PATCH 041/874] http clone --- scripts/clean-onboard-git.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/clean-onboard-git.sh b/scripts/clean-onboard-git.sh index a662b57f..789b764d 100755 --- a/scripts/clean-onboard-git.sh +++ b/scripts/clean-onboard-git.sh @@ -7,7 +7,7 @@ mkdir -p "$PC_HOME" "$PC_CACHE" "$PC_DATA" echo "PC_TEST_ROOT: $PC_TEST_ROOT" echo "PC_HOME: $PC_HOME" cd $PC_TEST_ROOT -git clone github.com:paperclipai/paperclip.git repo +git clone https://github.com/paperclipai/paperclip.git repo cd repo pnpm install env HOME="$PC_HOME" npm_config_cache="$PC_CACHE" npm_config_userconfig="$PC_HOME/.npmrc" \ From f5bf743745a00ebcb172c0e6862e1ca066cfd6a3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:11:46 -0500 Subject: [PATCH 042/874] fix: support older git in release cleanup --- scripts/release.sh | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 1c05e19c..07b1bd12 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -99,9 +99,24 @@ cleanup_release_state() { rm -f "$TEMP_CHANGESET_FILE" "$TEMP_PRE_FILE" - if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then - git -C "$REPO_ROOT" restore --source=HEAD --staged --worktree . - rm -f "$TEMP_CHANGESET_FILE" "$TEMP_PRE_FILE" + tracked_changes="$(git -C "$REPO_ROOT" diff --name-only; git -C "$REPO_ROOT" diff --cached --name-only)" + if [ -n "$tracked_changes" ]; then + printf '%s\n' "$tracked_changes" | sort -u | while IFS= read -r path; do + [ -z "$path" ] && continue + git -C "$REPO_ROOT" checkout -q HEAD -- "$path" || true + done + fi + + untracked_changes="$(git -C "$REPO_ROOT" ls-files --others --exclude-standard)" + if [ -n "$untracked_changes" ]; then + printf '%s\n' "$untracked_changes" | while IFS= read -r path; do + [ -z "$path" ] && continue + if [ -d "$REPO_ROOT/$path" ]; then + rm -rf "$REPO_ROOT/$path" + else + rm -f "$REPO_ROOT/$path" + fi + done fi } From 31c947bf7fb1ef6aa6517098b73dc8732518976d Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:23:04 -0500 Subject: [PATCH 043/874] fix: publish canaries in changesets pre mode --- scripts/release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release.sh b/scripts/release.sh index 07b1bd12..ab9257df 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -435,7 +435,7 @@ if [ "$dry_run" = true ]; then else if [ "$canary" = true ]; then info "==> Step 6/7: Publishing canary to npm..." - npx changeset publish --tag canary + npx changeset publish info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary" else info "==> Step 6/7: Publishing stable release to npm..." From 422f57b16001ed8842d860a9a1351d8f8984fc34 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:33:56 -0500 Subject: [PATCH 044/874] chore: use public-gh for manual release flow --- doc/RELEASING.md | 4 ++-- scripts/create-github-release.sh | 5 +++-- scripts/release.sh | 3 ++- skills/release/SKILL.md | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 9ca74d11..1751f466 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -78,7 +78,7 @@ ls "releases/v${VERSION}.md" ./scripts/release.sh patch # 5. Push the release commit and tag -git push origin HEAD:master --follow-tags +git push public-gh HEAD:master --follow-tags # 6. Create or update the GitHub Release from the pushed tag ./scripts/create-github-release.sh X.Y.Z @@ -393,7 +393,7 @@ What it does **not** do: After a stable publish succeeds: ```bash -git push origin HEAD:master --follow-tags +git push public-gh HEAD:master --follow-tags ./scripts/create-github-release.sh X.Y.Z ``` diff --git a/scripts/create-github-release.sh b/scripts/create-github-release.sh index 4d1d0789..1a12a783 100755 --- a/scripts/create-github-release.sh +++ b/scripts/create-github-release.sh @@ -2,6 +2,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}" dry_run=false version="" @@ -72,8 +73,8 @@ if [ "$dry_run" = true ]; then exit 0 fi -if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then - echo "Error: remote tag $tag was not found on origin. Push the release commit and tag first." >&2 +if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags "$PUBLISH_REMOTE" "refs/tags/$tag" >/dev/null 2>&1; then + echo "Error: remote tag $tag was not found on $PUBLISH_REMOTE. Push the release commit and tag first." >&2 exit 1 fi diff --git a/scripts/release.sh b/scripts/release.sh index ab9257df..f21acc60 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -18,6 +18,7 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" CLI_DIR="$REPO_ROOT/cli" TEMP_CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md" TEMP_PRE_FILE="$REPO_ROOT/.changeset/pre.json" +PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}" dry_run=false canary=false @@ -479,6 +480,6 @@ elif [ "$canary" = true ]; then else info "Published stable v${TARGET_STABLE_VERSION}." info "Next steps:" - info " git push origin HEAD:master --follow-tags" + info " git push ${PUBLISH_REMOTE} HEAD:master --follow-tags" info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION" fi diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index 088ed7ba..866bdb22 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -200,7 +200,7 @@ Stable publish does **not** push the release for you. After stable publish succeeds: ```bash -git push origin HEAD:master --follow-tags +git push public-gh HEAD:master --follow-tags ./scripts/create-github-release.sh X.Y.Z ``` From 8d53800c1993115d73551783a4a07a92bedc8acf Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:35:32 -0500 Subject: [PATCH 045/874] chore: restore pnpm-lock.yaml from master --- pnpm-lock.yaml | 47 +++++++++-------------------------------------- 1 file changed, 9 insertions(+), 38 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2885e8a..ff4f3e35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@25.2.3) - '@playwright/test': - specifier: ^1.58.2 - version: 1.58.2 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -38,6 +35,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -261,6 +261,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -376,6 +379,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -1690,11 +1696,6 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@playwright/test@1.58.2': - resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} - engines: {node: '>=18'} - hasBin: true - '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -4011,11 +4012,6 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4802,16 +4798,6 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - playwright-core@1.58.2: - resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.58.2: - resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} - engines: {node: '>=18'} - hasBin: true - points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -7408,10 +7394,6 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@playwright/test@1.58.2': - dependencies: - playwright: 1.58.2 - '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -9890,9 +9872,6 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -10944,14 +10923,6 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 - playwright-core@1.58.2: {} - - playwright@1.58.2: - dependencies: - playwright-core: 1.58.2 - optionalDependencies: - fsevents: 2.3.2 - points-on-curve@0.2.0: {} points-on-path@0.2.1: From af0e05f38c43ed1c60b6ef48606a27ee341e8fd4 Mon Sep 17 00:00:00 2001 From: RememberV Date: Mon, 9 Mar 2026 15:35:40 +0000 Subject: [PATCH 046/874] fix: onboarding wizard navigates to dashboard instead of first issue After onboarding, the wizard navigated to the newly created issue (e.g. /JAR/issues/JAR-1). useCompanyPageMemory then saved this path, causing every subsequent company switch to land on that stale issue instead of the dashboard. Remove the issue-specific navigation branch so handleLaunch always falls through to the dashboard route. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/OnboardingWizard.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index fbcbc7bf..5451e278 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -500,10 +500,6 @@ export function OnboardingWizard() { setLoading(false); reset(); closeOnboarding(); - if (createdCompanyPrefix && createdIssueRef) { - navigate(`/${createdCompanyPrefix}/issues/${createdIssueRef}`); - return; - } if (createdCompanyPrefix) { navigate(`/${createdCompanyPrefix}/dashboard`); return; From 948080fee9bebc8a0daadfe3396c3b7e6e89928d Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:37:38 -0500 Subject: [PATCH 047/874] Revert "chore: restore pnpm-lock.yaml from master" This reverts commit 8d53800c1993115d73551783a4a07a92bedc8acf. --- pnpm-lock.yaml | 47 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff4f3e35..f2885e8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@25.2.3) + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -35,9 +38,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -261,9 +261,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -379,9 +376,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -1696,6 +1690,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -4012,6 +4011,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4798,6 +4802,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -7394,6 +7408,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -9872,6 +9890,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10923,6 +10944,14 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + points-on-curve@0.2.0: {} points-on-path@0.2.1: From 7d8d6a5cafde72ca4284cba0f8709967a4d62f0b Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:38:18 -0500 Subject: [PATCH 048/874] chore: remove lockfile changes from release branch --- pnpm-lock.yaml | 47 +++++++++-------------------------------------- 1 file changed, 9 insertions(+), 38 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2885e8a..ff4f3e35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@25.2.3) - '@playwright/test': - specifier: ^1.58.2 - version: 1.58.2 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -38,6 +35,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -261,6 +261,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -376,6 +379,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -1690,11 +1696,6 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@playwright/test@1.58.2': - resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} - engines: {node: '>=18'} - hasBin: true - '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -4011,11 +4012,6 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4802,16 +4798,6 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - playwright-core@1.58.2: - resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.58.2: - resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} - engines: {node: '>=18'} - hasBin: true - points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -7408,10 +7394,6 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@playwright/test@1.58.2': - dependencies: - playwright: 1.58.2 - '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -9890,9 +9872,6 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -10944,14 +10923,6 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 - playwright-core@1.58.2: {} - - playwright@1.58.2: - dependencies: - playwright-core: 1.58.2 - optionalDependencies: - fsevents: 2.3.2 - points-on-curve@0.2.0: {} points-on-path@0.2.1: From 632079ae3b55002bb2e20957b8ca746e2e06e2a8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:43:04 -0500 Subject: [PATCH 049/874] chore: require frozen lockfile for releases --- .github/workflows/release.yml | 4 ++-- doc/RELEASING.md | 33 +++++++++++++++++++++------------ skills/release/SKILL.md | 7 +++++-- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 492b02b5..d456eb7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --no-frozen-lockfile + run: pnpm install --frozen-lockfile - name: Typecheck run: pnpm -r typecheck @@ -95,7 +95,7 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --no-frozen-lockfile + run: pnpm install --frozen-lockfile - name: Configure git author run: | diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 1751f466..1f9b7fae 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -26,20 +26,23 @@ Treat those as related but separate. npm can succeed while the GitHub Release is Use this when you want an installable prerelease without changing `latest`. ```bash -# 0. Preflight the canary candidate +# 0. Confirm master already has the CI-owned lockfile refresh merged +# If package manifests changed recently, wait for the refresh-lockfile PR first. + +# 1. Preflight the canary candidate ./scripts/release-preflight.sh canary patch -# 1. Draft or update the stable changelog for the intended stable version +# 2. Draft or update the stable changelog for the intended stable version VERSION=0.2.8 claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." -# 2. Preview the canary release +# 3. Preview the canary release ./scripts/release.sh patch --canary --dry-run -# 3. Publish the canary +# 4. Publish the canary ./scripts/release.sh patch --canary -# 4. Smoke test what users will actually install +# 5. Smoke test what users will actually install PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh # Users install with: @@ -60,27 +63,30 @@ Result: Use this only after the canary SHA is good enough to become the public default. ```bash -# 0. Start from the vetted commit +# 0. Confirm master already has the CI-owned lockfile refresh merged +# If package manifests changed recently, wait for the refresh-lockfile PR first. + +# 1. Start from the vetted commit git checkout master git pull -# 1. Preflight the stable candidate +# 2. Preflight the stable candidate ./scripts/release-preflight.sh stable patch -# 2. Confirm the stable changelog exists +# 3. Confirm the stable changelog exists VERSION=0.2.8 ls "releases/v${VERSION}.md" -# 3. Preview the stable publish +# 4. Preview the stable publish ./scripts/release.sh patch --dry-run -# 4. Publish the stable release to npm and create the local release commit + tag +# 5. Publish the stable release to npm and create the local release commit + tag ./scripts/release.sh patch -# 5. Push the release commit and tag +# 6. Push the release commit and tag git push public-gh HEAD:master --follow-tags -# 6. Create or update the GitHub Release from the pushed tag +# 7. Create or update the GitHub Release from the pushed tag ./scripts/create-github-release.sh X.Y.Z ``` @@ -163,6 +169,7 @@ The workflow: - [ ] The working tree is clean, including untracked files - [ ] The target branch and SHA are the ones you actually want to release +- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` - [ ] The required verification gate passed on that exact SHA - [ ] The bump type is correct for the user-visible impact - [ ] The stable changelog file exists or is ready to be written at `releases/vX.Y.Z.md` @@ -202,6 +209,8 @@ pnpm build This matches [`.github/workflows/pr-verify.yml`](../.github/workflows/pr-verify.yml). Run it before claiming a release candidate is ready. +The release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml) installs with `pnpm install --frozen-lockfile`. That is intentional. Releases must use the exact dependency graph already committed on `master`; if manifests changed and the CI-owned lockfile refresh has not landed yet, the release should fail until that prerequisite is merged. + For release work, prefer: ```bash diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index 866bdb22..085c1bad 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -36,8 +36,9 @@ Before proceeding, verify all of the following: 2. The repo working tree is clean, including untracked files. 3. There are commits since the last stable tag. 4. The release SHA has passed the verification gate or is about to. -5. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing. -6. If running through Paperclip, you have issue context for status updates and follow-up task creation. +5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`. +6. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing. +7. If running through Paperclip, you have issue context for status updates and follow-up task creation. If any precondition fails, stop and report the blocker. @@ -120,6 +121,8 @@ pnpm build If the release will be run through GitHub Actions, the workflow can rerun this gate. Still report whether the local tree currently passes. +The GitHub Actions release workflow installs with `pnpm install --frozen-lockfile`. Treat that as a release invariant, not a nuisance: if manifests changed and the lockfile refresh PR has not landed yet, stop and wait for `master` to contain the committed lockfile before shipping. + ## Step 4 — Publish a Canary Run: From 3ec96fdb7327dc0dce10320cee19ef48d28ccb8c Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 11:12:34 -0500 Subject: [PATCH 050/874] fix: complete authenticated docker onboard smoke --- Dockerfile.onboard-smoke | 2 +- doc/RELEASING.md | 2 ++ scripts/docker-onboard-smoke.sh | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Dockerfile.onboard-smoke b/Dockerfile.onboard-smoke index 7b13756b..ffc9a61e 100644 --- a/Dockerfile.onboard-smoke +++ b/Dockerfile.onboard-smoke @@ -37,4 +37,4 @@ WORKDIR /home/paperclip/workspace EXPOSE 3100 USER paperclip -CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\""] +CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\" & app_pid=$!; cleanup() { if kill -0 \"$app_pid\" >/dev/null 2>&1; then kill \"$app_pid\" >/dev/null 2>&1 || true; fi; }; trap cleanup EXIT INT TERM; ready=0; for _ in $(seq 1 60); do if curl -fsS \"http://127.0.0.1:${PORT}/api/health\" >/dev/null 2>&1; then ready=1; break; fi; sleep 1; done; if [ \"$ready\" -eq 1 ]; then echo; echo \"==> Creating bootstrap CEO invite after server startup\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" auth bootstrap-ceo --data-dir \"$PAPERCLIP_HOME\" || true; else echo; echo \"==> Warning: server did not become healthy within 60s; skipping bootstrap invite\"; fi; wait \"$app_pid\""] diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 1f9b7fae..a97a232f 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -126,6 +126,8 @@ This is the best existing fit when you want: - a dedicated host port - an end-to-end `npx paperclipai ... onboard` check +In authenticated/private mode, this smoke path also injects a smoke-only `BETTER_AUTH_SECRET` by default and prints the bootstrap CEO invite after the server becomes healthy. + If you want to exercise onboarding from a fresh local checkout rather than npm, use: ```bash diff --git a/scripts/docker-onboard-smoke.sh b/scripts/docker-onboard-smoke.sh index 2da125de..2477383f 100755 --- a/scripts/docker-onboard-smoke.sh +++ b/scripts/docker-onboard-smoke.sh @@ -9,6 +9,7 @@ DATA_DIR="${DATA_DIR:-$REPO_ROOT/data/docker-onboard-smoke}" HOST_UID="${HOST_UID:-$(id -u)}" PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}" PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}" +BETTER_AUTH_SECRET="${BETTER_AUTH_SECRET:-paperclip-onboard-smoke-secret}" DOCKER_TTY_ARGS=() if [[ -t 0 && -t 1 ]]; then @@ -38,5 +39,6 @@ docker run --rm \ -e PORT=3100 \ -e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \ -e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \ + -e BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \ -v "$DATA_DIR:/paperclip" \ "$IMAGE_NAME" From 8360b2e3e3764f1fb65e839f09b880761d5518ac Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 11:26:58 -0500 Subject: [PATCH 051/874] fix: complete authenticated onboarding startup --- Dockerfile.onboard-smoke | 2 +- cli/src/commands/auth-bootstrap-ceo.ts | 8 +- cli/src/commands/doctor.ts | 2 + cli/src/commands/onboard.ts | 16 +- cli/src/commands/run.ts | 50 +- cli/src/config/env.ts | 4 + doc/RELEASING.md | 2 +- scripts/docker-onboard-smoke.sh | 2 - server/src/index.ts | 1108 ++++++++++++------------ 9 files changed, 652 insertions(+), 542 deletions(-) diff --git a/Dockerfile.onboard-smoke b/Dockerfile.onboard-smoke index ffc9a61e..7b13756b 100644 --- a/Dockerfile.onboard-smoke +++ b/Dockerfile.onboard-smoke @@ -37,4 +37,4 @@ WORKDIR /home/paperclip/workspace EXPOSE 3100 USER paperclip -CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\" & app_pid=$!; cleanup() { if kill -0 \"$app_pid\" >/dev/null 2>&1; then kill \"$app_pid\" >/dev/null 2>&1 || true; fi; }; trap cleanup EXIT INT TERM; ready=0; for _ in $(seq 1 60); do if curl -fsS \"http://127.0.0.1:${PORT}/api/health\" >/dev/null 2>&1; then ready=1; break; fi; sleep 1; done; if [ \"$ready\" -eq 1 ]; then echo; echo \"==> Creating bootstrap CEO invite after server startup\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" auth bootstrap-ceo --data-dir \"$PAPERCLIP_HOME\" || true; else echo; echo \"==> Warning: server did not become healthy within 60s; skipping bootstrap invite\"; fi; wait \"$app_pid\""] +CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\""] diff --git a/cli/src/commands/auth-bootstrap-ceo.ts b/cli/src/commands/auth-bootstrap-ceo.ts index a844c447..ec539396 100644 --- a/cli/src/commands/auth-bootstrap-ceo.ts +++ b/cli/src/commands/auth-bootstrap-ceo.ts @@ -3,6 +3,7 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; import { and, eq, gt, isNull } from "drizzle-orm"; import { createDb, instanceUserRoles, invites } from "@paperclipai/db"; +import { loadPaperclipEnvFile } from "../config/env.js"; import { readConfig, resolveConfigPath } from "../config/store.js"; function hashToken(token: string) { @@ -13,7 +14,8 @@ function createInviteToken() { return `pcp_bootstrap_${randomBytes(24).toString("hex")}`; } -function resolveDbUrl(configPath?: string) { +function resolveDbUrl(configPath?: string, explicitDbUrl?: string) { + if (explicitDbUrl) return explicitDbUrl; const config = readConfig(configPath); if (process.env.DATABASE_URL) return process.env.DATABASE_URL; if (config?.database.mode === "postgres" && config.database.connectionString) { @@ -49,8 +51,10 @@ export async function bootstrapCeoInvite(opts: { force?: boolean; expiresHours?: number; baseUrl?: string; + dbUrl?: string; }) { const configPath = resolveConfigPath(opts.config); + loadPaperclipEnvFile(configPath); const config = readConfig(configPath); if (!config) { p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`); @@ -62,7 +66,7 @@ export async function bootstrapCeoInvite(opts: { return; } - const dbUrl = resolveDbUrl(configPath); + const dbUrl = resolveDbUrl(configPath, opts.dbUrl); if (!dbUrl) { p.log.error( "Could not resolve database connection for bootstrap.", diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index f6ec1f4f..ab99b012 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -14,6 +14,7 @@ import { storageCheck, type CheckResult, } from "../checks/index.js"; +import { loadPaperclipEnvFile } from "../config/env.js"; import { printPaperclipCliBanner } from "../utils/banner.js"; const STATUS_ICON = { @@ -31,6 +32,7 @@ export async function doctor(opts: { p.intro(pc.bgCyan(pc.black(" paperclip doctor "))); const configPath = resolveConfigPath(opts.config); + loadPaperclipEnvFile(configPath); const results: CheckResult[] = []; // 1. Config check (must pass before others) diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index e3f17001..523484f3 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -229,6 +229,10 @@ function quickstartDefaultsFromEnv(): { return { defaults, usedEnvKeys, ignoredEnvKeys }; } +function canCreateBootstrapInviteImmediately(config: Pick): boolean { + return config.server.deploymentMode === "authenticated" && config.database.mode !== "embedded-postgres"; +} + export async function onboard(opts: OnboardOptions): Promise { printPaperclipCliBanner(); p.intro(pc.bgCyan(pc.black(" paperclipai onboard "))); @@ -450,7 +454,7 @@ export async function onboard(opts: OnboardOptions): Promise { "Next commands", ); - if (server.deploymentMode === "authenticated") { + if (canCreateBootstrapInviteImmediately({ database, server })) { p.log.step("Generating bootstrap CEO invite"); await bootstrapCeoInvite({ config: configPath }); } @@ -473,5 +477,15 @@ export async function onboard(opts: OnboardOptions): Promise { return; } + if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") { + p.log.info( + [ + "Bootstrap CEO invite will be created after the server starts.", + `Next: ${pc.cyan("paperclipai run")}`, + `Then: ${pc.cyan("paperclipai auth bootstrap-ceo")}`, + ].join("\n"), + ); + } + p.outro("You're all set!"); } diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 6e061b2e..a6606745 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -3,9 +3,13 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import * as p from "@clack/prompts"; import pc from "picocolors"; +import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js"; import { onboard } from "./onboard.js"; import { doctor } from "./doctor.js"; +import { loadPaperclipEnvFile } from "../config/env.js"; import { configExists, resolveConfigPath } from "../config/store.js"; +import type { PaperclipConfig } from "../config/schema.js"; +import { readConfig } from "../config/store.js"; import { describeLocalInstancePaths, resolvePaperclipHomeDir, @@ -19,6 +23,13 @@ interface RunOptions { yes?: boolean; } +interface StartedServer { + apiUrl: string; + databaseUrl: string; + host: string; + listenPort: number; +} + export async function runCommand(opts: RunOptions): Promise { const instanceId = resolvePaperclipInstanceId(opts.instance); process.env.PAPERCLIP_INSTANCE_ID = instanceId; @@ -31,6 +42,7 @@ export async function runCommand(opts: RunOptions): Promise { const configPath = resolveConfigPath(opts.config); process.env.PAPERCLIP_CONFIG = configPath; + loadPaperclipEnvFile(configPath); p.intro(pc.bgCyan(pc.black(" paperclipai run "))); p.log.message(pc.dim(`Home: ${paths.homeDir}`)); @@ -60,8 +72,23 @@ export async function runCommand(opts: RunOptions): Promise { process.exit(1); } + const config = readConfig(configPath); + if (!config) { + p.log.error(`No config found at ${configPath}.`); + process.exit(1); + } + p.log.step("Starting Paperclip server..."); - await importServerEntry(); + const startedServer = await importServerEntry(); + + if (shouldGenerateBootstrapInviteAfterStart(config)) { + p.log.step("Generating bootstrap CEO invite"); + await bootstrapCeoInvite({ + config: configPath, + dbUrl: startedServer.databaseUrl, + baseUrl: startedServer.apiUrl.replace(/\/api$/, ""), + }); + } } function formatError(err: unknown): string { @@ -101,19 +128,20 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void { } } -async function importServerEntry(): Promise { +async function importServerEntry(): Promise { // Dev mode: try local workspace path (monorepo with tsx) const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); const devEntry = path.resolve(projectRoot, "server/src/index.ts"); if (fs.existsSync(devEntry)) { maybeEnableUiDevMiddleware(devEntry); - await import(pathToFileURL(devEntry).href); - return; + const mod = await import(pathToFileURL(devEntry).href); + return await startServerFromModule(mod, devEntry); } // Production mode: import the published @paperclipai/server package try { - await import("@paperclipai/server"); + const mod = await import("@paperclipai/server"); + return await startServerFromModule(mod, "@paperclipai/server"); } catch (err) { const missingSpecifier = getMissingModuleSpecifier(err); const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server"; @@ -130,3 +158,15 @@ async function importServerEntry(): Promise { ); } } + +function shouldGenerateBootstrapInviteAfterStart(config: PaperclipConfig): boolean { + return config.server.deploymentMode === "authenticated" && config.database.mode === "embedded-postgres"; +} + +async function startServerFromModule(mod: unknown, label: string): Promise { + const startServer = (mod as { startServer?: () => Promise }).startServer; + if (typeof startServer !== "function") { + throw new Error(`Paperclip server entrypoint did not export startServer(): ${label}`); + } + return await startServer(); +} diff --git a/cli/src/config/env.ts b/cli/src/config/env.ts index 908907ba..0ca4bcc1 100644 --- a/cli/src/config/env.ts +++ b/cli/src/config/env.ts @@ -36,6 +36,10 @@ export function resolveAgentJwtEnvFile(configPath?: string): string { return resolveEnvFilePath(configPath); } +export function loadPaperclipEnvFile(configPath?: string): void { + loadAgentJwtEnvFile(resolveEnvFilePath(configPath)); +} + export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void { if (loadedEnvFiles.has(filePath)) return; diff --git a/doc/RELEASING.md b/doc/RELEASING.md index a97a232f..bd082807 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -126,7 +126,7 @@ This is the best existing fit when you want: - a dedicated host port - an end-to-end `npx paperclipai ... onboard` check -In authenticated/private mode, this smoke path also injects a smoke-only `BETTER_AUTH_SECRET` by default and prints the bootstrap CEO invite after the server becomes healthy. +In authenticated/private mode, the expected result is a full authenticated onboarding flow, including printing the bootstrap CEO invite once startup completes. If you want to exercise onboarding from a fresh local checkout rather than npm, use: diff --git a/scripts/docker-onboard-smoke.sh b/scripts/docker-onboard-smoke.sh index 2477383f..2da125de 100755 --- a/scripts/docker-onboard-smoke.sh +++ b/scripts/docker-onboard-smoke.sh @@ -9,7 +9,6 @@ DATA_DIR="${DATA_DIR:-$REPO_ROOT/data/docker-onboard-smoke}" HOST_UID="${HOST_UID:-$(id -u)}" PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}" PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}" -BETTER_AUTH_SECRET="${BETTER_AUTH_SECRET:-paperclip-onboard-smoke-secret}" DOCKER_TTY_ARGS=() if [[ -t 0 && -t 1 ]]; then @@ -39,6 +38,5 @@ docker run --rm \ -e PORT=3100 \ -e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \ -e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \ - -e BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \ -v "$DATA_DIR:/paperclip" \ "$IMAGE_NAME" diff --git a/server/src/index.ts b/server/src/index.ts index e78a6479..71992ce2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,6 +4,7 @@ import { createServer } from "node:http"; import { resolve } from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; +import { pathToFileURL } from "node:url"; import type { Request as ExpressRequest, RequestHandler } from "express"; import { and, eq } from "drizzle-orm"; import { @@ -56,75 +57,99 @@ type EmbeddedPostgresCtor = new (opts: { onError?: (message: unknown) => void; }) => EmbeddedPostgresInstance; -const config = loadConfig(); -if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) { - process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider; -} -if (process.env.PAPERCLIP_SECRETS_STRICT_MODE === undefined) { - process.env.PAPERCLIP_SECRETS_STRICT_MODE = config.secretsStrictMode ? "true" : "false"; -} -if (process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE === undefined) { - process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = config.secretsMasterKeyFilePath; + +export interface StartedServer { + server: ReturnType; + host: string; + listenPort: number; + apiUrl: string; + databaseUrl: string; } -type MigrationSummary = - | "skipped" - | "already applied" - | "applied (empty database)" - | "applied (pending migrations)" - | "pending migrations skipped"; - -function formatPendingMigrationSummary(migrations: string[]): string { - if (migrations.length === 0) return "none"; - return migrations.length > 3 - ? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)` - : migrations.join(", "); -} - -async function promptApplyMigrations(migrations: string[]): Promise { - if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return false; - if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true") return true; - if (!stdin.isTTY || !stdout.isTTY) return true; - - const prompt = createInterface({ input: stdin, output: stdout }); - try { - const answer = (await prompt.question( - `Apply pending migrations (${formatPendingMigrationSummary(migrations)}) now? (y/N): `, - )).trim().toLowerCase(); - return answer === "y" || answer === "yes"; - } finally { - prompt.close(); +export async function startServer(): Promise { + const config = loadConfig(); + if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) { + process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider; } -} - -type EnsureMigrationsOptions = { - autoApply?: boolean; -}; - -async function ensureMigrations( - connectionString: string, - label: string, - opts?: EnsureMigrationsOptions, -): Promise { - const autoApply = opts?.autoApply === true; - let state = await inspectMigrations(connectionString); - if (state.status === "needsMigrations" && state.reason === "pending-migrations") { - const repair = await reconcilePendingMigrationHistory(connectionString); - if (repair.repairedMigrations.length > 0) { - logger.warn( - { repairedMigrations: repair.repairedMigrations }, - `${label} had drifted migration history; repaired migration journal entries from existing schema state.`, - ); - state = await inspectMigrations(connectionString); - if (state.status === "upToDate") return "already applied"; + if (process.env.PAPERCLIP_SECRETS_STRICT_MODE === undefined) { + process.env.PAPERCLIP_SECRETS_STRICT_MODE = config.secretsStrictMode ? "true" : "false"; + } + if (process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE === undefined) { + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = config.secretsMasterKeyFilePath; + } + + type MigrationSummary = + | "skipped" + | "already applied" + | "applied (empty database)" + | "applied (pending migrations)" + | "pending migrations skipped"; + + function formatPendingMigrationSummary(migrations: string[]): string { + if (migrations.length === 0) return "none"; + return migrations.length > 3 + ? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)` + : migrations.join(", "); + } + + async function promptApplyMigrations(migrations: string[]): Promise { + if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return false; + if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true") return true; + if (!stdin.isTTY || !stdout.isTTY) return true; + + const prompt = createInterface({ input: stdin, output: stdout }); + try { + const answer = (await prompt.question( + `Apply pending migrations (${formatPendingMigrationSummary(migrations)}) now? (y/N): `, + )).trim().toLowerCase(); + return answer === "y" || answer === "yes"; + } finally { + prompt.close(); } } - if (state.status === "upToDate") return "already applied"; - if (state.status === "needsMigrations" && state.reason === "no-migration-journal-non-empty-db") { - logger.warn( - { tableCount: state.tableCount }, - `${label} has existing tables but no migration journal. Run migrations manually to sync schema.`, - ); + + type EnsureMigrationsOptions = { + autoApply?: boolean; + }; + + async function ensureMigrations( + connectionString: string, + label: string, + opts?: EnsureMigrationsOptions, + ): Promise { + const autoApply = opts?.autoApply === true; + let state = await inspectMigrations(connectionString); + if (state.status === "needsMigrations" && state.reason === "pending-migrations") { + const repair = await reconcilePendingMigrationHistory(connectionString); + if (repair.repairedMigrations.length > 0) { + logger.warn( + { repairedMigrations: repair.repairedMigrations }, + `${label} had drifted migration history; repaired migration journal entries from existing schema state.`, + ); + state = await inspectMigrations(connectionString); + if (state.status === "upToDate") return "already applied"; + } + } + if (state.status === "upToDate") return "already applied"; + if (state.status === "needsMigrations" && state.reason === "no-migration-journal-non-empty-db") { + logger.warn( + { tableCount: state.tableCount }, + `${label} has existing tables but no migration journal. Run migrations manually to sync schema.`, + ); + const apply = autoApply ? true : await promptApplyMigrations(state.pendingMigrations); + if (!apply) { + logger.warn( + { pendingMigrations: state.pendingMigrations }, + `${label} has pending migrations; continuing without applying. Run pnpm db:migrate to apply before startup.`, + ); + return "pending migrations skipped"; + } + + logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`); + await applyPendingMigrations(connectionString); + return "applied (pending migrations)"; + } + const apply = autoApply ? true : await promptApplyMigrations(state.pendingMigrations); if (!apply) { logger.warn( @@ -133,499 +158,522 @@ async function ensureMigrations( ); return "pending migrations skipped"; } - + logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`); await applyPendingMigrations(connectionString); return "applied (pending migrations)"; } - - const apply = autoApply ? true : await promptApplyMigrations(state.pendingMigrations); - if (!apply) { - logger.warn( - { pendingMigrations: state.pendingMigrations }, - `${label} has pending migrations; continuing without applying. Run pnpm db:migrate to apply before startup.`, - ); - return "pending migrations skipped"; + + function isLoopbackHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; } - - logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`); - await applyPendingMigrations(connectionString); - return "applied (pending migrations)"; -} - -function isLoopbackHost(host: string): boolean { - const normalized = host.trim().toLowerCase(); - return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; -} - -const LOCAL_BOARD_USER_ID = "local-board"; -const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local"; -const LOCAL_BOARD_USER_NAME = "Board"; - -async function ensureLocalTrustedBoardPrincipal(db: any): Promise { - const now = new Date(); - const existingUser = await db - .select({ id: authUsers.id }) - .from(authUsers) - .where(eq(authUsers.id, LOCAL_BOARD_USER_ID)) - .then((rows: Array<{ id: string }>) => rows[0] ?? null); - - if (!existingUser) { - await db.insert(authUsers).values({ - id: LOCAL_BOARD_USER_ID, - name: LOCAL_BOARD_USER_NAME, - email: LOCAL_BOARD_USER_EMAIL, - emailVerified: true, - image: null, - createdAt: now, - updatedAt: now, - }); - } - - const role = await db - .select({ id: instanceUserRoles.id }) - .from(instanceUserRoles) - .where(and(eq(instanceUserRoles.userId, LOCAL_BOARD_USER_ID), eq(instanceUserRoles.role, "instance_admin"))) - .then((rows: Array<{ id: string }>) => rows[0] ?? null); - if (!role) { - await db.insert(instanceUserRoles).values({ - userId: LOCAL_BOARD_USER_ID, - role: "instance_admin", - }); - } - - const companyRows = await db.select({ id: companies.id }).from(companies); - for (const company of companyRows) { - const membership = await db - .select({ id: companyMemberships.id }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, company.id), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, LOCAL_BOARD_USER_ID), - ), - ) + + const LOCAL_BOARD_USER_ID = "local-board"; + const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local"; + const LOCAL_BOARD_USER_NAME = "Board"; + + async function ensureLocalTrustedBoardPrincipal(db: any): Promise { + const now = new Date(); + const existingUser = await db + .select({ id: authUsers.id }) + .from(authUsers) + .where(eq(authUsers.id, LOCAL_BOARD_USER_ID)) .then((rows: Array<{ id: string }>) => rows[0] ?? null); - if (membership) continue; - await db.insert(companyMemberships).values({ - companyId: company.id, - principalType: "user", - principalId: LOCAL_BOARD_USER_ID, - status: "active", - membershipRole: "owner", - }); - } -} - -let db; -let embeddedPostgres: EmbeddedPostgresInstance | null = null; -let embeddedPostgresStartedByThisProcess = false; -let migrationSummary: MigrationSummary = "skipped"; -let activeDatabaseConnectionString: string; -let startupDbInfo: - | { mode: "external-postgres"; connectionString: string } - | { mode: "embedded-postgres"; dataDir: string; port: number }; -if (config.databaseUrl) { - migrationSummary = await ensureMigrations(config.databaseUrl, "PostgreSQL"); - - db = createDb(config.databaseUrl); - logger.info("Using external PostgreSQL via DATABASE_URL/config"); - activeDatabaseConnectionString = config.databaseUrl; - startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl }; -} else { - const moduleName = "embedded-postgres"; - let EmbeddedPostgres: EmbeddedPostgresCtor; - try { - const mod = await import(moduleName); - EmbeddedPostgres = mod.default as EmbeddedPostgresCtor; - } catch { - throw new Error( - "Embedded PostgreSQL mode requires dependency `embedded-postgres`. Reinstall dependencies (without omitting required packages), or set DATABASE_URL for external Postgres.", - ); - } - - const dataDir = resolve(config.embeddedPostgresDataDir); - const configuredPort = config.embeddedPostgresPort; - let port = configuredPort; - const embeddedPostgresLogBuffer: string[] = []; - const EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT = 120; - const verboseEmbeddedPostgresLogs = process.env.PAPERCLIP_EMBEDDED_POSTGRES_VERBOSE === "true"; - const appendEmbeddedPostgresLog = (message: unknown) => { - const text = typeof message === "string" ? message : message instanceof Error ? message.message : String(message ?? ""); - for (const lineRaw of text.split(/\r?\n/)) { - const line = lineRaw.trim(); - if (!line) continue; - embeddedPostgresLogBuffer.push(line); - if (embeddedPostgresLogBuffer.length > EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT) { - embeddedPostgresLogBuffer.splice(0, embeddedPostgresLogBuffer.length - EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT); - } - if (verboseEmbeddedPostgresLogs) { - logger.info({ embeddedPostgresLog: line }, "embedded-postgres"); - } + + if (!existingUser) { + await db.insert(authUsers).values({ + id: LOCAL_BOARD_USER_ID, + name: LOCAL_BOARD_USER_NAME, + email: LOCAL_BOARD_USER_EMAIL, + emailVerified: true, + image: null, + createdAt: now, + updatedAt: now, + }); } - }; - const logEmbeddedPostgresFailure = (phase: "initialise" | "start", err: unknown) => { - if (embeddedPostgresLogBuffer.length > 0) { - logger.error( - { - phase, - recentLogs: embeddedPostgresLogBuffer, - err, - }, - "Embedded PostgreSQL failed; showing buffered startup logs", + + const role = await db + .select({ id: instanceUserRoles.id }) + .from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, LOCAL_BOARD_USER_ID), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows: Array<{ id: string }>) => rows[0] ?? null); + if (!role) { + await db.insert(instanceUserRoles).values({ + userId: LOCAL_BOARD_USER_ID, + role: "instance_admin", + }); + } + + const companyRows = await db.select({ id: companies.id }).from(companies); + for (const company of companyRows) { + const membership = await db + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, company.id), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, LOCAL_BOARD_USER_ID), + ), + ) + .then((rows: Array<{ id: string }>) => rows[0] ?? null); + if (membership) continue; + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: LOCAL_BOARD_USER_ID, + status: "active", + membershipRole: "owner", + }); + } + } + + let db; + let embeddedPostgres: EmbeddedPostgresInstance | null = null; + let embeddedPostgresStartedByThisProcess = false; + let migrationSummary: MigrationSummary = "skipped"; + let activeDatabaseConnectionString: string; + let startupDbInfo: + | { mode: "external-postgres"; connectionString: string } + | { mode: "embedded-postgres"; dataDir: string; port: number }; + if (config.databaseUrl) { + migrationSummary = await ensureMigrations(config.databaseUrl, "PostgreSQL"); + + db = createDb(config.databaseUrl); + logger.info("Using external PostgreSQL via DATABASE_URL/config"); + activeDatabaseConnectionString = config.databaseUrl; + startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl }; + } else { + const moduleName = "embedded-postgres"; + let EmbeddedPostgres: EmbeddedPostgresCtor; + try { + const mod = await import(moduleName); + EmbeddedPostgres = mod.default as EmbeddedPostgresCtor; + } catch { + throw new Error( + "Embedded PostgreSQL mode requires dependency `embedded-postgres`. Reinstall dependencies (without omitting required packages), or set DATABASE_URL for external Postgres.", ); } - }; - - if (config.databaseMode === "postgres") { - logger.warn("Database mode is postgres but no connection string was set; falling back to embedded PostgreSQL"); - } - - const clusterVersionFile = resolve(dataDir, "PG_VERSION"); - const clusterAlreadyInitialized = existsSync(clusterVersionFile); - const postmasterPidFile = resolve(dataDir, "postmaster.pid"); - const isPidRunning = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch { - return false; + + const dataDir = resolve(config.embeddedPostgresDataDir); + const configuredPort = config.embeddedPostgresPort; + let port = configuredPort; + const embeddedPostgresLogBuffer: string[] = []; + const EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT = 120; + const verboseEmbeddedPostgresLogs = process.env.PAPERCLIP_EMBEDDED_POSTGRES_VERBOSE === "true"; + const appendEmbeddedPostgresLog = (message: unknown) => { + const text = typeof message === "string" ? message : message instanceof Error ? message.message : String(message ?? ""); + for (const lineRaw of text.split(/\r?\n/)) { + const line = lineRaw.trim(); + if (!line) continue; + embeddedPostgresLogBuffer.push(line); + if (embeddedPostgresLogBuffer.length > EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT) { + embeddedPostgresLogBuffer.splice(0, embeddedPostgresLogBuffer.length - EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT); + } + if (verboseEmbeddedPostgresLogs) { + logger.info({ embeddedPostgresLog: line }, "embedded-postgres"); + } + } + }; + const logEmbeddedPostgresFailure = (phase: "initialise" | "start", err: unknown) => { + if (embeddedPostgresLogBuffer.length > 0) { + logger.error( + { + phase, + recentLogs: embeddedPostgresLogBuffer, + err, + }, + "Embedded PostgreSQL failed; showing buffered startup logs", + ); + } + }; + + if (config.databaseMode === "postgres") { + logger.warn("Database mode is postgres but no connection string was set; falling back to embedded PostgreSQL"); } - }; - - const getRunningPid = (): number | null => { - if (!existsSync(postmasterPidFile)) return null; - try { - const pidLine = readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim(); - const pid = Number(pidLine); - if (!Number.isInteger(pid) || pid <= 0) return null; - if (!isPidRunning(pid)) return null; - return pid; - } catch { - return null; - } - }; - - const runningPid = getRunningPid(); - if (runningPid) { - logger.warn(`Embedded PostgreSQL already running; reusing existing process (pid=${runningPid}, port=${port})`); - } else { - const detectedPort = await detectPort(configuredPort); - if (detectedPort !== configuredPort) { - logger.warn(`Embedded PostgreSQL port is in use; using next free port (requestedPort=${configuredPort}, selectedPort=${detectedPort})`); - } - port = detectedPort; - logger.info(`Using embedded PostgreSQL because no DATABASE_URL set (dataDir=${dataDir}, port=${port})`); - embeddedPostgres = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - onLog: appendEmbeddedPostgresLog, - onError: appendEmbeddedPostgresLog, - }); - - if (!clusterAlreadyInitialized) { + + const clusterVersionFile = resolve(dataDir, "PG_VERSION"); + const clusterAlreadyInitialized = existsSync(clusterVersionFile); + const postmasterPidFile = resolve(dataDir, "postmaster.pid"); + const isPidRunning = (pid: number): boolean => { try { - await embeddedPostgres.initialise(); + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + + const getRunningPid = (): number | null => { + if (!existsSync(postmasterPidFile)) return null; + try { + const pidLine = readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim(); + const pid = Number(pidLine); + if (!Number.isInteger(pid) || pid <= 0) return null; + if (!isPidRunning(pid)) return null; + return pid; + } catch { + return null; + } + }; + + const runningPid = getRunningPid(); + if (runningPid) { + logger.warn(`Embedded PostgreSQL already running; reusing existing process (pid=${runningPid}, port=${port})`); + } else { + const detectedPort = await detectPort(configuredPort); + if (detectedPort !== configuredPort) { + logger.warn(`Embedded PostgreSQL port is in use; using next free port (requestedPort=${configuredPort}, selectedPort=${detectedPort})`); + } + port = detectedPort; + logger.info(`Using embedded PostgreSQL because no DATABASE_URL set (dataDir=${dataDir}, port=${port})`); + embeddedPostgres = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + onLog: appendEmbeddedPostgresLog, + onError: appendEmbeddedPostgresLog, + }); + + if (!clusterAlreadyInitialized) { + try { + await embeddedPostgres.initialise(); + } catch (err) { + logEmbeddedPostgresFailure("initialise", err); + throw err; + } + } else { + logger.info(`Embedded PostgreSQL cluster already exists (${clusterVersionFile}); skipping init`); + } + + if (existsSync(postmasterPidFile)) { + logger.warn("Removing stale embedded PostgreSQL lock file"); + rmSync(postmasterPidFile, { force: true }); + } + try { + await embeddedPostgres.start(); } catch (err) { - logEmbeddedPostgresFailure("initialise", err); + logEmbeddedPostgresFailure("start", err); throw err; } - } else { - logger.info(`Embedded PostgreSQL cluster already exists (${clusterVersionFile}); skipping init`); + embeddedPostgresStartedByThisProcess = true; } - - if (existsSync(postmasterPidFile)) { - logger.warn("Removing stale embedded PostgreSQL lock file"); - rmSync(postmasterPidFile, { force: true }); + + const embeddedAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + const dbStatus = await ensurePostgresDatabase(embeddedAdminConnectionString, "paperclip"); + if (dbStatus === "created") { + logger.info("Created embedded PostgreSQL database: paperclip"); } - try { - await embeddedPostgres.start(); - } catch (err) { - logEmbeddedPostgresFailure("start", err); - throw err; + + const embeddedConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + const shouldAutoApplyFirstRunMigrations = !clusterAlreadyInitialized || dbStatus === "created"; + if (shouldAutoApplyFirstRunMigrations) { + logger.info("Detected first-run embedded PostgreSQL setup; applying pending migrations automatically"); } - embeddedPostgresStartedByThisProcess = true; + migrationSummary = await ensureMigrations(embeddedConnectionString, "Embedded PostgreSQL", { + autoApply: shouldAutoApplyFirstRunMigrations, + }); + + db = createDb(embeddedConnectionString); + logger.info("Embedded PostgreSQL ready"); + activeDatabaseConnectionString = embeddedConnectionString; + startupDbInfo = { mode: "embedded-postgres", dataDir, port }; } - - const embeddedAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - const dbStatus = await ensurePostgresDatabase(embeddedAdminConnectionString, "paperclip"); - if (dbStatus === "created") { - logger.info("Created embedded PostgreSQL database: paperclip"); - } - - const embeddedConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - const shouldAutoApplyFirstRunMigrations = !clusterAlreadyInitialized || dbStatus === "created"; - if (shouldAutoApplyFirstRunMigrations) { - logger.info("Detected first-run embedded PostgreSQL setup; applying pending migrations automatically"); - } - migrationSummary = await ensureMigrations(embeddedConnectionString, "Embedded PostgreSQL", { - autoApply: shouldAutoApplyFirstRunMigrations, - }); - - db = createDb(embeddedConnectionString); - logger.info("Embedded PostgreSQL ready"); - activeDatabaseConnectionString = embeddedConnectionString; - startupDbInfo = { mode: "embedded-postgres", dataDir, port }; -} - -if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) { - throw new Error( - `local_trusted mode requires loopback host binding (received: ${config.host}). ` + - "Use authenticated mode for non-loopback deployments.", - ); -} - -if (config.deploymentMode === "local_trusted" && config.deploymentExposure !== "private") { - throw new Error("local_trusted mode only supports private exposure"); -} - -if (config.deploymentMode === "authenticated") { - if (config.authBaseUrlMode === "explicit" && !config.authPublicBaseUrl) { - throw new Error("auth.baseUrlMode=explicit requires auth.publicBaseUrl"); - } - if (config.deploymentExposure === "public") { - if (config.authBaseUrlMode !== "explicit") { - throw new Error("authenticated public exposure requires auth.baseUrlMode=explicit"); - } - if (!config.authPublicBaseUrl) { - throw new Error("authenticated public exposure requires auth.publicBaseUrl"); - } - } -} - -let authReady = config.deploymentMode === "local_trusted"; -let betterAuthHandler: RequestHandler | undefined; -let resolveSession: - | ((req: ExpressRequest) => Promise) - | undefined; -let resolveSessionFromHeaders: - | ((headers: Headers) => Promise) - | undefined; -if (config.deploymentMode === "local_trusted") { - await ensureLocalTrustedBoardPrincipal(db as any); -} -if (config.deploymentMode === "authenticated") { - const { - createBetterAuthHandler, - createBetterAuthInstance, - deriveAuthTrustedOrigins, - resolveBetterAuthSession, - resolveBetterAuthSessionFromHeaders, - } = await import("./auth/better-auth.js"); - const betterAuthSecret = - process.env.BETTER_AUTH_SECRET?.trim() ?? process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim(); - if (!betterAuthSecret) { + + if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) { throw new Error( - "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET) to be set", + `local_trusted mode requires loopback host binding (received: ${config.host}). ` + + "Use authenticated mode for non-loopback deployments.", ); } - const derivedTrustedOrigins = deriveAuthTrustedOrigins(config); - const envTrustedOrigins = (process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? "") - .split(",") - .map((value) => value.trim()) - .filter((value) => value.length > 0); - const effectiveTrustedOrigins = Array.from(new Set([...derivedTrustedOrigins, ...envTrustedOrigins])); - logger.info( - { - authBaseUrlMode: config.authBaseUrlMode, - authPublicBaseUrl: config.authPublicBaseUrl ?? null, - trustedOrigins: effectiveTrustedOrigins, - trustedOriginsSource: { - derived: derivedTrustedOrigins.length, - env: envTrustedOrigins.length, - }, - }, - "Authenticated mode auth origin configuration", - ); - const auth = createBetterAuthInstance(db as any, config, effectiveTrustedOrigins); - betterAuthHandler = createBetterAuthHandler(auth); - resolveSession = (req) => resolveBetterAuthSession(auth, req); - resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers); - await initializeBoardClaimChallenge(db as any, { deploymentMode: config.deploymentMode }); - authReady = true; -} - -const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none"; -const storageService = createStorageServiceFromConfig(config); -const app = await createApp(db as any, { - uiMode, - storageService, - deploymentMode: config.deploymentMode, - deploymentExposure: config.deploymentExposure, - allowedHostnames: config.allowedHostnames, - bindHost: config.host, - authReady, - companyDeletionEnabled: config.companyDeletionEnabled, - betterAuthHandler, - resolveSession, -}); -const server = createServer(app as unknown as Parameters[0]); -const listenPort = await detectPort(config.port); - -if (listenPort !== config.port) { - logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`); -} - -const runtimeListenHost = config.host; -const runtimeApiHost = - runtimeListenHost === "0.0.0.0" || runtimeListenHost === "::" - ? "localhost" - : runtimeListenHost; -process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost; -process.env.PAPERCLIP_LISTEN_PORT = String(listenPort); -process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`; - -setupLiveEventsWebSocketServer(server, db as any, { - deploymentMode: config.deploymentMode, - resolveSessionFromHeaders, -}); - -if (config.heartbeatSchedulerEnabled) { - const heartbeat = heartbeatService(db as any); - - // Reap orphaned runs at startup (no threshold -- runningProcesses is empty) - void heartbeat.reapOrphanedRuns().catch((err) => { - logger.error({ err }, "startup reap of orphaned heartbeat runs failed"); - }); - - setInterval(() => { - void heartbeat - .tickTimers(new Date()) - .then((result) => { - if (result.enqueued > 0) { - logger.info({ ...result }, "heartbeat timer tick enqueued runs"); - } - }) - .catch((err) => { - logger.error({ err }, "heartbeat timer tick failed"); - }); - - // Periodically reap orphaned runs (5-min staleness threshold) - void heartbeat - .reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000 }) - .catch((err) => { - logger.error({ err }, "periodic reap of orphaned heartbeat runs failed"); - }); - }, config.heartbeatSchedulerIntervalMs); -} - -if (config.databaseBackupEnabled) { - const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000; - 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 { - const result = await runDatabaseBackup({ - connectionString: activeDatabaseConnectionString, - backupDir: config.databaseBackupDir, - retentionDays: config.databaseBackupRetentionDays, - filenamePrefix: "paperclip", - }); - logger.info( - { - backupFile: result.backupFile, - sizeBytes: result.sizeBytes, - prunedCount: result.prunedCount, - backupDir: config.databaseBackupDir, - retentionDays: config.databaseBackupRetentionDays, - }, - `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`, - ); - } catch (err) { - logger.error({ err, backupDir: config.databaseBackupDir }, "Automatic database backup failed"); - } finally { - backupInFlight = false; - } - }; - - logger.info( - { - intervalMinutes: config.databaseBackupIntervalMinutes, - retentionDays: config.databaseBackupRetentionDays, - backupDir: config.databaseBackupDir, - }, - "Automatic database backups enabled", - ); - setInterval(() => { - void runScheduledBackup(); - }, backupIntervalMs); -} - -server.listen(listenPort, config.host, () => { - logger.info(`Server listening on ${config.host}:${listenPort}`); - if (process.env.PAPERCLIP_OPEN_ON_LISTEN === "true") { - const openHost = config.host === "0.0.0.0" || config.host === "::" ? "127.0.0.1" : config.host; - const url = `http://${openHost}:${listenPort}`; - void import("open") - .then((mod) => mod.default(url)) - .then(() => { - logger.info(`Opened browser at ${url}`); - }) - .catch((err) => { - logger.warn({ err, url }, "Failed to open browser on startup"); - }); + + if (config.deploymentMode === "local_trusted" && config.deploymentExposure !== "private") { + throw new Error("local_trusted mode only supports private exposure"); } - printStartupBanner({ - host: config.host, + + if (config.deploymentMode === "authenticated") { + if (config.authBaseUrlMode === "explicit" && !config.authPublicBaseUrl) { + throw new Error("auth.baseUrlMode=explicit requires auth.publicBaseUrl"); + } + if (config.deploymentExposure === "public") { + if (config.authBaseUrlMode !== "explicit") { + throw new Error("authenticated public exposure requires auth.baseUrlMode=explicit"); + } + if (!config.authPublicBaseUrl) { + throw new Error("authenticated public exposure requires auth.publicBaseUrl"); + } + } + } + + let authReady = config.deploymentMode === "local_trusted"; + let betterAuthHandler: RequestHandler | undefined; + let resolveSession: + | ((req: ExpressRequest) => Promise) + | undefined; + let resolveSessionFromHeaders: + | ((headers: Headers) => Promise) + | undefined; + if (config.deploymentMode === "local_trusted") { + await ensureLocalTrustedBoardPrincipal(db as any); + } + if (config.deploymentMode === "authenticated") { + const { + createBetterAuthHandler, + createBetterAuthInstance, + deriveAuthTrustedOrigins, + resolveBetterAuthSession, + resolveBetterAuthSessionFromHeaders, + } = await import("./auth/better-auth.js"); + const betterAuthSecret = + process.env.BETTER_AUTH_SECRET?.trim() ?? process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim(); + if (!betterAuthSecret) { + throw new Error( + "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET) to be set", + ); + } + const derivedTrustedOrigins = deriveAuthTrustedOrigins(config); + const envTrustedOrigins = (process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + const effectiveTrustedOrigins = Array.from(new Set([...derivedTrustedOrigins, ...envTrustedOrigins])); + logger.info( + { + authBaseUrlMode: config.authBaseUrlMode, + authPublicBaseUrl: config.authPublicBaseUrl ?? null, + trustedOrigins: effectiveTrustedOrigins, + trustedOriginsSource: { + derived: derivedTrustedOrigins.length, + env: envTrustedOrigins.length, + }, + }, + "Authenticated mode auth origin configuration", + ); + const auth = createBetterAuthInstance(db as any, config, effectiveTrustedOrigins); + betterAuthHandler = createBetterAuthHandler(auth); + resolveSession = (req) => resolveBetterAuthSession(auth, req); + resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers); + await initializeBoardClaimChallenge(db as any, { deploymentMode: config.deploymentMode }); + authReady = true; + } + + const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none"; + const storageService = createStorageServiceFromConfig(config); + const app = await createApp(db as any, { + uiMode, + storageService, deploymentMode: config.deploymentMode, deploymentExposure: config.deploymentExposure, + allowedHostnames: config.allowedHostnames, + bindHost: config.host, authReady, - requestedPort: config.port, - listenPort, - uiMode, - db: startupDbInfo, - migrationSummary, - heartbeatSchedulerEnabled: config.heartbeatSchedulerEnabled, - heartbeatSchedulerIntervalMs: config.heartbeatSchedulerIntervalMs, - databaseBackupEnabled: config.databaseBackupEnabled, - databaseBackupIntervalMinutes: config.databaseBackupIntervalMinutes, - databaseBackupRetentionDays: config.databaseBackupRetentionDays, - databaseBackupDir: config.databaseBackupDir, + companyDeletionEnabled: config.companyDeletionEnabled, + betterAuthHandler, + resolveSession, }); - - const boardClaimUrl = getBoardClaimWarningUrl(config.host, listenPort); - if (boardClaimUrl) { - const red = "\x1b[41m\x1b[30m"; - const yellow = "\x1b[33m"; - const reset = "\x1b[0m"; - console.log( - [ - `${red} BOARD CLAIM REQUIRED ${reset}`, - `${yellow}This instance was previously local_trusted and still has local-board as the only admin.${reset}`, - `${yellow}Sign in with a real user and open this one-time URL to claim ownership:${reset}`, - `${yellow}${boardClaimUrl}${reset}`, - `${yellow}If you are connecting over Tailscale, replace the host in this URL with your Tailscale IP/MagicDNS name.${reset}`, - ].join("\n"), - ); + const server = createServer(app as unknown as Parameters[0]); + const listenPort = await detectPort(config.port); + + if (listenPort !== config.port) { + logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`); } -}); - -if (embeddedPostgres && embeddedPostgresStartedByThisProcess) { - const shutdown = async (signal: "SIGINT" | "SIGTERM") => { - logger.info({ signal }, "Stopping embedded PostgreSQL"); - try { - await embeddedPostgres?.stop(); - } catch (err) { - logger.error({ err }, "Failed to stop embedded PostgreSQL cleanly"); - } finally { - process.exit(0); - } - }; - - process.once("SIGINT", () => { - void shutdown("SIGINT"); + + const runtimeListenHost = config.host; + const runtimeApiHost = + runtimeListenHost === "0.0.0.0" || runtimeListenHost === "::" + ? "localhost" + : runtimeListenHost; + process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost; + process.env.PAPERCLIP_LISTEN_PORT = String(listenPort); + process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`; + + setupLiveEventsWebSocketServer(server, db as any, { + deploymentMode: config.deploymentMode, + resolveSessionFromHeaders, }); - process.once("SIGTERM", () => { - void shutdown("SIGTERM"); + + if (config.heartbeatSchedulerEnabled) { + const heartbeat = heartbeatService(db as any); + + // Reap orphaned runs at startup (no threshold -- runningProcesses is empty) + void heartbeat.reapOrphanedRuns().catch((err) => { + logger.error({ err }, "startup reap of orphaned heartbeat runs failed"); + }); + + setInterval(() => { + void heartbeat + .tickTimers(new Date()) + .then((result) => { + if (result.enqueued > 0) { + logger.info({ ...result }, "heartbeat timer tick enqueued runs"); + } + }) + .catch((err) => { + logger.error({ err }, "heartbeat timer tick failed"); + }); + + // Periodically reap orphaned runs (5-min staleness threshold) + void heartbeat + .reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000 }) + .catch((err) => { + logger.error({ err }, "periodic reap of orphaned heartbeat runs failed"); + }); + }, config.heartbeatSchedulerIntervalMs); + } + + if (config.databaseBackupEnabled) { + const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000; + 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 { + const result = await runDatabaseBackup({ + connectionString: activeDatabaseConnectionString, + backupDir: config.databaseBackupDir, + retentionDays: config.databaseBackupRetentionDays, + filenamePrefix: "paperclip", + }); + logger.info( + { + backupFile: result.backupFile, + sizeBytes: result.sizeBytes, + prunedCount: result.prunedCount, + backupDir: config.databaseBackupDir, + retentionDays: config.databaseBackupRetentionDays, + }, + `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`, + ); + } catch (err) { + logger.error({ err, backupDir: config.databaseBackupDir }, "Automatic database backup failed"); + } finally { + backupInFlight = false; + } + }; + + logger.info( + { + intervalMinutes: config.databaseBackupIntervalMinutes, + retentionDays: config.databaseBackupRetentionDays, + backupDir: config.databaseBackupDir, + }, + "Automatic database backups enabled", + ); + setInterval(() => { + void runScheduledBackup(); + }, backupIntervalMs); + } + + await new Promise((resolveListen, rejectListen) => { + const onError = (err: Error) => { + server.off("error", onError); + rejectListen(err); + }; + + server.once("error", onError); + server.listen(listenPort, config.host, () => { + server.off("error", onError); + logger.info(`Server listening on ${config.host}:${listenPort}`); + if (process.env.PAPERCLIP_OPEN_ON_LISTEN === "true") { + const openHost = config.host === "0.0.0.0" || config.host === "::" ? "127.0.0.1" : config.host; + const url = `http://${openHost}:${listenPort}`; + void import("open") + .then((mod) => mod.default(url)) + .then(() => { + logger.info(`Opened browser at ${url}`); + }) + .catch((err) => { + logger.warn({ err, url }, "Failed to open browser on startup"); + }); + } + printStartupBanner({ + host: config.host, + deploymentMode: config.deploymentMode, + deploymentExposure: config.deploymentExposure, + authReady, + requestedPort: config.port, + listenPort, + uiMode, + db: startupDbInfo, + migrationSummary, + heartbeatSchedulerEnabled: config.heartbeatSchedulerEnabled, + heartbeatSchedulerIntervalMs: config.heartbeatSchedulerIntervalMs, + databaseBackupEnabled: config.databaseBackupEnabled, + databaseBackupIntervalMinutes: config.databaseBackupIntervalMinutes, + databaseBackupRetentionDays: config.databaseBackupRetentionDays, + databaseBackupDir: config.databaseBackupDir, + }); + + const boardClaimUrl = getBoardClaimWarningUrl(config.host, listenPort); + if (boardClaimUrl) { + const red = "\x1b[41m\x1b[30m"; + const yellow = "\x1b[33m"; + const reset = "\x1b[0m"; + console.log( + [ + `${red} BOARD CLAIM REQUIRED ${reset}`, + `${yellow}This instance was previously local_trusted and still has local-board as the only admin.${reset}`, + `${yellow}Sign in with a real user and open this one-time URL to claim ownership:${reset}`, + `${yellow}${boardClaimUrl}${reset}`, + `${yellow}If you are connecting over Tailscale, replace the host in this URL with your Tailscale IP/MagicDNS name.${reset}`, + ].join("\n"), + ); + } + + resolveListen(); + }); + }); + + if (embeddedPostgres && embeddedPostgresStartedByThisProcess) { + const shutdown = async (signal: "SIGINT" | "SIGTERM") => { + logger.info({ signal }, "Stopping embedded PostgreSQL"); + try { + await embeddedPostgres?.stop(); + } catch (err) { + logger.error({ err }, "Failed to stop embedded PostgreSQL cleanly"); + } finally { + process.exit(0); + } + }; + + process.once("SIGINT", () => { + void shutdown("SIGINT"); + }); + process.once("SIGTERM", () => { + void shutdown("SIGTERM"); + }); + } + + return { + server, + host: config.host, + listenPort, + apiUrl: process.env.PAPERCLIP_API_URL ?? `http://${runtimeApiHost}:${listenPort}`, + databaseUrl: activeDatabaseConnectionString, + }; +} + +function isMainModule(metaUrl: string): boolean { + const entry = process.argv[1]; + if (!entry) return false; + try { + return pathToFileURL(resolve(entry)).href === metaUrl; + } catch { + return false; + } +} + +if (isMainModule(import.meta.url)) { + void startServer().catch((err) => { + logger.error({ err }, "Paperclip server failed to start"); + process.exit(1); }); } From 23dec980e26f932e45b7f95a806dd911845725dc Mon Sep 17 00:00:00 2001 From: lockfile-bot Date: Mon, 9 Mar 2026 16:41:30 +0000 Subject: [PATCH 052/874] chore(lockfile): refresh pnpm-lock.yaml --- pnpm-lock.yaml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9536ff75..d1dd1ddc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@25.2.3) + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -1671,6 +1674,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -3987,6 +3995,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4773,6 +4786,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -7369,6 +7392,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -9847,6 +9874,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10898,6 +10928,14 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + points-on-curve@0.2.0: {} points-on-path@0.2.1: From a4181060054433f5337e6e5e1b375ccb4accc656 Mon Sep 17 00:00:00 2001 From: online5880 Date: Tue, 10 Mar 2026 01:43:45 +0900 Subject: [PATCH 053/874] fix: restore cross-env in server dev watch --- server/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/package.json b/server/package.json index 32cc9cd5..8c442e25 100644 --- a/server/package.json +++ b/server/package.json @@ -23,7 +23,7 @@ ], "scripts": { "dev": "tsx src/index.ts", - "dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", + "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", "prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh", "build": "tsc", "prepack": "pnpm run prepare:ui-dist", From 756ddb6cf7f3e84ed1267c414a0dc5ee50be8f90 Mon Sep 17 00:00:00 2001 From: online5880 Date: Tue, 10 Mar 2026 02:34:52 +0900 Subject: [PATCH 054/874] fix: remove lockfile changes from PR --- pnpm-lock.yaml | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36aa29a9..d1dd1ddc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@playwright/test': specifier: ^1.58.2 version: 1.58.2 - cross-env: - specifier: ^10.1.0 - version: 10.1.0 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -324,9 +321,6 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 - cross-env: - specifier: ^10.1.0 - version: 10.1.0 supertest: specifier: ^7.0.0 version: 7.2.2 @@ -995,9 +989,6 @@ packages: cpu: [x64] os: [win32] - '@epic-web/invariant@1.0.0': - resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} - '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -3433,11 +3424,6 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} - cross-env@10.1.0: - resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} - engines: {node: '>=20'} - hasBin: true - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -6755,8 +6741,6 @@ snapshots: '@embedded-postgres/windows-x64@18.1.0-beta.16': optional: true - '@epic-web/invariant@1.0.0': {} - '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -9271,11 +9255,6 @@ snapshots: crelt@1.0.6: {} - cross-env@10.1.0: - dependencies: - '@epic-web/invariant': 1.0.0 - cross-spawn: 7.0.6 - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 From 469bfe39538571bbd6dd32358e3e59a998bf8375 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 13:55:30 -0500 Subject: [PATCH 055/874] chore: add release train workflow --- .github/workflows/release.yml | 8 +- doc/PUBLISHING.md | 7 +- doc/RELEASING.md | 594 ++++++++++++------------------ package.json | 1 + releases/v0.3.0.md | 48 +-- scripts/create-github-release.sh | 6 +- scripts/release-lib.sh | 222 +++++++++++ scripts/release-preflight.sh | 132 ++++--- scripts/release-start.sh | 182 +++++++++ scripts/release.sh | 237 +++++------- skills/release-changelog/SKILL.md | 38 ++ skills/release/SKILL.md | 35 +- 12 files changed, 911 insertions(+), 599 deletions(-) create mode 100644 scripts/release-lib.sh create mode 100755 scripts/release-start.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d456eb7c..7165d059 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ concurrency: jobs: verify: - if: github.ref == 'refs/heads/master' + if: startsWith(github.ref, 'refs/heads/release/') runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -68,7 +68,7 @@ jobs: run: pnpm build publish: - if: github.ref == 'refs/heads/master' + if: startsWith(github.ref, 'refs/heads/release/') needs: verify runs-on: ubuntu-latest timeout-minutes: 45 @@ -115,9 +115,9 @@ jobs: fi ./scripts/release.sh "${args[@]}" - - name: Push stable release commit and tag + - name: Push stable release branch commit and tag if: inputs.channel == 'stable' && !inputs.dry_run - run: git push origin HEAD:master --follow-tags + run: git push origin "HEAD:${GITHUB_REF_NAME}" --follow-tags - name: Create GitHub Release if: inputs.channel == 'stable' && !inputs.dry_run diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index 9326fd5b..9e8befb3 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -8,10 +8,11 @@ For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This Use these scripts instead of older one-off publish commands: +- [`scripts/release-start.sh`](../scripts/release-start.sh) to create or resume `release/X.Y.Z` - [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) before any canary or stable release - [`scripts/release.sh`](../scripts/release.sh) for canary and stable npm publishes - [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback -- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after a stable push +- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after pushing the stable branch tag ## Why the CLI needs special packaging @@ -87,7 +88,7 @@ This means: Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`. -The stable publish flow also creates the local release commit and git tag. Pushing the commit/tag and creating the GitHub Release happen afterward as separate maintainer steps. +The stable publish flow also creates the local release commit and git tag on `release/X.Y.Z`. Pushing that branch commit/tag, creating the GitHub Release, and merging the release branch back to `master` happen afterward as separate maintainer steps. ## Rollback model @@ -109,7 +110,7 @@ Recommended CI release setup: - use npm trusted publishing via GitHub OIDC - require approval through the `npm-release` environment -- run releases from `master` +- run releases from `release/X.Y.Z` - use canary first, then stable ## Related Files diff --git a/doc/RELEASING.md b/doc/RELEASING.md index bd082807..5f951d69 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -2,260 +2,138 @@ Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface. -This document is intentionally practical: +The release model is branch-driven: -- TL;DR command sequences are at the top. -- Detailed checklists come next. -- Motivation, failure handling, and rollback playbooks follow after that. +1. Start a release train on `release/X.Y.Z` +2. Draft the stable changelog on that branch +3. Publish one or more canaries from that branch +4. Publish stable from that same branch head +5. Push the branch commit and tag +6. Create the GitHub Release +7. Merge `release/X.Y.Z` back to `master` without squash or rebase ## Release Surfaces -Every Paperclip release has four separate surfaces: +Every release has four separate surfaces: -1. **Verification** — the exact git SHA must pass typecheck, tests, and build. -2. **npm** — `paperclipai` and the public workspace packages are published. -3. **GitHub** — the stable release gets a git tag and a GitHub Release. -4. **Website / announcements** — the stable changelog is published externally and announced. +1. **Verification** — the exact git SHA passes typecheck, tests, and build +2. **npm** — `paperclipai` and public workspace packages are published +3. **GitHub** — the stable release gets a git tag and GitHub Release +4. **Website / announcements** — the stable changelog is published externally and announced -Treat those as related but separate. npm can succeed while the GitHub Release is still pending. GitHub can be correct while the website changelog is stale. A maintainer release is done only when all four surfaces are handled. +A release is done only when all four surfaces are handled. + +## Core Invariants + +- Canary and stable for `X.Y.Z` must come from the same `release/X.Y.Z` branch. +- The release scripts must run from the matching `release/X.Y.Z` branch. +- Once `vX.Y.Z` exists locally, on GitHub, or on npm, that release train is frozen. +- Do not squash-merge or rebase-merge a release branch PR back to `master`. +- The stable changelog is always `releases/vX.Y.Z.md`. Never create canary changelog files. + +The reason for the merge rule is simple: the tag must keep pointing at the exact published commit. Squash or rebase breaks that property. ## TL;DR -### Canary release +### 1. Start the release train -Use this when you want an installable prerelease without changing `latest`. +Use this to compute the next version, create or resume the branch, create or resume a dedicated worktree, and push the branch to GitHub. ```bash -# 0. Confirm master already has the CI-owned lockfile refresh merged -# If package manifests changed recently, wait for the refresh-lockfile PR first. +./scripts/release-start.sh patch +``` -# 1. Preflight the canary candidate -./scripts/release-preflight.sh canary patch +That script: -# 2. Draft or update the stable changelog for the intended stable version -VERSION=0.2.8 +- fetches the release remote and tags +- computes the next stable version from the latest `v*` tag +- creates or resumes `release/X.Y.Z` +- creates or resumes a dedicated worktree +- pushes the branch to the remote by default +- refuses to reuse a frozen release train + +### 2. Draft the stable changelog + +From the release worktree: + +```bash +VERSION=X.Y.Z claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." +``` -# 3. Preview the canary release +### 3. Verify and publish a canary + +```bash +./scripts/release-preflight.sh canary patch ./scripts/release.sh patch --canary --dry-run - -# 4. Publish the canary ./scripts/release.sh patch --canary - -# 5. Smoke test what users will actually install PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +``` -# Users install with: +Users install canaries with: + +```bash npx paperclipai@canary onboard ``` -Result: - -- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary` -- `latest` is unchanged -- no git tag is created -- no GitHub Release is created -- the working tree returns to clean after the script finishes -- after stable `0.2.7`, a patch canary targets `0.2.8-canary.0`, never `0.2.7-canary.N` - -### Stable release - -Use this only after the canary SHA is good enough to become the public default. +### 4. Publish stable ```bash -# 0. Confirm master already has the CI-owned lockfile refresh merged -# If package manifests changed recently, wait for the refresh-lockfile PR first. - -# 1. Start from the vetted commit -git checkout master -git pull - -# 2. Preflight the stable candidate ./scripts/release-preflight.sh stable patch - -# 3. Confirm the stable changelog exists -VERSION=0.2.8 -ls "releases/v${VERSION}.md" - -# 4. Preview the stable publish ./scripts/release.sh patch --dry-run - -# 5. Publish the stable release to npm and create the local release commit + tag ./scripts/release.sh patch - -# 6. Push the release commit and tag -git push public-gh HEAD:master --follow-tags - -# 7. Create or update the GitHub Release from the pushed tag +git push public-gh HEAD --follow-tags ./scripts/create-github-release.sh X.Y.Z ``` -Result: +Then open a PR from `release/X.Y.Z` to `master` and merge without squash or rebase. -- npm gets stable `X.Y.Z` under dist-tag `latest` -- a local git commit and tag `vX.Y.Z` are created -- after push, GitHub gets the matching Release -- the website and announcement steps still need to be handled manually +## Release Branches -### Emergency rollback +Paperclip uses one release branch per target stable version: -If `latest` is broken after publish, repoint it to the last known good stable version first, then work on the fix. +- `release/0.3.0` +- `release/0.3.1` +- `release/1.0.0` + +Do not create separate per-canary branches like `canary/0.3.0-1`. A canary is just a prerelease snapshot of the same stable train. + +## Script Entry Points + +- [`scripts/release-start.sh`](../scripts/release-start.sh) — create or resume the release train branch/worktree +- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — validate branch, version plan, git/npm state, and verification gate +- [`scripts/release.sh`](../scripts/release.sh) — publish canary or stable from the release branch +- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after pushing the tag +- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable version + +## Detailed Workflow + +### 1. Start or resume the release train + +Run: ```bash -# Preview -./scripts/rollback-latest.sh X.Y.Z --dry-run - -# Roll back latest for every public package -./scripts/rollback-latest.sh X.Y.Z +./scripts/release-start.sh ``` -This does **not** unpublish anything. It only moves the `latest` dist-tag back to the last good stable release. - -### Standalone onboarding smoke - -You already have a script for isolated onboarding verification: +Useful options: ```bash -HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh -HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh +./scripts/release-start.sh patch --dry-run +./scripts/release-start.sh minor --worktree-dir ../paperclip-release-0.4.0 +./scripts/release-start.sh patch --no-push ``` -This is the best existing fit when you want: +The script is intentionally idempotent: -- a standalone Paperclip data dir -- a dedicated host port -- an end-to-end `npx paperclipai ... onboard` check +- if `release/X.Y.Z` already exists locally, it reuses it +- if the branch already exists on the remote, it resumes it locally +- if the branch is already checked out in another worktree, it points you there +- if `vX.Y.Z` already exists locally, remotely, or on npm, it refuses to reuse that train -In authenticated/private mode, the expected result is a full authenticated onboarding flow, including printing the bootstrap CEO invite once startup completes. +### 2. Write the stable changelog early -If you want to exercise onboarding from a fresh local checkout rather than npm, use: - -```bash -./scripts/clean-onboard-git.sh -``` - -That is not a required release step every time, but it is a useful higher-confidence check when onboarding is the main risk area or when you need to verify what the current codebase does before publishing. - -If you want to exercise onboarding from the current committed ref in your local repo, use: - -```bash -./scripts/clean-onboard-ref.sh -PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh -./scripts/clean-onboard-ref.sh HEAD -``` - -This uses the current committed `HEAD` in a detached temp worktree. It does **not** include uncommitted local edits. - -### GitHub Actions release - -There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). It is designed for npm trusted publishing via GitHub OIDC instead of long-lived npm tokens. - -Use it from the Actions tab: - -1. Choose `Release` -2. Choose `channel`: `canary` or `stable` -3. Choose `bump`: `patch`, `minor`, or `major` -4. Choose whether this is a `dry_run` -5. Run it from `master` - -The workflow: - -- reruns `typecheck`, `test:run`, and `build` -- gates publish behind the `npm-release` environment -- can publish canaries without touching `latest` -- can publish stable, push the release commit and tag, and create the GitHub Release - -## Release Checklist - -### Before any publish - -- [ ] The working tree is clean, including untracked files -- [ ] The target branch and SHA are the ones you actually want to release -- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` -- [ ] The required verification gate passed on that exact SHA -- [ ] The bump type is correct for the user-visible impact -- [ ] The stable changelog file exists or is ready to be written at `releases/vX.Y.Z.md` -- [ ] You know which previous stable version you would roll back to if needed - -### Before a canary - -- [ ] You are intentionally testing something that should be installable before it becomes default -- [ ] You are comfortable with users installing it via `npx paperclipai@canary onboard` -- [ ] You understand that each canary is a new immutable npm version such as `1.2.3-canary.1` - -### Before a stable - -- [ ] The candidate has already passed smoke testing -- [ ] The changelog should be the stable version only, for example `v1.2.3` -- [ ] You are ready to push the release commit and tag immediately after npm publish -- [ ] You are ready to create the GitHub Release immediately after the push -- [ ] You have a post-release website / announcement plan - -### After a stable - -- [ ] `npm view paperclipai@latest version` matches the new stable version -- [ ] The git tag exists on GitHub -- [ ] The GitHub Release exists and uses `releases/vX.Y.Z.md` -- [ ] The website changelog is updated -- [ ] Any announcement copy matches the shipped release, not the canary - -## Verification Gate - -The repository standard is: - -```bash -pnpm -r typecheck -pnpm test:run -pnpm build -``` - -This matches [`.github/workflows/pr-verify.yml`](../.github/workflows/pr-verify.yml). Run it before claiming a release candidate is ready. - -The release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml) installs with `pnpm install --frozen-lockfile`. That is intentional. Releases must use the exact dependency graph already committed on `master`; if manifests changed and the CI-owned lockfile refresh has not landed yet, the release should fail until that prerequisite is merged. - -For release work, prefer: - -```bash -./scripts/release-preflight.sh canary -./scripts/release-preflight.sh stable -``` - -That script runs the verification gate and prints the computed target versions before you publish anything. - -## Versioning Policy - -### Stable versions - -Stable releases use normal semver: - -- `patch` for bug fixes -- `minor` for additive features, endpoints, and additive migrations -- `major` for destructive migrations, removed APIs, or other breaking behavior - -### Canary versions - -Canaries are semver prereleases of the **intended stable version**: - -- `1.2.3-canary.0` -- `1.2.3-canary.1` -- `1.2.3-canary.2` - -That gives you three useful properties: - -1. Users can install the prerelease explicitly with `@canary` -2. `latest` stays safe -3. The stable changelog can remain just `v1.2.3` - -We do **not** create separate changelog files for canary versions. - -Concrete example: - -- if the latest stable release is `0.2.7`, a patch canary is `0.2.8-canary.0` -- `0.2.7-canary.0` is invalid, because `0.2.7` is already the shipped stable version - -## Changelog Policy - -The maintainer changelog source of truth is: +Create or update: - `releases/vX.Y.Z.md` @@ -268,14 +146,13 @@ Recommended structure: - `Improvements` - `Fixes` - `Upgrade Guide` when needed +- `Contributors` — @-mention every contributor by GitHub username (no emails) Package-level `CHANGELOG.md` files are generated as part of the release mechanics. They are not the main release narrative. -## Detailed Workflow +### 3. Run release preflight -### 1. Decide the bump - -Run preflight first: +From the `release/X.Y.Z` worktree: ```bash ./scripts/release-preflight.sh canary @@ -283,70 +160,54 @@ Run preflight first: ./scripts/release-preflight.sh stable ``` -That command: +The preflight script now checks all of the following before it runs the verification gate: -- verifies the worktree is clean, including untracked files -- shows the last stable tag and computed next versions -- shows the commit range since the last stable tag -- highlights migration and breaking-change signals -- runs `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build` +- the worktree is clean, including untracked files +- the current branch matches the computed `release/X.Y.Z` +- the release train is not frozen +- the target version is still free on npm +- the target tag does not already exist locally or remotely +- whether the remote release branch already exists +- whether `releases/vX.Y.Z.md` is present -If you want the raw inputs separately, review the range since the last stable tag: +Then it runs: ```bash -LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) -git log "${LAST_TAG}..HEAD" --oneline --no-merges -git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/ -git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/ -git log "${LAST_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true +pnpm -r typecheck +pnpm test:run +pnpm build ``` -Use the higher bump if there is any doubt. - -### 2. Write the stable changelog first - -Create or update: - -```bash -VERSION=X.Y.Z -claude -p "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." -``` - -This is deliberate. The release notes should describe the stable story, not the canary mechanics. - -### 3. Publish one or more canaries +### 4. Publish one or more canaries Run: ```bash +./scripts/release.sh --canary --dry-run ./scripts/release.sh --canary ``` -What the script does: +Result: -1. Verifies the working tree is clean -2. Computes the intended stable version from the last stable tag -3. Computes the next canary ordinal from npm -4. Versions the public packages to `X.Y.Z-canary.N` -5. Builds the workspace and publishable CLI -6. Publishes to npm under dist-tag `canary` -7. Cleans up the temporary versioning state so your branch returns to clean +- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary` +- `latest` is unchanged +- no git tag is created +- no GitHub Release is created +- the worktree returns to clean after the script finishes -This means the script is safe to repeat as many times as needed while iterating: +Guardrails: -- `1.2.3-canary.0` -- `1.2.3-canary.1` -- `1.2.3-canary.2` +- the script refuses to run from the wrong branch +- the script refuses to publish from a frozen train +- the canary is always derived from the next stable version +- if the stable notes file is missing, the script warns before you forget it -The target stable release can still remain `1.2.3`. +Concrete example: -Guardrail: +- if the latest stable is `0.2.7`, a patch canary targets `0.2.8-canary.0` +- `0.2.7-canary.N` is invalid because `0.2.7` is already stable -- the canary is always derived from the **next stable version** -- after stable `0.2.7`, the next patch canary is `0.2.8-canary.0` -- the scripts refuse to publish `0.2.7-canary.N` once `0.2.7` is already the stable release - -### 4. Smoke test the canary +### 5. Smoke test the canary Run the actual install path in Docker: @@ -361,165 +222,198 @@ HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary . HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh ``` -If you want to smoke onboarding from the current codebase rather than npm, run: +If you want to exercise onboarding from the current committed ref instead of npm, use: ```bash -./scripts/clean-onboard-git.sh ./scripts/clean-onboard-ref.sh +PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh +./scripts/clean-onboard-ref.sh HEAD ``` Minimum checks: -- [ ] `npx paperclipai@canary onboard` installs -- [ ] onboarding completes without crashes -- [ ] the server boots -- [ ] the UI loads -- [ ] basic company creation and dashboard load work +- `npx paperclipai@canary onboard` installs +- onboarding completes without crashes +- the server boots +- the UI loads +- basic company creation and dashboard load work -### 5. Publish stable from the vetted commit +If smoke testing fails: -Once the candidate SHA is good, run the stable flow on that exact commit: +1. stop the stable release +2. fix the issue on the same `release/X.Y.Z` branch +3. publish another canary +4. rerun smoke testing + +### 6. Publish stable from the same release branch + +Once the branch head is vetted, run: ```bash +./scripts/release.sh --dry-run ./scripts/release.sh ``` -What the script does: +Stable publish: -1. Verifies the working tree is clean -2. Versions the public packages to the stable semver -3. Builds the workspace and CLI publish bundle -4. Publishes to npm under `latest` -5. Restores temporary publish artifacts -6. Creates the local release commit and git tag +- publishes `X.Y.Z` to npm under `latest` +- creates the local release commit +- creates the local tag `vX.Y.Z` -What it does **not** do: +Stable publish refuses to proceed if: -- it does not push for you -- it does not update the website -- it does not announce the release for you +- the current branch is not `release/X.Y.Z` +- the remote release branch does not exist yet +- the stable notes file is missing +- the target tag already exists locally or remotely +- the stable version already exists on npm -### 6. Push the release and create the GitHub Release +Those checks intentionally freeze the train after stable publish. -After a stable publish succeeds: +### 7. Push the stable branch commit and tag + +After stable publish succeeds: ```bash -git push public-gh HEAD:master --follow-tags +git push public-gh HEAD --follow-tags ./scripts/create-github-release.sh X.Y.Z ``` -The GitHub release notes come from: +The GitHub Release notes come from: - `releases/vX.Y.Z.md` -### 7. Complete the external surfaces +### 8. Merge the release branch back to `master` + +Open a PR: + +- base: `master` +- head: `release/X.Y.Z` + +Merge rule: + +- allowed: merge commit or fast-forward +- forbidden: squash merge +- forbidden: rebase merge + +Post-merge verification: + +```bash +git fetch public-gh --tags +git merge-base --is-ancestor "vX.Y.Z" "public-gh/master" +``` + +That command must succeed. If it fails, the published tagged commit is not reachable from `master`, which means the merge strategy was wrong. + +### 9. Finish the external surfaces After GitHub is correct: - publish the changelog on the website -- write the announcement copy +- write and send the announcement copy - ensure public docs and install guidance point to the stable version -## GitHub Actions and npm Trusted Publishing +## GitHub Actions Release -If you want GitHub to own the actual npm publish, use [`.github/workflows/release.yml`](../.github/workflows/release.yml) together with npm trusted publishing. +There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). -Recommended setup: +Use it from the Actions tab on the relevant `release/X.Y.Z` branch: -1. Configure the GitHub Actions workflow as a trusted publisher for **every public package** on npm -2. Use the `npm-release` GitHub environment with required reviewers -3. Run stable publishes from `master` only -4. Keep the workflow manual via `workflow_dispatch` +1. Choose `Release` +2. Choose `channel`: `canary` or `stable` +3. Choose `bump`: `patch`, `minor`, or `major` +4. Choose whether this is a `dry_run` +5. Run it from the release branch, not from `master` -Why this is the right shape: +The workflow: -- no long-lived npm token needs to live in GitHub secrets -- reviewers can approve the publish step at the environment gate -- the workflow reruns verification on the release SHA before publish -- stable and canary use the same mechanics +- reruns `typecheck`, `test:run`, and `build` +- gates publish behind the `npm-release` environment +- can publish canaries without touching `latest` +- can publish stable, push the stable branch commit and tag, and create the GitHub Release + +It does not merge the release branch back to `master` for you. + +## Release Checklist + +### Before any publish + +- [ ] The release train exists on `release/X.Y.Z` +- [ ] The working tree is clean, including untracked files +- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the train is cut +- [ ] The required verification gate passed on the exact branch head you want to publish +- [ ] The bump type is correct for the user-visible impact +- [ ] The stable changelog file exists or is ready at `releases/vX.Y.Z.md` +- [ ] You know which previous stable version you would roll back to if needed + +### Before a stable + +- [ ] The candidate has already passed smoke testing +- [ ] The remote `release/X.Y.Z` branch exists +- [ ] You are ready to push the stable branch commit and tag immediately after npm publish +- [ ] You are ready to create the GitHub Release immediately after the push +- [ ] You are ready to open the PR back to `master` + +### After a stable + +- [ ] `npm view paperclipai@latest version` matches the new stable version +- [ ] The git tag exists on GitHub +- [ ] The GitHub Release exists and uses `releases/vX.Y.Z.md` +- [ ] `vX.Y.Z` is reachable from `master` +- [ ] The website changelog is updated +- [ ] Announcement copy matches the stable release, not the canary ## Failure Playbooks -### If the canary fails before publish - -Nothing shipped. Fix the code and rerun the canary workflow. - ### If the canary publishes but the smoke test fails -Do **not** publish stable. +Do not publish stable. Instead: -1. Fix the issue -2. Publish another canary -3. Re-run smoke testing +1. fix the issue on `release/X.Y.Z` +2. publish another canary +3. rerun smoke testing -The canary version number will increase, but the stable target version can remain the same. - -### If the stable npm publish succeeds but push fails +### If stable npm publish succeeds but push or GitHub release creation fails This is a partial release. npm is already live. Do this immediately: -1. Fix the git issue -2. Push the release commit and tag from the same checkout -3. Create the GitHub Release +1. fix the git or GitHub issue from the same checkout +2. push the stable branch commit and tag +3. create the GitHub Release -Do **not** publish the same version again. +Do not republish the same version. -### If the stable release is bad after `latest` moves +### If `latest` is broken after stable publish -Use the rollback script first: +Preview: ```bash -./scripts/rollback-latest.sh +./scripts/rollback-latest.sh X.Y.Z --dry-run ``` -Then: +Roll back: -1. open an incident note or maintainer comment -2. fix forward on a new patch release -3. update the changelog / release notes if the user-facing guidance changed +```bash +./scripts/rollback-latest.sh X.Y.Z +``` -### If the GitHub Release is wrong +This does not unpublish anything. It only moves the `latest` dist-tag back to the last good stable release. -Edit it by re-running: +Then fix forward with a new patch release. + +### If the GitHub Release notes are wrong + +Re-run: ```bash ./scripts/create-github-release.sh X.Y.Z ``` -This updates the release notes if the GitHub Release already exists. - -### If the website changelog is wrong - -Fix the website independently. Do not republish npm just to repair the website surface. - -## Rollback Strategy - -The default rollback strategy is **dist-tag rollback, then fix forward**. - -Why: - -- npm versions are immutable -- users need `npx paperclipai onboard` to recover quickly -- moving `latest` back is faster and safer than trying to delete history - -Rollback procedure: - -1. identify the last known good stable version -2. run `./scripts/rollback-latest.sh ` -3. verify `npm view paperclipai@latest version` -4. fix forward with a new stable release - -## Scripts Reference - -- [`scripts/release.sh`](../scripts/release.sh) — stable and canary npm publish flow -- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — clean-tree, version-plan, and verification-gate preflight -- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after push -- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable release -- [`scripts/docker-onboard-smoke.sh`](../scripts/docker-onboard-smoke.sh) — Docker smoke test for the installed CLI +If the release already exists, the script updates it. ## Related Docs diff --git a/package.json b/package.json index 4aee6527..b2e23d55 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "db:backup": "./scripts/backup-db.sh", "paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts", "build:npm": "./scripts/build-npm.sh", + "release:start": "./scripts/release-start.sh", "release": "./scripts/release.sh", "release:preflight": "./scripts/release-preflight.sh", "release:github": "./scripts/create-github-release.sh", diff --git a/releases/v0.3.0.md b/releases/v0.3.0.md index 4e18ae6c..6d600386 100644 --- a/releases/v0.3.0.md +++ b/releases/v0.3.0.md @@ -4,9 +4,9 @@ ## Highlights -- **New adapters: Cursor, OpenCode, and Pi** — Paperclip now supports three additional local coding agents. Cursor and OpenCode integrate as first-class adapters with model discovery, run-log streaming, and skill injection. Pi adds a local RPC mode with cost tracking. All three appear in the onboarding wizard alongside Claude Code and Codex. -- **OpenClaw gateway adapter** — A new gateway-only OpenClaw flow replaces the legacy adapter. It uses strict SSE streaming, supports device-key pairing, and handles invite-based onboarding with join-token validation. -- **Inbox and unread semantics** — Issues now track per-user read state. Unread indicators appear in the inbox, dashboard, and browser tab (blue dot). The inbox badge includes join requests and approvals, and inbox ordering is alert-focused. +- **New adapters: Cursor, OpenCode, and Pi** — Paperclip now supports three additional local coding agents. Cursor and OpenCode integrate as first-class adapters with model discovery, run-log streaming, and skill injection. Pi adds a local RPC mode with cost tracking. All three appear in the onboarding wizard alongside Claude Code and Codex. ([#62](https://github.com/paperclipai/paperclip/pull/62), [#141](https://github.com/paperclipai/paperclip/pull/141), [#240](https://github.com/paperclipai/paperclip/pull/240), [#183](https://github.com/paperclipai/paperclip/pull/183), @aaaaron, @Konan69, @richardanaya) +- **OpenClaw gateway adapter** — A new gateway-only OpenClaw flow replaces the legacy adapter. It uses strict SSE streaming, supports device-key pairing, and handles invite-based onboarding with join-token validation. ([#270](https://github.com/paperclipai/paperclip/pull/270)) +- **Inbox and unread semantics** — Issues now track per-user read state. Unread indicators appear in the inbox, dashboard, and browser tab (blue dot). The inbox badge includes join requests and approvals, and inbox ordering is alert-focused. ([#196](https://github.com/paperclipai/paperclip/pull/196), @hougangdev) - **PWA support** — The UI ships as an installable Progressive Web App with a service worker and enhanced manifest. The service worker uses a network-first strategy to prevent stale content. - **Agent creation wizard** — A new choice modal and full-page configuration flow make it easier to add agents. The sidebar AGENTS header now has a quick-add button. @@ -19,29 +19,35 @@ - **Project status clickable** — The status chip in the project properties pane is now clickable for quick updates. - **Scroll-to-bottom button** — Issue detail and run pages show a floating scroll-to-bottom button when you scroll up. - **Database backup CLI** — `paperclipai db:backup` lets you snapshot the database on demand, with optional automatic scheduling. -- **Disable sign-up** — A new `auth.disableSignUp` config option (and `AUTH_DISABLE_SIGNUP` env var) lets operators lock registration. -- **Deduplicated shortnames** — Agent and project shortnames are now auto-deduplicated on create and update instead of rejecting duplicates. -- **Human-readable role labels** — The agent list and properties pane show friendly role names. +- **Disable sign-up** — A new `auth.disableSignUp` config option (and `AUTH_DISABLE_SIGNUP` env var) lets operators lock registration. ([#279](https://github.com/paperclipai/paperclip/pull/279), @JasonOA888) +- **Deduplicated shortnames** — Agent and project shortnames are now auto-deduplicated on create and update instead of rejecting duplicates. ([#264](https://github.com/paperclipai/paperclip/pull/264), @mvanhorn) +- **Human-readable role labels** — The agent list and properties pane show friendly role names. ([#263](https://github.com/paperclipai/paperclip/pull/263), @mvanhorn) - **Assignee picker sorting** — Recent selections appear first, then alphabetical. -- **Mobile layout polish** — Unified GitHub-style issue rows across issues, inbox, and dashboard. Improved popover scrolling, command palette centering, and property toggles on mobile. +- **Mobile layout polish** — Unified GitHub-style issue rows across issues, inbox, and dashboard. Improved popover scrolling, command palette centering, and property toggles on mobile. ([#118](https://github.com/paperclipai/paperclip/pull/118), @MumuTW) - **Invite UX improvements** — Invite links auto-copy to clipboard, snippet-only flow in settings, 10-minute invite TTL, and clearer network-host guidance. - **Permalink anchors on comments** — Each comment has a stable anchor link and a GET-by-ID API endpoint. -- **Docker deployment hardening** — Authenticated deployment mode by default, named data volume, `PAPERCLIP_PUBLIC_URL` and `PAPERCLIP_ALLOWED_HOSTNAMES` exposed in compose files, health-check DB wait, and Node 24 base image. -- **Updated model lists** — Added `claude-sonnet-4-6`, `claude-haiku-4-6`, and `gpt-5.4` to adapter model constants. +- **Docker deployment hardening** — Authenticated deployment mode by default, named data volume, `PAPERCLIP_PUBLIC_URL` and `PAPERCLIP_ALLOWED_HOSTNAMES` exposed in compose files, health-check DB wait, and Node 24 base image. ([#400](https://github.com/paperclipai/paperclip/pull/400), [#283](https://github.com/paperclipai/paperclip/pull/283), [#284](https://github.com/paperclipai/paperclip/pull/284), @AiMagic5000, @mingfang) +- **Updated model lists** — Added `claude-sonnet-4-6`, `claude-haiku-4-6`, and `gpt-5.4` to adapter model constants. ([#293](https://github.com/paperclipai/paperclip/pull/293), [#110](https://github.com/paperclipai/paperclip/pull/110), @cpfarhood, @artokun) - **Playwright e2e tests** — New end-to-end test suite covering the onboarding wizard flow. ## Fixes -- **Secret redaction in run logs** — Env vars sourced from secrets are now redacted by provenance, with consistent `secretKeys` tracking. -- **SPA catch-all 500s** — The server serves cached `index.html` in the catch-all route and uses `root` in `sendFile`, preventing 500 errors on dotfile paths and SPA refreshes. -- **Unmatched API routes return 404 JSON** — Previously fell through to the SPA handler. -- **Agent wake logic** — Agents wake when issues move out of backlog, skip self-wake on own comments, and skip wakeup for backlog-status changes. Pending-approval agents are excluded from heartbeat timers. -- **Run log fd leak** — Fixed a file-descriptor leak in log append that caused `spawn EBADF` errors. +- **Secret redaction in run logs** — Env vars sourced from secrets are now redacted by provenance, with consistent `secretKeys` tracking. ([#261](https://github.com/paperclipai/paperclip/pull/261), @mvanhorn) +- **SPA catch-all 500s** — The server serves cached `index.html` in the catch-all route and uses `root` in `sendFile`, preventing 500 errors on dotfile paths and SPA refreshes. ([#269](https://github.com/paperclipai/paperclip/pull/269), [#78](https://github.com/paperclipai/paperclip/pull/78), @mvanhorn, @MumuTW) +- **Unmatched API routes return 404 JSON** — Previously fell through to the SPA handler. ([#269](https://github.com/paperclipai/paperclip/pull/269), @mvanhorn) +- **Agent wake logic** — Agents wake when issues move out of backlog, skip self-wake on own comments, and skip wakeup for backlog-status changes. Pending-approval agents are excluded from heartbeat timers. ([#159](https://github.com/paperclipai/paperclip/pull/159), [#154](https://github.com/paperclipai/paperclip/pull/154), [#267](https://github.com/paperclipai/paperclip/pull/267), [#72](https://github.com/paperclipai/paperclip/pull/72), @Logesh-waran2003, @cschneid, @mvanhorn, @STRML) +- **Run log fd leak** — Fixed a file-descriptor leak in log append that caused `spawn EBADF` errors. ([#266](https://github.com/paperclipai/paperclip/pull/266), @mvanhorn) - **500 error logging** — Error logs now include the actual error message and request context instead of generic pino-http output. -- **Boolean env parsing** — `parseBooleanFromEnv` no longer silently treats common truthy values as false. -- **Onboarding env defaults** — `onboard` now correctly derives secrets from env vars and reports ignored exposure settings in `local_trusted` mode. -- **Windows path compatibility** — Migration paths use `fileURLToPath` for Windows-safe resolution. -- **Secure cookies on HTTP** — Disabled secure cookie flag for plain HTTP deployments to prevent auth failures. -- **URL encoding** — `buildUrl` splits path and query to prevent `%3F` encoding issues. -- **Auth trusted origins** — Effective trusted origins and allowed hostnames are now applied correctly in public mode. -- **UI stability** — Fixed blank screen when prompt templates are emptied, search URL sync causing re-renders, issue title overflow in inbox, and sidebar badge counts including approvals. +- **Boolean env parsing** — `parseBooleanFromEnv` no longer silently treats common truthy values as false. ([#91](https://github.com/paperclipai/paperclip/pull/91), @zvictor) +- **Onboarding env defaults** — `onboard` now correctly derives secrets from env vars and reports ignored exposure settings in `local_trusted` mode. ([#91](https://github.com/paperclipai/paperclip/pull/91), @zvictor) +- **Windows path compatibility** — Migration paths use `fileURLToPath` for Windows-safe resolution. ([#265](https://github.com/paperclipai/paperclip/pull/265), [#413](https://github.com/paperclipai/paperclip/pull/413), @mvanhorn, @online5880) +- **Secure cookies on HTTP** — Disabled secure cookie flag for plain HTTP deployments to prevent auth failures. ([#376](https://github.com/paperclipai/paperclip/pull/376), @dalestubblefield) +- **URL encoding** — `buildUrl` splits path and query to prevent `%3F` encoding issues. ([#260](https://github.com/paperclipai/paperclip/pull/260), @mvanhorn) +- **Auth trusted origins** — Effective trusted origins and allowed hostnames are now applied correctly in public mode. ([#99](https://github.com/paperclipai/paperclip/pull/99), @zvictor) +- **UI stability** — Fixed blank screen when prompt templates are emptied, search URL sync causing re-renders, issue title overflow in inbox, and sidebar badge counts including approvals. ([#262](https://github.com/paperclipai/paperclip/pull/262), [#196](https://github.com/paperclipai/paperclip/pull/196), [#423](https://github.com/paperclipai/paperclip/pull/423), @mvanhorn, @hougangdev, @RememberV) + +## Contributors + +Thank you to everyone who contributed to this release! + +@aaaaron, @AiMagic5000, @artokun, @cpfarhood, @cschneid, @dalestubblefield, @Dotta, @eltociear, @fahmmin, @gsxdsm, @hougangdev, @JasonOA888, @Konan69, @Logesh-waran2003, @mingfang, @MumuTW, @mvanhorn, @numman-ali, @online5880, @RememberV, @richardanaya, @STRML, @tylerwince, @zvictor diff --git a/scripts/create-github-release.sh b/scripts/create-github-release.sh index 1a12a783..ae82755a 100755 --- a/scripts/create-github-release.sh +++ b/scripts/create-github-release.sh @@ -2,7 +2,8 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}" +# shellcheck source=./release-lib.sh +. "$REPO_ROOT/scripts/release-lib.sh" dry_run=false version="" @@ -17,7 +18,7 @@ Examples: ./scripts/create-github-release.sh 1.2.3 --dry-run Notes: - - Run this after pushing the release commit and tag. + - Run this after pushing the stable release branch and tag. - If the release already exists, this script updates its title and notes. EOF } @@ -52,6 +53,7 @@ fi tag="v$version" notes_file="$REPO_ROOT/releases/${tag}.md" +PUBLISH_REMOTE="$(resolve_release_remote)" if ! command -v gh >/dev/null 2>&1; then echo "Error: gh CLI is required to create GitHub releases." >&2 diff --git a/scripts/release-lib.sh b/scripts/release-lib.sh new file mode 100644 index 00000000..0247136e --- /dev/null +++ b/scripts/release-lib.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash + +if [ -z "${REPO_ROOT:-}" ]; then + REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fi + +release_info() { + echo "$@" +} + +release_warn() { + echo "Warning: $*" >&2 +} + +release_fail() { + echo "Error: $*" >&2 + exit 1 +} + +git_remote_exists() { + git -C "$REPO_ROOT" remote get-url "$1" >/dev/null 2>&1 +} + +resolve_release_remote() { + local remote="${RELEASE_REMOTE:-${PUBLISH_REMOTE:-}}" + + if [ -n "$remote" ]; then + git_remote_exists "$remote" || release_fail "git remote '$remote' does not exist." + printf '%s\n' "$remote" + return + fi + + if git_remote_exists public-gh; then + printf 'public-gh\n' + return + fi + + if git_remote_exists origin; then + printf 'origin\n' + return + fi + + release_fail "no git remote found. Configure RELEASE_REMOTE or PUBLISH_REMOTE." +} + +fetch_release_remote() { + git -C "$REPO_ROOT" fetch "$1" --prune --tags +} + +get_last_stable_tag() { + git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1 +} + +get_current_stable_version() { + local tag + tag="$(get_last_stable_tag)" + if [ -z "$tag" ]; then + printf '0.0.0\n' + else + printf '%s\n' "${tag#v}" + fi +} + +compute_bumped_version() { + node - "$1" "$2" <<'NODE' +const current = process.argv[2]; +const bump = process.argv[3]; +const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/); + +if (!match) { + throw new Error(`invalid semver version: ${current}`); +} + +let [major, minor, patch] = match.slice(1).map(Number); + +if (bump === 'patch') { + patch += 1; +} else if (bump === 'minor') { + minor += 1; + patch = 0; +} else if (bump === 'major') { + major += 1; + minor = 0; + patch = 0; +} else { + throw new Error(`unsupported bump type: ${bump}`); +} + +process.stdout.write(`${major}.${minor}.${patch}`); +NODE +} + +next_canary_version() { + local stable_version="$1" + local versions_json + + versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')" + + node - "$stable_version" "$versions_json" <<'NODE' +const stable = process.argv[2]; +const versionsArg = process.argv[3]; + +let versions = []; +try { + const parsed = JSON.parse(versionsArg); + versions = Array.isArray(parsed) ? parsed : [parsed]; +} catch { + versions = []; +} + +const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`); +let max = -1; + +for (const version of versions) { + const match = version.match(pattern); + if (!match) continue; + max = Math.max(max, Number(match[1])); +} + +process.stdout.write(`${stable}-canary.${max + 1}`); +NODE +} + +release_branch_name() { + printf 'release/%s\n' "$1" +} + +release_notes_file() { + printf '%s/releases/v%s.md\n' "$REPO_ROOT" "$1" +} + +default_release_worktree_path() { + local version="$1" + local parent_dir + local repo_name + + parent_dir="$(cd "$REPO_ROOT/.." && pwd)" + repo_name="$(basename "$REPO_ROOT")" + printf '%s/%s-release-%s\n' "$parent_dir" "$repo_name" "$version" +} + +git_current_branch() { + git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD 2>/dev/null || true +} + +git_local_branch_exists() { + git -C "$REPO_ROOT" show-ref --verify --quiet "refs/heads/$1" +} + +git_remote_branch_exists() { + git -C "$REPO_ROOT" ls-remote --exit-code --heads "$2" "refs/heads/$1" >/dev/null 2>&1 +} + +git_local_tag_exists() { + git -C "$REPO_ROOT" show-ref --verify --quiet "refs/tags/$1" +} + +git_remote_tag_exists() { + git -C "$REPO_ROOT" ls-remote --exit-code --tags "$2" "refs/tags/$1" >/dev/null 2>&1 +} + +npm_version_exists() { + local version="$1" + local resolved + + resolved="$(npm view "paperclipai@${version}" version 2>/dev/null || true)" + [ "$resolved" = "$version" ] +} + +require_clean_worktree() { + if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then + release_fail "working tree is not clean. Commit, stash, or remove changes before releasing." + fi +} + +git_worktree_path_for_branch() { + local branch_ref="refs/heads/$1" + + git -C "$REPO_ROOT" worktree list --porcelain | awk -v branch_ref="$branch_ref" ' + $1 == "worktree" { path = substr($0, 10) } + $1 == "branch" && $2 == branch_ref { print path; exit } + ' +} + +path_is_worktree_for_branch() { + local path="$1" + local branch="$2" + local current_branch + + [ -d "$path" ] || return 1 + current_branch="$(git -C "$path" symbolic-ref --quiet --short HEAD 2>/dev/null || true)" + [ "$current_branch" = "$branch" ] +} + +ensure_release_branch_for_version() { + local stable_version="$1" + local current_branch + local expected_branch + + current_branch="$(git_current_branch)" + expected_branch="$(release_branch_name "$stable_version")" + + if [ -z "$current_branch" ]; then + release_fail "release work must run from branch $expected_branch, but HEAD is detached." + fi + + if [ "$current_branch" != "$expected_branch" ]; then + release_fail "release work must run from branch $expected_branch, but current branch is $current_branch." + fi +} + +stable_release_exists_anywhere() { + local stable_version="$1" + local remote="$2" + local tag="v$stable_version" + + git_local_tag_exists "$tag" || git_remote_tag_exists "$tag" "$remote" || npm_version_exists "$stable_version" +} + +release_train_is_frozen() { + stable_release_exists_anywhere "$1" "$2" +} diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index 84faf5b2..8db717b1 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -2,6 +2,8 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=./release-lib.sh +. "$REPO_ROOT/scripts/release-lib.sh" export GIT_PAGER=cat channel="" @@ -18,7 +20,9 @@ Examples: What it does: - verifies the git worktree is clean, including untracked files + - verifies you are on the matching release/X.Y.Z branch - shows the last stable tag and the target version(s) + - shows the git/npm/GitHub release-train state - shows commits since the last stable tag - highlights migration/schema/breaking-change signals - runs the verification gate: @@ -63,79 +67,19 @@ if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then exit 1 fi -compute_bumped_version() { - node - "$1" "$2" <<'NODE' -const current = process.argv[2]; -const bump = process.argv[3]; -const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/); - -if (!match) { - throw new Error(`invalid semver version: ${current}`); -} - -let [major, minor, patch] = match.slice(1).map(Number); - -if (bump === 'patch') { - patch += 1; -} else if (bump === 'minor') { - minor += 1; - patch = 0; -} else if (bump === 'major') { - major += 1; - minor = 0; - patch = 0; -} else { - throw new Error(`unsupported bump type: ${bump}`); -} - -process.stdout.write(`${major}.${minor}.${patch}`); -NODE -} - -next_canary_version() { - local stable_version="$1" - local versions_json - - versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')" - - node - "$stable_version" "$versions_json" <<'NODE' -const stable = process.argv[2]; -const versionsArg = process.argv[3]; - -let versions = []; -try { - const parsed = JSON.parse(versionsArg); - versions = Array.isArray(parsed) ? parsed : [parsed]; -} catch { - versions = []; -} - -const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`); -let max = -1; - -for (const version of versions) { - const match = version.match(pattern); - if (!match) continue; - max = Math.max(max, Number(match[1])); -} - -process.stdout.write(`${stable}-canary.${max + 1}`); -NODE -} - -LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)" -CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}" -if [ -z "$CURRENT_STABLE_VERSION" ]; then - CURRENT_STABLE_VERSION="0.0.0" -fi +RELEASE_REMOTE="$(resolve_release_remote)" +fetch_release_remote "$RELEASE_REMOTE" +LAST_STABLE_TAG="$(get_last_stable_tag)" +CURRENT_STABLE_VERSION="$(get_current_stable_version)" TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")" TARGET_CANARY_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" +EXPECTED_RELEASE_BRANCH="$(release_branch_name "$TARGET_STABLE_VERSION")" +CURRENT_BRANCH="$(git_current_branch)" +RELEASE_TAG="v$TARGET_STABLE_VERSION" +NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")" -if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then - echo "Error: working tree is not clean. Commit, stash, or remove changes before releasing." >&2 - exit 1 -fi +require_clean_worktree if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then echo "Error: next stable version matches the current stable version." >&2 @@ -147,10 +91,41 @@ if [[ "$TARGET_CANARY_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then exit 1 fi +ensure_release_branch_for_version "$TARGET_STABLE_VERSION" + +REMOTE_BRANCH_EXISTS="no" +REMOTE_TAG_EXISTS="no" +LOCAL_TAG_EXISTS="no" +NPM_STABLE_EXISTS="no" + +if git_remote_branch_exists "$EXPECTED_RELEASE_BRANCH" "$RELEASE_REMOTE"; then + REMOTE_BRANCH_EXISTS="yes" +fi + +if git_local_tag_exists "$RELEASE_TAG"; then + LOCAL_TAG_EXISTS="yes" +fi + +if git_remote_tag_exists "$RELEASE_TAG" "$RELEASE_REMOTE"; then + REMOTE_TAG_EXISTS="yes" +fi + +if npm_version_exists "$TARGET_STABLE_VERSION"; then + NPM_STABLE_EXISTS="yes" +fi + +if [ "$LOCAL_TAG_EXISTS" = "yes" ] || [ "$REMOTE_TAG_EXISTS" = "yes" ] || [ "$NPM_STABLE_EXISTS" = "yes" ]; then + echo "Error: release train $EXPECTED_RELEASE_BRANCH is frozen because $RELEASE_TAG already exists locally, remotely, or version $TARGET_STABLE_VERSION is already on npm." >&2 + exit 1 +fi + echo "" echo "==> Release preflight" +echo " Remote: $RELEASE_REMOTE" echo " Channel: $channel" echo " Bump: $bump_type" +echo " Current branch: ${CURRENT_BRANCH:-}" +echo " Expected branch: $EXPECTED_RELEASE_BRANCH" echo " Last stable tag: ${LAST_STABLE_TAG:-}" echo " Current stable version: $CURRENT_STABLE_VERSION" echo " Next stable version: $TARGET_STABLE_VERSION" @@ -162,6 +137,23 @@ fi echo "" echo "==> Working tree" echo " ✓ Clean" +echo " ✓ Branch matches release train" + +echo "" +echo "==> Release train state" +echo " Remote branch exists: $REMOTE_BRANCH_EXISTS" +echo " Local stable tag exists: $LOCAL_TAG_EXISTS" +echo " Remote stable tag exists: $REMOTE_TAG_EXISTS" +echo " Stable version on npm: $NPM_STABLE_EXISTS" +if [ -f "$NOTES_FILE" ]; then + echo " Release notes: present at $NOTES_FILE" +else + echo " Release notes: missing at $NOTES_FILE" +fi + +if [ "$REMOTE_BRANCH_EXISTS" = "no" ]; then + echo " Warning: remote branch $EXPECTED_RELEASE_BRANCH does not exist on $RELEASE_REMOTE yet." +fi echo "" echo "==> Commits since last stable tag" @@ -193,8 +185,10 @@ pnpm build echo "" echo "==> Release preflight summary" +echo " Remote: $RELEASE_REMOTE" echo " Channel: $channel" echo " Bump: $bump_type" +echo " Release branch: $EXPECTED_RELEASE_BRANCH" echo " Last stable tag: ${LAST_STABLE_TAG:-}" echo " Current stable version: $CURRENT_STABLE_VERSION" echo " Next stable version: $TARGET_STABLE_VERSION" diff --git a/scripts/release-start.sh b/scripts/release-start.sh new file mode 100755 index 00000000..c41af0f8 --- /dev/null +++ b/scripts/release-start.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=./release-lib.sh +. "$REPO_ROOT/scripts/release-lib.sh" + +dry_run=false +push_branch=true +bump_type="" +worktree_path="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/release-start.sh [--dry-run] [--no-push] [--worktree-dir PATH] + +Examples: + ./scripts/release-start.sh patch + ./scripts/release-start.sh minor --dry-run + ./scripts/release-start.sh major --worktree-dir ../paperclip-release-1.0.0 + +What it does: + - fetches the release remote and tags + - computes the next stable version from the latest stable tag + - creates or resumes branch release/X.Y.Z + - creates or resumes a dedicated worktree for that branch + - pushes the release branch to the remote by default + +Notes: + - Stable publishes freeze a release train. If vX.Y.Z already exists locally, + remotely, or on npm, this script refuses to reuse release/X.Y.Z. + - Use --no-push only if you intentionally do not want the release branch on + GitHub yet. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) dry_run=true ;; + --no-push) push_branch=false ;; + --worktree-dir) + shift + [ $# -gt 0 ] || release_fail "--worktree-dir requires a path." + worktree_path="$1" + ;; + -h|--help) + usage + exit 0 + ;; + *) + if [ -n "$bump_type" ]; then + release_fail "only one bump type may be provided." + fi + bump_type="$1" + ;; + esac + shift +done + +if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then + usage + exit 1 +fi + +release_remote="$(resolve_release_remote)" +fetch_release_remote "$release_remote" + +last_stable_tag="$(get_last_stable_tag)" +current_stable_version="$(get_current_stable_version)" +target_stable_version="$(compute_bumped_version "$current_stable_version" "$bump_type")" +target_canary_version="$(next_canary_version "$target_stable_version")" +release_branch="$(release_branch_name "$target_stable_version")" +release_tag="v$target_stable_version" + +if [ -z "$worktree_path" ]; then + worktree_path="$(default_release_worktree_path "$target_stable_version")" +fi + +if stable_release_exists_anywhere "$target_stable_version" "$release_remote"; then + release_fail "release train $release_branch is frozen because $release_tag already exists locally, remotely, or version $target_stable_version is already on npm." +fi + +branch_exists_local=false +branch_exists_remote=false +branch_worktree_path="" +created_worktree=false +created_branch=false +pushed_branch=false + +if git_local_branch_exists "$release_branch"; then + branch_exists_local=true +fi + +if git_remote_branch_exists "$release_branch" "$release_remote"; then + branch_exists_remote=true +fi + +branch_worktree_path="$(git_worktree_path_for_branch "$release_branch")" +if [ -n "$branch_worktree_path" ]; then + worktree_path="$branch_worktree_path" +fi + +if [ -e "$worktree_path" ] && ! path_is_worktree_for_branch "$worktree_path" "$release_branch"; then + release_fail "path $worktree_path already exists and is not a worktree for $release_branch." +fi + +if [ -z "$branch_worktree_path" ]; then + if [ "$dry_run" = true ]; then + if [ "$branch_exists_local" = true ] || [ "$branch_exists_remote" = true ]; then + release_info "[dry-run] Would add worktree $worktree_path for existing branch $release_branch" + else + release_info "[dry-run] Would create branch $release_branch from $release_remote/master" + release_info "[dry-run] Would add worktree $worktree_path" + fi + else + if [ "$branch_exists_local" = true ]; then + git -C "$REPO_ROOT" worktree add "$worktree_path" "$release_branch" + elif [ "$branch_exists_remote" = true ]; then + git -C "$REPO_ROOT" branch --track "$release_branch" "$release_remote/$release_branch" + git -C "$REPO_ROOT" worktree add "$worktree_path" "$release_branch" + created_branch=true + else + git -C "$REPO_ROOT" worktree add -b "$release_branch" "$worktree_path" "$release_remote/master" + created_branch=true + fi + created_worktree=true + fi +fi + +if [ "$dry_run" = false ] && [ "$push_branch" = true ] && [ "$branch_exists_remote" = false ]; then + git -C "$worktree_path" push -u "$release_remote" "$release_branch" + pushed_branch=true +fi + +if [ "$dry_run" = false ] && [ "$branch_exists_remote" = true ]; then + git -C "$worktree_path" branch --set-upstream-to "$release_remote/$release_branch" "$release_branch" >/dev/null 2>&1 || true +fi + +release_info "" +release_info "==> Release train" +release_info " Remote: $release_remote" +release_info " Last stable tag: ${last_stable_tag:-}" +release_info " Current stable version: $current_stable_version" +release_info " Bump: $bump_type" +release_info " Target stable version: $target_stable_version" +release_info " Next canary version: $target_canary_version" +release_info " Branch: $release_branch" +release_info " Tag (reserved until stable publish): $release_tag" +release_info " Worktree: $worktree_path" +release_info " Release notes path: $worktree_path/releases/v${target_stable_version}.md" + +release_info "" +release_info "==> Status" +if [ -n "$branch_worktree_path" ]; then + release_info " ✓ Reusing existing worktree for $release_branch" +elif [ "$dry_run" = true ]; then + release_info " ✓ Dry run only; no branch or worktree created" +else + [ "$created_branch" = true ] && release_info " ✓ Created branch $release_branch" + [ "$created_worktree" = true ] && release_info " ✓ Created worktree $worktree_path" +fi + +if [ "$branch_exists_remote" = true ]; then + release_info " ✓ Remote branch already exists on $release_remote" +elif [ "$dry_run" = true ] && [ "$push_branch" = true ]; then + release_info " [dry-run] Would push $release_branch to $release_remote" +elif [ "$push_branch" = true ] && [ "$pushed_branch" = true ]; then + release_info " ✓ Pushed $release_branch to $release_remote" +elif [ "$push_branch" = false ]; then + release_warn "release branch was not pushed. Stable publish will later refuse until the branch exists on $release_remote." +fi + +release_info "" +release_info "Next steps:" +release_info " cd $worktree_path" +release_info " Draft or update releases/v${target_stable_version}.md" +release_info " ./scripts/release-preflight.sh canary $bump_type" +release_info " ./scripts/release.sh $bump_type --canary" +release_info "" +release_info "Merge rule:" +release_info " Merge $release_branch back to master without squash or rebase so tag $release_tag remains reachable from master." diff --git a/scripts/release.sh b/scripts/release.sh index f21acc60..555a674c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -15,10 +15,11 @@ set -euo pipefail # npm dist-tag "canary". Stable releases publish 1.2.3 under "latest". REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=./release-lib.sh +. "$REPO_ROOT/scripts/release-lib.sh" CLI_DIR="$REPO_ROOT/cli" TEMP_CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md" TEMP_PRE_FILE="$REPO_ROOT/.changeset/pre.json" -PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}" dry_run=false canary=false @@ -41,6 +42,7 @@ Notes: - Canary publishes prerelease versions like 1.2.3-canary.0 under the npm dist-tag "canary". - Stable publishes 1.2.3 under the npm dist-tag "latest". + - Run this from branch release/X.Y.Z matching the computed target version. - Dry runs leave the working tree clean. EOF } @@ -73,15 +75,6 @@ if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then exit 1 fi -info() { - echo "$@" -} - -fail() { - echo "Error: $*" >&2 - exit 1 -} - restore_publish_artifacts() { if [ -f "$CLI_DIR/package.dev.json" ]; then mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json" @@ -130,28 +123,22 @@ set_cleanup_trap() { trap cleanup_release_state EXIT } -require_clean_worktree() { - if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then - fail "working tree is not clean. Commit, stash, or remove changes before releasing." - fi -} - require_npm_publish_auth() { if [ "$dry_run" = true ]; then return fi if npm whoami >/dev/null 2>&1; then - info " ✓ Logged in to npm as $(npm whoami)" + release_info " ✓ Logged in to npm as $(npm whoami)" return fi if [ "${GITHUB_ACTIONS:-}" = "true" ]; then - info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing" + release_info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing" return fi - fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow." + release_fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow." } list_public_package_info() { @@ -202,66 +189,6 @@ for (const [dir, name] of rows) { NODE } -compute_bumped_version() { - node - "$1" "$2" <<'NODE' -const current = process.argv[2]; -const bump = process.argv[3]; -const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/); - -if (!match) { - throw new Error(`invalid semver version: ${current}`); -} - -let [major, minor, patch] = match.slice(1).map(Number); - -if (bump === 'patch') { - patch += 1; -} else if (bump === 'minor') { - minor += 1; - patch = 0; -} else if (bump === 'major') { - major += 1; - minor = 0; - patch = 0; -} else { - throw new Error(`unsupported bump type: ${bump}`); -} - -process.stdout.write(`${major}.${minor}.${patch}`); -NODE -} - -next_canary_version() { - local stable_version="$1" - local versions_json - - versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')" - - node - "$stable_version" "$versions_json" <<'NODE' -const stable = process.argv[2]; -const versionsArg = process.argv[3]; - -let versions = []; -try { - const parsed = JSON.parse(versionsArg); - versions = Array.isArray(parsed) ? parsed : [parsed]; -} catch { - versions = []; -} - -const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`); -let max = -1; - -for (const version of versions) { - const match = version.match(pattern); - if (!match) continue; - max = Math.max(max, Number(match[1])); -} - -process.stdout.write(`${stable}-canary.${max + 1}`); -NODE -} - replace_version_string() { local from_version="$1" local to_version="$2" @@ -312,25 +239,55 @@ for (const relFile of extraFiles) { NODE } -LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)" -CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}" -if [ -z "$CURRENT_STABLE_VERSION" ]; then - CURRENT_STABLE_VERSION="0.0.0" -fi +PUBLISH_REMOTE="$(resolve_release_remote)" +fetch_release_remote "$PUBLISH_REMOTE" + +LAST_STABLE_TAG="$(get_last_stable_tag)" +CURRENT_STABLE_VERSION="$(get_current_stable_version)" TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")" TARGET_PUBLISH_VERSION="$TARGET_STABLE_VERSION" +CURRENT_BRANCH="$(git_current_branch)" +EXPECTED_RELEASE_BRANCH="$(release_branch_name "$TARGET_STABLE_VERSION")" +NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")" +RELEASE_TAG="v$TARGET_STABLE_VERSION" if [ "$canary" = true ]; then TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" fi if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then - fail "next stable version matches the current stable version. Refusing to publish." + release_fail "next stable version matches the current stable version. Refusing to publish." fi if [[ "$TARGET_PUBLISH_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then - fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N." + release_fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N." +fi + +require_clean_worktree +ensure_release_branch_for_version "$TARGET_STABLE_VERSION" + +if git_local_tag_exists "$RELEASE_TAG" || git_remote_tag_exists "$RELEASE_TAG" "$PUBLISH_REMOTE"; then + release_fail "release train $EXPECTED_RELEASE_BRANCH is frozen because tag $RELEASE_TAG already exists locally or on $PUBLISH_REMOTE." +fi + +if npm_version_exists "$TARGET_STABLE_VERSION"; then + release_fail "stable version $TARGET_STABLE_VERSION is already published on npm. Refusing to reuse release train $EXPECTED_RELEASE_BRANCH." +fi + +if [ "$canary" = false ] && [ ! -f "$NOTES_FILE" ]; then + release_fail "stable release notes file is required at $NOTES_FILE before publishing stable." +fi + +if [ "$canary" = true ] && [ ! -f "$NOTES_FILE" ]; then + release_warn "stable release notes file is missing at $NOTES_FILE. Draft it before you finalize stable." +fi + +if ! git_remote_branch_exists "$EXPECTED_RELEASE_BRANCH" "$PUBLISH_REMOTE"; then + if [ "$canary" = false ] && [ "$dry_run" = false ]; then + release_fail "remote branch $EXPECTED_RELEASE_BRANCH does not exist on $PUBLISH_REMOTE. Run ./scripts/release-start.sh $bump_type first or push the branch before stable publish." + fi + release_warn "remote branch $EXPECTED_RELEASE_BRANCH does not exist on $PUBLISH_REMOTE yet." fi PUBLIC_PACKAGE_INFO="$(list_public_package_info)" @@ -338,33 +295,36 @@ PUBLIC_PACKAGE_NAMES="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)" PUBLIC_PACKAGE_DIRS="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f1)" if [ -z "$PUBLIC_PACKAGE_INFO" ]; then - fail "no public packages were found in the workspace." + release_fail "no public packages were found in the workspace." fi -info "" -info "==> Release plan" -info " Last stable tag: ${LAST_STABLE_TAG:-}" -info " Current stable version: $CURRENT_STABLE_VERSION" +release_info "" +release_info "==> Release plan" +release_info " Remote: $PUBLISH_REMOTE" +release_info " Current branch: ${CURRENT_BRANCH:-}" +release_info " Expected branch: $EXPECTED_RELEASE_BRANCH" +release_info " Last stable tag: ${LAST_STABLE_TAG:-}" +release_info " Current stable version: $CURRENT_STABLE_VERSION" if [ "$canary" = true ]; then - info " Target stable version: $TARGET_STABLE_VERSION" - info " Canary version: $TARGET_PUBLISH_VERSION" - info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N" + release_info " Target stable version: $TARGET_STABLE_VERSION" + release_info " Canary version: $TARGET_PUBLISH_VERSION" + release_info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N" else - info " Stable version: $TARGET_STABLE_VERSION" + release_info " Stable version: $TARGET_STABLE_VERSION" fi -info "" -info "==> Step 1/7: Preflight checks..." -require_clean_worktree -info " ✓ Working tree is clean" +release_info "" +release_info "==> Step 1/7: Preflight checks..." +release_info " ✓ Working tree is clean" +release_info " ✓ Branch matches release train" require_npm_publish_auth if [ "$dry_run" = true ] || [ "$canary" = true ]; then set_cleanup_trap fi -info "" -info "==> Step 2/7: Creating release changeset..." +release_info "" +release_info "==> Step 2/7: Creating release changeset..." { echo "---" while IFS= read -r pkg_name; do @@ -379,10 +339,10 @@ info "==> Step 2/7: Creating release changeset..." echo "Stable release preparation for $TARGET_STABLE_VERSION" fi } > "$TEMP_CHANGESET_FILE" -info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages" +release_info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages" -info "" -info "==> Step 3/7: Versioning packages..." +release_info "" +release_info "==> Step 3/7: Versioning packages..." cd "$REPO_ROOT" if [ "$canary" = true ]; then npx changeset pre enter canary @@ -398,12 +358,12 @@ fi VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")" if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then - fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE." + release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE." fi -info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION" +release_info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION" -info "" -info "==> Step 4/7: Building workspace artifacts..." +release_info "" +release_info "==> Step 4/7: Building workspace artifacts..." cd "$REPO_ROOT" pnpm build bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh" @@ -411,49 +371,49 @@ for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-loc rm -rf "$REPO_ROOT/$pkg_dir/skills" cp -r "$REPO_ROOT/skills" "$REPO_ROOT/$pkg_dir/skills" done -info " ✓ Workspace build complete" +release_info " ✓ Workspace build complete" -info "" -info "==> Step 5/7: Building publishable CLI bundle..." +release_info "" +release_info "==> Step 5/7: Building publishable CLI bundle..." "$REPO_ROOT/scripts/build-npm.sh" --skip-checks -info " ✓ CLI bundle ready" +release_info " ✓ CLI bundle ready" -info "" +release_info "" if [ "$dry_run" = true ]; then - info "==> Step 6/7: Previewing publish payloads (--dry-run)..." + release_info "==> Step 6/7: Previewing publish payloads (--dry-run)..." while IFS= read -r pkg_dir; do [ -z "$pkg_dir" ] && continue - info " --- $pkg_dir ---" + release_info " --- $pkg_dir ---" cd "$REPO_ROOT/$pkg_dir" npm pack --dry-run 2>&1 | tail -3 done <<< "$PUBLIC_PACKAGE_DIRS" cd "$REPO_ROOT" if [ "$canary" = true ]; then - info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary" + release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary" else - info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest" + release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest" fi else if [ "$canary" = true ]; then - info "==> Step 6/7: Publishing canary to npm..." + release_info "==> Step 6/7: Publishing canary to npm..." npx changeset publish - info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary" + release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary" else - info "==> Step 6/7: Publishing stable release to npm..." + release_info "==> Step 6/7: Publishing stable release to npm..." npx changeset publish - info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest" + release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest" fi fi -info "" +release_info "" if [ "$dry_run" = true ]; then - info "==> Step 7/7: Cleaning up dry-run state..." - info " ✓ Dry run leaves the working tree unchanged" + release_info "==> Step 7/7: Cleaning up dry-run state..." + release_info " ✓ Dry run leaves the working tree unchanged" elif [ "$canary" = true ]; then - info "==> Step 7/7: Cleaning up canary state..." - info " ✓ Canary state will be discarded after publish" + release_info "==> Step 7/7: Cleaning up canary state..." + release_info " ✓ Canary state will be discarded after publish" else - info "==> Step 7/7: Finalizing stable release commit..." + release_info "==> Step 7/7: Finalizing stable release commit..." restore_publish_artifacts git -C "$REPO_ROOT" add -u .changeset packages server cli @@ -463,23 +423,24 @@ else git -C "$REPO_ROOT" commit -m "chore: release v$TARGET_STABLE_VERSION" git -C "$REPO_ROOT" tag "v$TARGET_STABLE_VERSION" - info " ✓ Created commit and tag v$TARGET_STABLE_VERSION" + release_info " ✓ Created commit and tag v$TARGET_STABLE_VERSION" fi -info "" +release_info "" if [ "$dry_run" = true ]; then if [ "$canary" = true ]; then - info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}." + release_info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}." else - info "Dry run complete for stable v${TARGET_STABLE_VERSION}." + release_info "Dry run complete for stable v${TARGET_STABLE_VERSION}." fi elif [ "$canary" = true ]; then - info "Published canary ${TARGET_PUBLISH_VERSION}." - info "Install with: npx paperclipai@canary onboard" - info "Stable version remains: $CURRENT_STABLE_VERSION" + release_info "Published canary ${TARGET_PUBLISH_VERSION}." + release_info "Install with: npx paperclipai@canary onboard" + release_info "Stable version remains: $CURRENT_STABLE_VERSION" else - info "Published stable v${TARGET_STABLE_VERSION}." - info "Next steps:" - info " git push ${PUBLISH_REMOTE} HEAD:master --follow-tags" - info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION" + release_info "Published stable v${TARGET_STABLE_VERSION}." + release_info "Next steps:" + release_info " git push ${PUBLISH_REMOTE} HEAD --follow-tags" + release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION" + release_info " Open a PR from ${EXPECTED_RELEASE_BRANCH} to master and merge without squash or rebase" fi diff --git a/skills/release-changelog/SKILL.md b/skills/release-changelog/SKILL.md index b70b97f5..4b1cdba0 100644 --- a/skills/release-changelog/SKILL.md +++ b/skills/release-changelog/SKILL.md @@ -106,6 +106,25 @@ Guidelines: - keep highlights short and concrete - spell out upgrade actions for breaking changes +### Inline PR and contributor attribution + +When a bullet item clearly maps to a merged pull request, add inline attribution at the +end of the entry in this format: + +``` +- **Feature name** — Description. ([#123](https://github.com/paperclipai/paperclip/pull/123), @contributor1, @contributor2) +``` + +Rules: + +- Only add a PR link when you can confidently trace the bullet to a specific merged PR. + Use merge commit messages (`Merge pull request #N from user/branch`) to map PRs. +- List the contributor(s) who authored the PR. Use GitHub usernames, not real names or emails. +- If multiple PRs contributed to a single bullet, list them all: `([#10](url), [#12](url), @user1, @user2)`. +- If you cannot determine the PR number or contributor with confidence, omit the attribution + parenthetical — do not guess. +- Core maintainer commits that don't have an external PR can omit the parenthetical. + ## Step 5 — Write the File Template: @@ -124,10 +143,29 @@ Template: ## Fixes ## Upgrade Guide + +## Contributors + +Thank you to everyone who contributed to this release! + +@username1, @username2, @username3 ``` Omit empty sections except `Highlights`, `Improvements`, and `Fixes`, which should usually exist. +The `Contributors` section should always be included. List every person who authored +commits in the release range, @-mentioning them by their **GitHub username** (not their +real name or email). To find GitHub usernames: + +1. Extract usernames from merge commit messages: `git log v{last}..HEAD --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username. +2. For noreply emails like `user@users.noreply.github.com`, the username is the part before `@`. +3. For contributors whose username is ambiguous, check `gh api users/{guess}` or the PR page. + +**Never expose contributor email addresses.** Use `@username` only. + +Exclude bot accounts (e.g. `lockfile-bot`, `dependabot`) from the list. List contributors +in alphabetical order by GitHub username (case-insensitive). + ## Step 6 — Review Before Release Before handing it off: diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index 085c1bad..5f39ba76 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -13,10 +13,11 @@ Run the full Paperclip release as a maintainer workflow, not just an npm publish This skill coordinates: - stable changelog drafting via `release-changelog` +- release-train setup via `scripts/release-start.sh` - prerelease canary publishing via `scripts/release.sh --canary` - Docker smoke testing via `scripts/docker-onboard-smoke.sh` - stable publishing via `scripts/release.sh` -- pushing the release commit and tag +- pushing the stable branch commit and tag - GitHub Release creation via `scripts/create-github-release.sh` - website / announcement follow-up tasks @@ -36,7 +37,7 @@ Before proceeding, verify all of the following: 2. The repo working tree is clean, including untracked files. 3. There are commits since the last stable tag. 4. The release SHA has passed the verification gate or is about to. -5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`. +5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the release branch is cut. 6. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing. 7. If running through Paperclip, you have issue context for status updates and follow-up task creation. @@ -55,13 +56,15 @@ Collect these inputs up front: Paperclip now uses this release model: -1. Draft the **stable** changelog as `releases/vX.Y.Z.md` -2. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0` -3. Smoke test the canary via Docker -4. Publish the stable version `X.Y.Z` -5. Push the release commit and tag -6. Create the GitHub Release -7. Complete website and announcement surfaces +1. Start or resume `release/X.Y.Z` +2. Draft the **stable** changelog as `releases/vX.Y.Z.md` +3. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0` +4. Smoke test the canary via Docker +5. Publish the stable version `X.Y.Z` +6. Push the stable branch commit and tag +7. Create the GitHub Release +8. Merge `release/X.Y.Z` back to `master` without squash or rebase +9. Complete website and announcement surfaces Critical consequence: @@ -70,7 +73,13 @@ Critical consequence: ## Step 1 — Decide the Stable Version -Run release preflight first: +Start the release train first: + +```bash +./scripts/release-start.sh {patch|minor|major} +``` + +Then run release preflight: ```bash ./scripts/release-preflight.sh canary {patch|minor|major} @@ -125,7 +134,7 @@ The GitHub Actions release workflow installs with `pnpm install --frozen-lockfil ## Step 4 — Publish a Canary -Run: +Run from the `release/X.Y.Z` branch: ```bash ./scripts/release.sh {patch|minor|major} --canary --dry-run @@ -203,12 +212,14 @@ Stable publish does **not** push the release for you. After stable publish succeeds: ```bash -git push public-gh HEAD:master --follow-tags +git push public-gh HEAD --follow-tags ./scripts/create-github-release.sh X.Y.Z ``` Use the stable changelog file as the GitHub Release notes source. +Then open the PR from `release/X.Y.Z` back to `master` and merge without squash or rebase. + ## Step 8 — Finish the Other Surfaces Create or verify follow-up work for: From 5dd1e6335a42d52128f532a37f07b7433cca21ae Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 14:09:30 -0500 Subject: [PATCH 056/874] Fix root TypeScript solution config --- cli/tsconfig.json | 2 +- packages/adapter-utils/tsconfig.json | 2 +- packages/adapters/claude-local/tsconfig.json | 2 +- packages/adapters/codex-local/tsconfig.json | 2 +- packages/adapters/cursor-local/tsconfig.json | 2 +- .../adapters/openclaw-gateway/tsconfig.json | 2 +- .../adapters/opencode-local/tsconfig.json | 2 +- packages/adapters/pi-local/tsconfig.json | 2 +- packages/db/tsconfig.json | 2 +- packages/shared/tsconfig.json | 2 +- server/tsconfig.json | 2 +- tsconfig.base.json | 18 +++++++++++ tsconfig.json | 32 +++++++++---------- 13 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 tsconfig.base.json diff --git a/cli/tsconfig.json b/cli/tsconfig.json index e4600622..dc664efe 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../tsconfig.json", + "extends": "../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/adapter-utils/tsconfig.json b/packages/adapter-utils/tsconfig.json index a086b149..5a24989c 100644 --- a/packages/adapter-utils/tsconfig.json +++ b/packages/adapter-utils/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/adapters/claude-local/tsconfig.json b/packages/adapters/claude-local/tsconfig.json index 2f355cfe..e1b71318 100644 --- a/packages/adapters/claude-local/tsconfig.json +++ b/packages/adapters/claude-local/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/adapters/codex-local/tsconfig.json b/packages/adapters/codex-local/tsconfig.json index 2f355cfe..e1b71318 100644 --- a/packages/adapters/codex-local/tsconfig.json +++ b/packages/adapters/codex-local/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/adapters/cursor-local/tsconfig.json b/packages/adapters/cursor-local/tsconfig.json index 90314411..8fea361a 100644 --- a/packages/adapters/cursor-local/tsconfig.json +++ b/packages/adapters/cursor-local/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", diff --git a/packages/adapters/openclaw-gateway/tsconfig.json b/packages/adapters/openclaw-gateway/tsconfig.json index 2f355cfe..e1b71318 100644 --- a/packages/adapters/openclaw-gateway/tsconfig.json +++ b/packages/adapters/openclaw-gateway/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/adapters/opencode-local/tsconfig.json b/packages/adapters/opencode-local/tsconfig.json index 2f355cfe..e1b71318 100644 --- a/packages/adapters/opencode-local/tsconfig.json +++ b/packages/adapters/opencode-local/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/adapters/pi-local/tsconfig.json b/packages/adapters/pi-local/tsconfig.json index 2f355cfe..e1b71318 100644 --- a/packages/adapters/pi-local/tsconfig.json +++ b/packages/adapters/pi-local/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index a086b149..5a24989c 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index a086b149..5a24989c 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/server/tsconfig.json b/server/tsconfig.json index da335836..921c3aed 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../tsconfig.json", + "extends": "../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..25e06d83 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + } +} diff --git a/tsconfig.json b/tsconfig.json index 25e06d83..3a989f38 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,18 +1,18 @@ { - "compilerOptions": { - "target": "ES2023", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "esModuleInterop": true, - "strict": true, - "skipLibCheck": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "dist", - "rootDir": "src", - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true - } + "extends": "./tsconfig.base.json", + "files": [], + "references": [ + { "path": "./packages/adapter-utils" }, + { "path": "./packages/shared" }, + { "path": "./packages/db" }, + { "path": "./packages/adapters/claude-local" }, + { "path": "./packages/adapters/codex-local" }, + { "path": "./packages/adapters/cursor-local" }, + { "path": "./packages/adapters/openclaw-gateway" }, + { "path": "./packages/adapters/opencode-local" }, + { "path": "./packages/adapters/pi-local" }, + { "path": "./server" }, + { "path": "./ui" }, + { "path": "./cli" } + ] } From c62266aa6aca7f2d07746e71894709b9009f52a3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 14:41:00 -0500 Subject: [PATCH 057/874] tweaks to docker smoke --- cli/src/commands/run.ts | 20 +++++++++++++++++++- doc/DOCKER.md | 1 + scripts/docker-onboard-smoke.sh | 3 +++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index a6606745..04743b48 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -86,11 +86,29 @@ export async function runCommand(opts: RunOptions): Promise { await bootstrapCeoInvite({ config: configPath, dbUrl: startedServer.databaseUrl, - baseUrl: startedServer.apiUrl.replace(/\/api$/, ""), + baseUrl: resolveBootstrapInviteBaseUrl(config, startedServer), }); } } +function resolveBootstrapInviteBaseUrl( + config: PaperclipConfig, + startedServer: StartedServer, +): string { + const explicitBaseUrl = + process.env.PAPERCLIP_PUBLIC_URL ?? + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? + process.env.BETTER_AUTH_URL ?? + process.env.BETTER_AUTH_BASE_URL ?? + (config.auth.baseUrlMode === "explicit" ? config.auth.publicBaseUrl : undefined); + + if (typeof explicitBaseUrl === "string" && explicitBaseUrl.trim().length > 0) { + return explicitBaseUrl.trim().replace(/\/+$/, ""); + } + + return startedServer.apiUrl.replace(/\/api$/, ""); +} + function formatError(err: unknown): string { if (err instanceof Error) { if (err.message && err.message.trim().length > 0) return err.message; diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 49d0c4ab..9cc867db 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -122,5 +122,6 @@ Notes: - Container runtime user id defaults to your local `id -u` so the mounted data dir stays writable while avoiding root runtime. - Smoke script defaults to `authenticated/private` mode so `HOST=0.0.0.0` can be exposed to the host. - Smoke script defaults host port to `3131` to avoid conflicts with local Paperclip on `3100`. +- Smoke script also defaults `PAPERCLIP_PUBLIC_URL` to `http://localhost:` so bootstrap invite URLs and auth callbacks use the reachable host port instead of the container's internal `3100`. - Run the script in the foreground to watch the onboarding flow; stop with `Ctrl+C` after validation. - The image definition is in `Dockerfile.onboard-smoke`. diff --git a/scripts/docker-onboard-smoke.sh b/scripts/docker-onboard-smoke.sh index 2da125de..3b3e24d8 100755 --- a/scripts/docker-onboard-smoke.sh +++ b/scripts/docker-onboard-smoke.sh @@ -9,6 +9,7 @@ DATA_DIR="${DATA_DIR:-$REPO_ROOT/data/docker-onboard-smoke}" HOST_UID="${HOST_UID:-$(id -u)}" PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}" PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}" +PAPERCLIP_PUBLIC_URL="${PAPERCLIP_PUBLIC_URL:-http://localhost:${HOST_PORT}}" DOCKER_TTY_ARGS=() if [[ -t 0 && -t 1 ]]; then @@ -27,6 +28,7 @@ docker build \ echo "==> Running onboard smoke container" echo " UI should be reachable at: http://localhost:$HOST_PORT" +echo " Public URL: $PAPERCLIP_PUBLIC_URL" echo " Data dir: $DATA_DIR" echo " Deployment: $PAPERCLIP_DEPLOYMENT_MODE/$PAPERCLIP_DEPLOYMENT_EXPOSURE" echo " Live output: onboard banner and server logs stream in this terminal (Ctrl+C to stop)" @@ -38,5 +40,6 @@ docker run --rm \ -e PORT=3100 \ -e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \ -e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \ + -e PAPERCLIP_PUBLIC_URL="$PAPERCLIP_PUBLIC_URL" \ -v "$DATA_DIR:/paperclip" \ "$IMAGE_NAME" From 64f5c3f8378f8107533ca8516b524f915d4a9810 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 15:30:08 -0500 Subject: [PATCH 058/874] Fix authenticated smoke bootstrap flow --- doc/DOCKER.md | 1 + scripts/docker-onboard-smoke.sh | 211 ++++++++++++++++++++++++++++++-- server/src/routes/health.ts | 23 +++- ui/src/App.tsx | 9 +- ui/src/api/health.ts | 1 + 5 files changed, 230 insertions(+), 15 deletions(-) diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 9cc867db..82559bf8 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -123,5 +123,6 @@ Notes: - Smoke script defaults to `authenticated/private` mode so `HOST=0.0.0.0` can be exposed to the host. - Smoke script defaults host port to `3131` to avoid conflicts with local Paperclip on `3100`. - Smoke script also defaults `PAPERCLIP_PUBLIC_URL` to `http://localhost:` so bootstrap invite URLs and auth callbacks use the reachable host port instead of the container's internal `3100`. +- In authenticated mode, the smoke script defaults `SMOKE_AUTO_BOOTSTRAP=true` and drives the real bootstrap path automatically: it signs up a real user, runs `paperclipai auth bootstrap-ceo` inside the container to mint a real bootstrap invite, accepts that invite over HTTP, and verifies board session access. - Run the script in the foreground to watch the onboarding flow; stop with `Ctrl+C` after validation. - The image definition is in `Dockerfile.onboard-smoke`. diff --git a/scripts/docker-onboard-smoke.sh b/scripts/docker-onboard-smoke.sh index 3b3e24d8..c441a623 100755 --- a/scripts/docker-onboard-smoke.sh +++ b/scripts/docker-onboard-smoke.sh @@ -10,14 +10,187 @@ HOST_UID="${HOST_UID:-$(id -u)}" PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}" PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}" PAPERCLIP_PUBLIC_URL="${PAPERCLIP_PUBLIC_URL:-http://localhost:${HOST_PORT}}" -DOCKER_TTY_ARGS=() - -if [[ -t 0 && -t 1 ]]; then - DOCKER_TTY_ARGS=(-it) -fi +SMOKE_AUTO_BOOTSTRAP="${SMOKE_AUTO_BOOTSTRAP:-true}" +SMOKE_ADMIN_NAME="${SMOKE_ADMIN_NAME:-Smoke Admin}" +SMOKE_ADMIN_EMAIL="${SMOKE_ADMIN_EMAIL:-smoke-admin@paperclip.local}" +SMOKE_ADMIN_PASSWORD="${SMOKE_ADMIN_PASSWORD:-paperclip-smoke-password}" +CONTAINER_NAME="${IMAGE_NAME//[^a-zA-Z0-9_.-]/-}" +LOG_PID="" +COOKIE_JAR="" +TMP_DIR="" mkdir -p "$DATA_DIR" +cleanup() { + if [[ -n "$LOG_PID" ]]; then + kill "$LOG_PID" >/dev/null 2>&1 || true + fi + docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then + rm -rf "$TMP_DIR" + fi +} + +trap cleanup EXIT INT TERM + +wait_for_http() { + local url="$1" + local attempts="${2:-60}" + local sleep_seconds="${3:-1}" + local i + for ((i = 1; i <= attempts; i += 1)); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep "$sleep_seconds" + done + return 1 +} + +generate_bootstrap_invite_url() { + local bootstrap_output + bootstrap_output="$( + docker exec \ + -e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \ + -e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \ + -e PAPERCLIP_PUBLIC_URL="$PAPERCLIP_PUBLIC_URL" \ + -e PAPERCLIP_HOME="/paperclip" \ + "$CONTAINER_NAME" bash -lc \ + 'npx --yes "paperclipai@${PAPERCLIPAI_VERSION}" auth bootstrap-ceo --data-dir "$PAPERCLIP_HOME" --base-url "$PAPERCLIP_PUBLIC_URL"' \ + 2>&1 + )" || { + echo "Smoke bootstrap failed: could not run bootstrap-ceo inside container" >&2 + printf '%s\n' "$bootstrap_output" >&2 + return 1 + } + + local invite_url + invite_url="$( + printf '%s\n' "$bootstrap_output" \ + | grep -o 'https\?://[^[:space:]]*/invite/pcp_bootstrap_[[:alnum:]]*' \ + | tail -n 1 + )" + + if [[ -z "$invite_url" ]]; then + echo "Smoke bootstrap failed: bootstrap-ceo did not print an invite URL" >&2 + printf '%s\n' "$bootstrap_output" >&2 + return 1 + fi + + printf '%s\n' "$invite_url" +} + +post_json_with_cookies() { + local url="$1" + local body="$2" + local output_file="$3" + curl -sS \ + -o "$output_file" \ + -w "%{http_code}" \ + -c "$COOKIE_JAR" \ + -b "$COOKIE_JAR" \ + -H "Content-Type: application/json" \ + -H "Origin: $PAPERCLIP_PUBLIC_URL" \ + -X POST \ + "$url" \ + --data "$body" +} + +get_with_cookies() { + local url="$1" + curl -fsS \ + -c "$COOKIE_JAR" \ + -b "$COOKIE_JAR" \ + -H "Accept: application/json" \ + "$url" +} + +sign_up_or_sign_in() { + local signup_response="$TMP_DIR/signup.json" + local signup_status + signup_status="$(post_json_with_cookies \ + "$PAPERCLIP_PUBLIC_URL/api/auth/sign-up/email" \ + "{\"name\":\"$SMOKE_ADMIN_NAME\",\"email\":\"$SMOKE_ADMIN_EMAIL\",\"password\":\"$SMOKE_ADMIN_PASSWORD\"}" \ + "$signup_response")" + if [[ "$signup_status" =~ ^2 ]]; then + echo " Smoke bootstrap: created admin user $SMOKE_ADMIN_EMAIL" + return 0 + fi + + local signin_response="$TMP_DIR/signin.json" + local signin_status + signin_status="$(post_json_with_cookies \ + "$PAPERCLIP_PUBLIC_URL/api/auth/sign-in/email" \ + "{\"email\":\"$SMOKE_ADMIN_EMAIL\",\"password\":\"$SMOKE_ADMIN_PASSWORD\"}" \ + "$signin_response")" + if [[ "$signin_status" =~ ^2 ]]; then + echo " Smoke bootstrap: signed in existing admin user $SMOKE_ADMIN_EMAIL" + return 0 + fi + + echo "Smoke bootstrap failed: could not sign up or sign in admin user" >&2 + echo "Sign-up response:" >&2 + cat "$signup_response" >&2 || true + echo >&2 + echo "Sign-in response:" >&2 + cat "$signin_response" >&2 || true + echo >&2 + return 1 +} + +auto_bootstrap_authenticated_smoke() { + local health_url="$PAPERCLIP_PUBLIC_URL/api/health" + local health_json + health_json="$(curl -fsS "$health_url")" + if [[ "$health_json" != *'"deploymentMode":"authenticated"'* ]]; then + return 0 + fi + + sign_up_or_sign_in + + if [[ "$health_json" == *'"bootstrapStatus":"ready"'* ]]; then + echo " Smoke bootstrap: instance already ready" + else + local invite_url + invite_url="$(generate_bootstrap_invite_url)" + echo " Smoke bootstrap: generated bootstrap invite via auth bootstrap-ceo" + + local invite_token="${invite_url##*/}" + local accept_response="$TMP_DIR/accept.json" + local accept_status + accept_status="$(post_json_with_cookies \ + "$PAPERCLIP_PUBLIC_URL/api/invites/$invite_token/accept" \ + '{"requestType":"human"}' \ + "$accept_response")" + if [[ ! "$accept_status" =~ ^2 ]]; then + echo "Smoke bootstrap failed: bootstrap invite acceptance returned HTTP $accept_status" >&2 + cat "$accept_response" >&2 || true + echo >&2 + return 1 + fi + echo " Smoke bootstrap: accepted bootstrap invite" + fi + + local session_json + session_json="$(get_with_cookies "$PAPERCLIP_PUBLIC_URL/api/auth/get-session")" + if [[ "$session_json" != *'"userId"'* ]]; then + echo "Smoke bootstrap failed: no authenticated session after bootstrap" >&2 + echo "$session_json" >&2 + return 1 + fi + + local companies_json + companies_json="$(get_with_cookies "$PAPERCLIP_PUBLIC_URL/api/companies")" + if [[ "${companies_json:0:1}" != "[" ]]; then + echo "Smoke bootstrap failed: board companies endpoint did not return JSON array" >&2 + echo "$companies_json" >&2 + return 1 + fi + + echo " Smoke bootstrap: board session verified" + echo " Smoke admin credentials: $SMOKE_ADMIN_EMAIL / $SMOKE_ADMIN_PASSWORD" +} + echo "==> Building onboard smoke image" docker build \ --build-arg PAPERCLIPAI_VERSION="$PAPERCLIPAI_VERSION" \ @@ -29,12 +202,15 @@ docker build \ echo "==> Running onboard smoke container" echo " UI should be reachable at: http://localhost:$HOST_PORT" echo " Public URL: $PAPERCLIP_PUBLIC_URL" +echo " Smoke auto-bootstrap: $SMOKE_AUTO_BOOTSTRAP" echo " Data dir: $DATA_DIR" echo " Deployment: $PAPERCLIP_DEPLOYMENT_MODE/$PAPERCLIP_DEPLOYMENT_EXPOSURE" echo " Live output: onboard banner and server logs stream in this terminal (Ctrl+C to stop)" -docker run --rm \ - "${DOCKER_TTY_ARGS[@]}" \ - --name "${IMAGE_NAME//[^a-zA-Z0-9_.-]/-}" \ + +docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + +docker run -d --rm \ + --name "$CONTAINER_NAME" \ -p "$HOST_PORT:3100" \ -e HOST=0.0.0.0 \ -e PORT=3100 \ @@ -42,4 +218,21 @@ docker run --rm \ -e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \ -e PAPERCLIP_PUBLIC_URL="$PAPERCLIP_PUBLIC_URL" \ -v "$DATA_DIR:/paperclip" \ - "$IMAGE_NAME" + "$IMAGE_NAME" >/dev/null + +docker logs -f "$CONTAINER_NAME" & +LOG_PID=$! + +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-onboard-smoke.XXXXXX")" +COOKIE_JAR="$TMP_DIR/cookies.txt" + +if ! wait_for_http "$PAPERCLIP_PUBLIC_URL/api/health" 90 1; then + echo "Smoke bootstrap failed: server did not become ready at $PAPERCLIP_PUBLIC_URL/api/health" >&2 + exit 1 +fi + +if [[ "$SMOKE_AUTO_BOOTSTRAP" == "true" && "$PAPERCLIP_DEPLOYMENT_MODE" == "authenticated" ]]; then + auto_bootstrap_authenticated_smoke +fi + +wait "$LOG_PID" diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 9a95f6e8..ddc7c441 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import type { Db } from "@paperclipai/db"; -import { count, sql } from "drizzle-orm"; -import { instanceUserRoles } from "@paperclipai/db"; +import { and, count, eq, gt, isNull, sql } from "drizzle-orm"; +import { instanceUserRoles, invites } from "@paperclipai/db"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; export function healthRoutes( @@ -27,6 +27,7 @@ export function healthRoutes( } let bootstrapStatus: "ready" | "bootstrap_pending" = "ready"; + let bootstrapInviteActive = false; if (opts.deploymentMode === "authenticated") { const roleCount = await db .select({ count: count() }) @@ -34,6 +35,23 @@ export function healthRoutes( .where(sql`${instanceUserRoles.role} = 'instance_admin'`) .then((rows) => Number(rows[0]?.count ?? 0)); bootstrapStatus = roleCount > 0 ? "ready" : "bootstrap_pending"; + + if (bootstrapStatus === "bootstrap_pending") { + const now = new Date(); + const inviteCount = await db + .select({ count: count() }) + .from(invites) + .where( + and( + eq(invites.inviteType, "bootstrap_ceo"), + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + gt(invites.expiresAt, now), + ), + ) + .then((rows) => Number(rows[0]?.count ?? 0)); + bootstrapInviteActive = inviteCount > 0; + } } res.json({ @@ -42,6 +60,7 @@ export function healthRoutes( deploymentExposure: opts.deploymentExposure, authReady: opts.authReady, bootstrapStatus, + bootstrapInviteActive, features: { companyDeletionEnabled: opts.companyDeletionEnabled, }, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 18df83d8..ee6db712 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -32,14 +32,15 @@ import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; -function BootstrapPendingPage() { +function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { return (

Instance setup required

- No instance admin exists yet. Run this command in your Paperclip environment to generate - the first admin invite URL: + {hasActiveInvite + ? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:" + : "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"}

 {`pnpm paperclipai auth bootstrap-ceo`}
@@ -78,7 +79,7 @@ function CloudAccessGate() {
   }
 
   if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
-    return ;
+    return ;
   }
 
   if (isAuthenticatedMode && !sessionQuery.data) {
diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts
index 614bb522..cb1b1374 100644
--- a/ui/src/api/health.ts
+++ b/ui/src/api/health.ts
@@ -4,6 +4,7 @@ export type HealthStatus = {
   deploymentExposure?: "private" | "public";
   authReady?: boolean;
   bootstrapStatus?: "ready" | "bootstrap_pending";
+  bootstrapInviteActive?: boolean;
   features?: {
     companyDeletionEnabled?: boolean;
   };

From 8a7b7a2383cf3d8494f600c67c9a6b3ee538630a Mon Sep 17 00:00:00 2001
From: adamrobbie 
Date: Mon, 9 Mar 2026 16:58:57 -0400
Subject: [PATCH 059/874] docs: remove obsolete TODO for CONTRIBUTING.md

---
 README.md | 2 --
 1 file changed, 2 deletions(-)

diff --git a/README.md b/README.md
index c3d9fc8e..70ddee5f 100644
--- a/README.md
+++ b/README.md
@@ -248,8 +248,6 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
 
 We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
 
-
-
 
## Community From 01c5a6f198e782dcb6a6b18bdea967636ef45d06 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 16:06:16 -0500 Subject: [PATCH 060/874] Unblock canary onboard smoke bootstrap --- cli/src/commands/auth-bootstrap-ceo.ts | 7 +++++++ scripts/docker-onboard-smoke.sh | 19 +++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/auth-bootstrap-ceo.ts b/cli/src/commands/auth-bootstrap-ceo.ts index ec539396..dc720f63 100644 --- a/cli/src/commands/auth-bootstrap-ceo.ts +++ b/cli/src/commands/auth-bootstrap-ceo.ts @@ -75,6 +75,11 @@ export async function bootstrapCeoInvite(opts: { } const db = createDb(dbUrl); + const closableDb = db as typeof db & { + $client?: { + end?: (options?: { timeout?: number }) => Promise; + }; + }; try { const existingAdminCount = await db .select() @@ -122,5 +127,7 @@ export async function bootstrapCeoInvite(opts: { } catch (err) { p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`); p.log.info("If using embedded-postgres, start the Paperclip server and run this command again."); + } finally { + await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined); } } diff --git a/scripts/docker-onboard-smoke.sh b/scripts/docker-onboard-smoke.sh index c441a623..41c875be 100755 --- a/scripts/docker-onboard-smoke.sh +++ b/scripts/docker-onboard-smoke.sh @@ -49,20 +49,27 @@ wait_for_http() { generate_bootstrap_invite_url() { local bootstrap_output - bootstrap_output="$( + local bootstrap_status + if bootstrap_output="$( docker exec \ -e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \ -e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \ -e PAPERCLIP_PUBLIC_URL="$PAPERCLIP_PUBLIC_URL" \ -e PAPERCLIP_HOME="/paperclip" \ "$CONTAINER_NAME" bash -lc \ - 'npx --yes "paperclipai@${PAPERCLIPAI_VERSION}" auth bootstrap-ceo --data-dir "$PAPERCLIP_HOME" --base-url "$PAPERCLIP_PUBLIC_URL"' \ + 'timeout 20s npx --yes "paperclipai@${PAPERCLIPAI_VERSION}" auth bootstrap-ceo --data-dir "$PAPERCLIP_HOME" --base-url "$PAPERCLIP_PUBLIC_URL"' \ 2>&1 - )" || { + )"; then + bootstrap_status=0 + else + bootstrap_status=$? + fi + + if [[ $bootstrap_status -ne 0 && $bootstrap_status -ne 124 ]]; then echo "Smoke bootstrap failed: could not run bootstrap-ceo inside container" >&2 printf '%s\n' "$bootstrap_output" >&2 return 1 - } + fi local invite_url invite_url="$( @@ -77,6 +84,10 @@ generate_bootstrap_invite_url() { return 1 fi + if [[ $bootstrap_status -eq 124 ]]; then + echo " Smoke bootstrap: bootstrap-ceo timed out after printing invite URL; continuing" >&2 + fi + printf '%s\n' "$invite_url" } From c672b71f7fe523a2f70420b2eb598aceb25d569a Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 16:13:15 -0500 Subject: [PATCH 061/874] Refresh bootstrap gate while setup is pending --- ui/src/App.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ee6db712..8828ca86 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -56,6 +56,15 @@ function CloudAccessGate() { queryKey: queryKeys.health, queryFn: () => healthApi.get(), retry: false, + refetchInterval: (query) => { + const data = query.state.data as + | { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" } + | undefined; + return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending" + ? 2000 + : false; + }, + refetchIntervalInBackground: true, }); const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated"; From 7e8908afa23d3c89558e4cd96ef2aa85692e447d Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 16:31:12 -0500 Subject: [PATCH 062/874] chore: release v0.3.0 --- .changeset/add-pi-adapter-support.md | 5 ----- cli/CHANGELOG.md | 21 +++++++++++++++++++ cli/package.json | 2 +- packages/adapter-utils/CHANGELOG.md | 6 ++++++ packages/adapter-utils/package.json | 2 +- packages/adapters/claude-local/CHANGELOG.md | 11 ++++++++++ packages/adapters/claude-local/package.json | 2 +- packages/adapters/codex-local/CHANGELOG.md | 11 ++++++++++ packages/adapters/codex-local/package.json | 2 +- packages/adapters/cursor-local/CHANGELOG.md | 11 ++++++++++ packages/adapters/cursor-local/package.json | 2 +- .../adapters/openclaw-gateway/package.json | 2 +- packages/adapters/opencode-local/CHANGELOG.md | 11 ++++++++++ packages/adapters/opencode-local/package.json | 2 +- packages/adapters/pi-local/package.json | 2 +- packages/db/CHANGELOG.md | 12 +++++++++++ packages/db/package.json | 2 +- packages/shared/CHANGELOG.md | 7 +++++++ packages/shared/package.json | 2 +- server/CHANGELOG.md | 20 ++++++++++++++++++ server/package.json | 2 +- 21 files changed, 121 insertions(+), 16 deletions(-) delete mode 100644 .changeset/add-pi-adapter-support.md diff --git a/.changeset/add-pi-adapter-support.md b/.changeset/add-pi-adapter-support.md deleted file mode 100644 index 97005a39..00000000 --- a/.changeset/add-pi-adapter-support.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@paperclipai/shared": minor ---- - -Add support for Pi local adapter in constants and onboarding UI. \ No newline at end of file diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index e72da839..6bae020a 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,5 +1,26 @@ # paperclipai +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies [6077ae6] +- Updated dependencies + - @paperclipai/shared@0.3.0 + - @paperclipai/adapter-utils@0.3.0 + - @paperclipai/adapter-claude-local@0.3.0 + - @paperclipai/adapter-codex-local@0.3.0 + - @paperclipai/adapter-cursor-local@0.3.0 + - @paperclipai/adapter-openclaw-gateway@0.3.0 + - @paperclipai/adapter-opencode-local@0.3.0 + - @paperclipai/adapter-pi-local@0.3.0 + - @paperclipai/db@0.3.0 + - @paperclipai/server@0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/cli/package.json b/cli/package.json index 9670d997..21de193a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "paperclipai", - "version": "0.2.7", + "version": "0.3.0", "description": "Paperclip CLI — orchestrate AI agent teams to run a business", "type": "module", "bin": { diff --git a/packages/adapter-utils/CHANGELOG.md b/packages/adapter-utils/CHANGELOG.md index 6fbad4b9..dd4c015b 100644 --- a/packages/adapter-utils/CHANGELOG.md +++ b/packages/adapter-utils/CHANGELOG.md @@ -1,5 +1,11 @@ # @paperclipai/adapter-utils +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json index 118eb895..4b264bf4 100644 --- a/packages/adapter-utils/package.json +++ b/packages/adapter-utils/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-utils", - "version": "0.2.7", + "version": "0.3.0", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/claude-local/CHANGELOG.md b/packages/adapters/claude-local/CHANGELOG.md index 63bcdc4d..ac3bcac5 100644 --- a/packages/adapters/claude-local/CHANGELOG.md +++ b/packages/adapters/claude-local/CHANGELOG.md @@ -1,5 +1,16 @@ # @paperclipai/adapter-claude-local +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies + - @paperclipai/adapter-utils@0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index c999013d..f73390b7 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-claude-local", - "version": "0.2.7", + "version": "0.3.0", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/codex-local/CHANGELOG.md b/packages/adapters/codex-local/CHANGELOG.md index dd1bc70e..8a4e2d11 100644 --- a/packages/adapters/codex-local/CHANGELOG.md +++ b/packages/adapters/codex-local/CHANGELOG.md @@ -1,5 +1,16 @@ # @paperclipai/adapter-codex-local +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies + - @paperclipai/adapter-utils@0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/adapters/codex-local/package.json b/packages/adapters/codex-local/package.json index e6853aa7..81801045 100644 --- a/packages/adapters/codex-local/package.json +++ b/packages/adapters/codex-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-codex-local", - "version": "0.2.7", + "version": "0.3.0", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/cursor-local/CHANGELOG.md b/packages/adapters/cursor-local/CHANGELOG.md index d0147ff1..ae97efac 100644 --- a/packages/adapters/cursor-local/CHANGELOG.md +++ b/packages/adapters/cursor-local/CHANGELOG.md @@ -1,5 +1,16 @@ # @paperclipai/adapter-cursor-local +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies + - @paperclipai/adapter-utils@0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/adapters/cursor-local/package.json b/packages/adapters/cursor-local/package.json index 4ef66052..67434641 100644 --- a/packages/adapters/cursor-local/package.json +++ b/packages/adapters/cursor-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-cursor-local", - "version": "0.2.7", + "version": "0.3.0", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/openclaw-gateway/package.json b/packages/adapters/openclaw-gateway/package.json index 0999b220..c81ee740 100644 --- a/packages/adapters/openclaw-gateway/package.json +++ b/packages/adapters/openclaw-gateway/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-openclaw-gateway", - "version": "0.2.7", + "version": "0.3.0", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/opencode-local/CHANGELOG.md b/packages/adapters/opencode-local/CHANGELOG.md index ef07f9bf..904b21de 100644 --- a/packages/adapters/opencode-local/CHANGELOG.md +++ b/packages/adapters/opencode-local/CHANGELOG.md @@ -1,5 +1,16 @@ # @paperclipai/adapter-opencode-local +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies + - @paperclipai/adapter-utils@0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/adapters/opencode-local/package.json b/packages/adapters/opencode-local/package.json index 7c6b48a3..cf2d078a 100644 --- a/packages/adapters/opencode-local/package.json +++ b/packages/adapters/opencode-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-opencode-local", - "version": "0.2.7", + "version": "0.3.0", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/pi-local/package.json b/packages/adapters/pi-local/package.json index 1184c1ca..442d83d2 100644 --- a/packages/adapters/pi-local/package.json +++ b/packages/adapters/pi-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-pi-local", - "version": "0.1.0", + "version": "0.3.0", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/db/CHANGELOG.md b/packages/db/CHANGELOG.md index 824dd87c..077cb652 100644 --- a/packages/db/CHANGELOG.md +++ b/packages/db/CHANGELOG.md @@ -1,5 +1,17 @@ # @paperclipai/db +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies [6077ae6] +- Updated dependencies + - @paperclipai/shared@0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/db/package.json b/packages/db/package.json index 0a0b4521..1dae4bde 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/db", - "version": "0.2.7", + "version": "0.3.0", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md index 4d28fa0a..492cee6f 100644 --- a/packages/shared/CHANGELOG.md +++ b/packages/shared/CHANGELOG.md @@ -1,5 +1,12 @@ # @paperclipai/shared +## 0.3.0 + +### Minor Changes + +- 6077ae6: Add support for Pi local adapter in constants and onboarding UI. +- Stable release preparation for 0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/shared/package.json b/packages/shared/package.json index fe12dc6a..33452f67 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/shared", - "version": "0.2.7", + "version": "0.3.0", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/server/CHANGELOG.md b/server/CHANGELOG.md index 889647b4..7749b094 100644 --- a/server/CHANGELOG.md +++ b/server/CHANGELOG.md @@ -1,5 +1,25 @@ # @paperclipai/server +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies [6077ae6] +- Updated dependencies + - @paperclipai/shared@0.3.0 + - @paperclipai/adapter-utils@0.3.0 + - @paperclipai/adapter-claude-local@0.3.0 + - @paperclipai/adapter-codex-local@0.3.0 + - @paperclipai/adapter-cursor-local@0.3.0 + - @paperclipai/adapter-openclaw-gateway@0.3.0 + - @paperclipai/adapter-opencode-local@0.3.0 + - @paperclipai/adapter-pi-local@0.3.0 + - @paperclipai/db@0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/server/package.json b/server/package.json index 8c442e25..aeb09944 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/server", - "version": "0.2.7", + "version": "0.3.0", "type": "module", "exports": { ".": "./src/index.ts" From cbbf695c35e56bfd4b12b01a15e1e8062f883964 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 16:43:53 -0500 Subject: [PATCH 063/874] release files --- .../adapters/openclaw-gateway/CHANGELOG.md | 12 ++++++++ packages/adapters/pi-local/CHANGELOG.md | 12 ++++++++ scripts/create-github-release.sh | 17 +++++++---- scripts/release-lib.sh | 29 +++++++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 packages/adapters/openclaw-gateway/CHANGELOG.md create mode 100644 packages/adapters/pi-local/CHANGELOG.md diff --git a/packages/adapters/openclaw-gateway/CHANGELOG.md b/packages/adapters/openclaw-gateway/CHANGELOG.md new file mode 100644 index 00000000..8b6357e3 --- /dev/null +++ b/packages/adapters/openclaw-gateway/CHANGELOG.md @@ -0,0 +1,12 @@ +# @paperclipai/adapter-openclaw-gateway + +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies + - @paperclipai/adapter-utils@0.3.0 diff --git a/packages/adapters/pi-local/CHANGELOG.md b/packages/adapters/pi-local/CHANGELOG.md new file mode 100644 index 00000000..f7297faa --- /dev/null +++ b/packages/adapters/pi-local/CHANGELOG.md @@ -0,0 +1,12 @@ +# @paperclipai/adapter-pi-local + +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies + - @paperclipai/adapter-utils@0.3.0 diff --git a/scripts/create-github-release.sh b/scripts/create-github-release.sh index ae82755a..fa80852d 100755 --- a/scripts/create-github-release.sh +++ b/scripts/create-github-release.sh @@ -19,6 +19,7 @@ Examples: Notes: - Run this after pushing the stable release branch and tag. + - Defaults to git remote public-gh. - If the release already exists, this script updates its title and notes. EOF } @@ -53,13 +54,19 @@ fi tag="v$version" notes_file="$REPO_ROOT/releases/${tag}.md" +PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}" PUBLISH_REMOTE="$(resolve_release_remote)" - if ! command -v gh >/dev/null 2>&1; then echo "Error: gh CLI is required to create GitHub releases." >&2 exit 1 fi +GITHUB_REPO="$(github_repo_from_remote "$PUBLISH_REMOTE" || true)" +if [ -z "$GITHUB_REPO" ]; then + echo "Error: could not determine GitHub repository from remote $PUBLISH_REMOTE." >&2 + exit 1 +fi + if [ ! -f "$notes_file" ]; then echo "Error: release notes file not found at $notes_file." >&2 exit 1 @@ -71,7 +78,7 @@ if ! git -C "$REPO_ROOT" rev-parse "$tag" >/dev/null 2>&1; then fi if [ "$dry_run" = true ]; then - echo "[dry-run] gh release create $tag --title $tag --notes-file $notes_file" + echo "[dry-run] gh release create $tag -R $GITHUB_REPO --title $tag --notes-file $notes_file" exit 0 fi @@ -80,10 +87,10 @@ if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags "$PUBLISH_REMOTE" "refs/ta exit 1 fi -if gh release view "$tag" >/dev/null 2>&1; then - gh release edit "$tag" --title "$tag" --notes-file "$notes_file" +if gh release view "$tag" -R "$GITHUB_REPO" >/dev/null 2>&1; then + gh release edit "$tag" -R "$GITHUB_REPO" --title "$tag" --notes-file "$notes_file" echo "Updated GitHub Release $tag" else - gh release create "$tag" --title "$tag" --notes-file "$notes_file" + gh release create "$tag" -R "$GITHUB_REPO" --title "$tag" --notes-file "$notes_file" echo "Created GitHub Release $tag" fi diff --git a/scripts/release-lib.sh b/scripts/release-lib.sh index 0247136e..d2a33526 100644 --- a/scripts/release-lib.sh +++ b/scripts/release-lib.sh @@ -21,6 +21,35 @@ git_remote_exists() { git -C "$REPO_ROOT" remote get-url "$1" >/dev/null 2>&1 } +github_repo_from_remote() { + local remote_url + + remote_url="$(git -C "$REPO_ROOT" remote get-url "$1" 2>/dev/null || true)" + [ -n "$remote_url" ] || return 1 + + remote_url="${remote_url%.git}" + remote_url="${remote_url#ssh://}" + + node - "$remote_url" <<'NODE' +const remoteUrl = process.argv[2]; + +const patterns = [ + /^https?:\/\/github\.com\/([^/]+\/[^/]+)$/, + /^git@github\.com:([^/]+\/[^/]+)$/, + /^[^:]+:([^/]+\/[^/]+)$/ +]; + +for (const pattern of patterns) { + const match = remoteUrl.match(pattern); + if (!match) continue; + process.stdout.write(match[1]); + process.exit(0); +} + +process.exit(1); +NODE +} + resolve_release_remote() { local remote="${RELEASE_REMOTE:-${PUBLISH_REMOTE:-}}" From fbf9d5714f16891d9ec8a3842da8ff4e04776248 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 17:01:45 -0500 Subject: [PATCH 064/874] feat: add pr-report skill --- skills/pr-report/SKILL.md | 202 +++++++++ .../pr-report/assets/html-report-starter.html | 426 ++++++++++++++++++ skills/pr-report/references/style-guide.md | 149 ++++++ 3 files changed, 777 insertions(+) create mode 100644 skills/pr-report/SKILL.md create mode 100644 skills/pr-report/assets/html-report-starter.html create mode 100644 skills/pr-report/references/style-guide.md diff --git a/skills/pr-report/SKILL.md b/skills/pr-report/SKILL.md new file mode 100644 index 00000000..5064b67c --- /dev/null +++ b/skills/pr-report/SKILL.md @@ -0,0 +1,202 @@ +--- +name: pr-report +description: > + Review a pull request or contribution deeply, explain it tutorial-style for a + maintainer, and produce a polished report artifact such as HTML or Markdown. + Use when asked to analyze a PR, explain a contributor's design decisions, + compare it with similar systems, or prepare a merge recommendation. +--- + +# PR Report Skill + +Produce a maintainer-grade review of a PR, branch, or large contribution. + +Default posture: + +- understand the change before judging it +- explain the system as built, not just the diff +- separate architectural problems from product-scope objections +- make a concrete recommendation, not a vague impression + +## When to Use + +Use this skill when the user asks for things like: + +- "review this PR deeply" +- "explain this contribution to me" +- "make me a report or webpage for this PR" +- "compare this design to similar systems" +- "should I merge this?" + +## Outputs + +Common outputs: + +- standalone HTML report in `tmp/reports/...` +- Markdown report in `report/` or another requested folder +- short maintainer summary in chat + +If the user asks for a webpage, build a polished standalone HTML artifact with +clear sections and readable visual hierarchy. + +Resources bundled with this skill: + +- `references/style-guide.md` for visual direction and report presentation rules +- `assets/html-report-starter.html` for a reusable standalone HTML/CSS starter + +## Workflow + +### 1. Acquire and frame the target + +Work from local code when possible, not just the GitHub PR page. + +Gather: + +- target branch or worktree +- diff size and changed subsystems +- relevant repo docs, specs, and invariants +- contributor intent if it is documented in PR text or design docs + +Start by answering: what is this change *trying* to become? + +### 2. Build a mental model of the system + +Do not stop at file-by-file notes. Reconstruct the design: + +- what new runtime or contract exists +- which layers changed: db, shared types, server, UI, CLI, docs +- lifecycle: install, startup, execution, UI, failure, disablement +- trust boundary: what code runs where, under what authority + +For large contributions, include a tutorial-style section that teaches the +system from first principles. + +### 3. Review like a maintainer + +Findings come first. Order by severity. + +Prioritize: + +- behavioral regressions +- trust or security gaps +- misleading abstractions +- lifecycle and operational risks +- coupling that will be hard to unwind +- missing tests or unverifiable claims + +Always cite concrete file references when possible. + +### 4. Distinguish the objection type + +Be explicit about whether a concern is: + +- product direction +- architecture +- implementation quality +- rollout strategy +- documentation honesty + +Do not hide an architectural objection inside a scope objection. + +### 5. Compare to external precedents when needed + +If the contribution introduces a framework or platform concept, compare it to +similar open-source systems. + +When comparing: + +- prefer official docs or source +- focus on extension boundaries, context passing, trust model, and UI ownership +- extract lessons, not just similarities + +Good comparison questions: + +- Who owns lifecycle? +- Who owns UI composition? +- Is context explicit or ambient? +- Are plugins trusted code or sandboxed code? +- Are extension points named and typed? + +### 6. Make the recommendation actionable + +Do not stop at "merge" or "do not merge." + +Choose one: + +- merge as-is +- merge after specific redesign +- salvage specific pieces +- keep as design research + +If rejecting or narrowing, say what should be kept. + +Useful recommendation buckets: + +- keep the protocol/type model +- redesign the UI boundary +- narrow the initial surface area +- defer third-party execution +- ship a host-owned extension-point model first + +### 7. Build the artifact + +Suggested report structure: + +1. Executive summary +2. What the PR actually adds +3. Tutorial: how the system works +4. Strengths +5. Main findings +6. Comparisons +7. Recommendation + +For HTML reports: + +- use intentional typography and color +- make navigation easy for long reports +- favor strong section headings and small reference labels +- avoid generic dashboard styling + +Before building from scratch, read `references/style-guide.md`. +If a fast polished starter is helpful, begin from `assets/html-report-starter.html` +and replace the placeholder content with the actual report. + +### 8. Verify before handoff + +Check: + +- artifact path exists +- findings still match the actual code +- any requested forbidden strings are absent from generated output +- if tests were not run, say so explicitly + +## Review Heuristics + +### Plugin and platform work + +Watch closely for: + +- docs claiming sandboxing while runtime executes trusted host processes +- module-global state used to smuggle React context +- hidden dependence on render order +- plugins reaching into host internals instead of using explicit APIs +- "capabilities" that are really policy labels on top of fully trusted code + +### Good signs + +- typed contracts shared across layers +- explicit extension points +- host-owned lifecycle +- honest trust model +- narrow first rollout with room to grow + +## Final Response + +In chat, summarize: + +- where the report is +- your overall call +- the top one or two reasons +- whether verification or tests were skipped + +Keep the chat summary shorter than the report itself. diff --git a/skills/pr-report/assets/html-report-starter.html b/skills/pr-report/assets/html-report-starter.html new file mode 100644 index 00000000..be6f0550 --- /dev/null +++ b/skills/pr-report/assets/html-report-starter.html @@ -0,0 +1,426 @@ + + + + + + PR Report Starter + + + + + + +
+ + +
+
+
Executive Summary
+

Use the hero for the clearest one-line judgment.

+

+ Replace this with the short explanation of what the contribution does, why it matters, + and what the core maintainer question is. +

+
+ Strength + Tradeoff + Risk +
+
+
+
Overall Call
+
Placeholder
+
+
+
Main Concern
+
Placeholder
+
+
+
Best Part
+
Placeholder
+
+
+
Weakest Part
+
Placeholder
+
+
+
+ Use this block for the thesis, a sharp takeaway, or a key cited point. +
+
+ +
+

Tutorial Section

+
+
+

Concept Card

+

Use cards for mental models, subsystems, or comparison slices.

+
path/to/file.ts:10
+
+
+

Second Card

+

Keep cards fairly dense. This template is about style, not fixed structure.

+
path/to/file.ts:20
+
+
+
+ +
+

Findings

+
+
High
+

Finding Title

+

Use findings for the sharpest judgment calls and risks.

+
path/to/file.ts:30
+
+
+ +
+

Recommendation

+
+
+

Path Forward

+

Use this area for merge guidance, salvage plan, or rollout advice.

+
+
+

What To Keep

+

Call out the parts worth preserving even if the whole proposal should not land.

+
+
+
+
+
+ + diff --git a/skills/pr-report/references/style-guide.md b/skills/pr-report/references/style-guide.md new file mode 100644 index 00000000..35158d1a --- /dev/null +++ b/skills/pr-report/references/style-guide.md @@ -0,0 +1,149 @@ +# PR Report Style Guide + +Use this guide when the user wants a report artifact, especially a webpage. + +## Goal + +Make the report feel like an editorial review, not an internal admin dashboard. +The page should make a long technical argument easy to scan without looking +generic or overdesigned. + +## Visual Direction + +Preferred tone: + +- editorial +- warm +- serious +- high-contrast +- handcrafted, not corporate SaaS + +Avoid: + +- default app-shell layouts +- purple gradients on white +- generic card dashboards +- cramped pages with weak hierarchy +- novelty fonts that hurt readability + +## Typography + +Recommended pattern: + +- one expressive serif or display face for major headings +- one sturdy sans-serif for body copy and UI labels + +Good combinations: + +- Newsreader + IBM Plex Sans +- Source Serif 4 + Instrument Sans +- Fraunces + Public Sans +- Libre Baskerville + Work Sans + +Rules: + +- headings should feel deliberate and large +- body copy should stay comfortable for long reading +- reference labels and badges should use smaller dense sans text + +## Layout + +Recommended structure: + +- a sticky side or top navigation for long reports +- one strong hero summary at the top +- panel or paper-like sections for each major topic +- multi-column card grids for comparisons and strengths +- single-column body text for findings and recommendations + +Use generous spacing. Long-form technical reports need breathing room. + +## Color + +Prefer muted paper-like backgrounds with one warm accent and one cool counterweight. + +Suggested token categories: + +- `--bg` +- `--paper` +- `--ink` +- `--muted` +- `--line` +- `--accent` +- `--good` +- `--warn` +- `--bad` + +The accent should highlight navigation, badges, and important labels. Do not +let accent colors dominate body text. + +## Useful UI Elements + +Include small reusable styles for: + +- summary metrics +- badges +- quotes or callouts +- finding cards +- severity labels +- reference labels +- comparison cards +- responsive two-column sections + +## Motion + +Keep motion restrained. + +Good: + +- soft fade/slide-in on first load +- hover response on nav items or cards + +Bad: + +- constant animation +- floating blobs +- decorative motion with no reading benefit + +## Content Presentation + +Even when the user wants design polish, clarity stays primary. + +Good structure for long reports: + +1. executive summary +2. what changed +3. tutorial explanation +4. strengths +5. findings +6. comparisons +7. recommendation + +The exact headings can change. The important thing is to separate explanation +from judgment. + +## References + +Reference labels should be visually quiet but easy to spot. + +Good pattern: + +- small muted text +- monospace or compact sans +- keep them close to the paragraph they support + +## Starter Usage + +If you need a fast polished base, start from: + +- `assets/html-report-starter.html` + +Customize: + +- fonts +- color tokens +- hero copy +- section ordering +- card density + +Do not preserve the placeholder sections if they do not fit the actual report. From 9248881d42d02764751c7e9ca14afd26511a187e Mon Sep 17 00:00:00 2001 From: Jayakrishnan Date: Tue, 10 Mar 2026 12:01:46 +0000 Subject: [PATCH 065/874] fix(adapter-utils): strip Claude Code env vars from child processes When the Paperclip server is started from within a Claude Code session (e.g. `npx paperclipai run` in a Claude Code terminal), the `CLAUDECODE` and related env vars (`CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_SESSION`, `CLAUDE_CODE_PARENT_SESSION`) leak into `process.env`. Since `runChildProcess()` spreads `process.env` into the child environment, every spawned `claude` CLI process inherits these vars and immediately exits with: "Claude Code cannot be launched inside another Claude Code session." This is particularly disruptive for the `claude-local` adapter, where every agent run spawns a `claude` child process. A single contaminated server start (or cron job that inherits the env) silently breaks all agent executions until the server is restarted in a clean environment. The fix deletes the four known Claude Code nesting-guard env vars from the merged environment before passing it to `spawn()`. Co-Authored-By: Claude Opus 4.6 --- packages/adapter-utils/src/server-utils.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 3d273cd9..8e28fbff 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -272,7 +272,19 @@ export async function runChildProcess( const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg)); return new Promise((resolve, reject) => { - const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env }); + const rawMerged: NodeJS.ProcessEnv = { ...process.env, ...opts.env }; + + // Strip Claude Code nesting-guard env vars so spawned `claude` processes + // don't refuse to start with "cannot be launched inside another session". + // These vars leak in when the Paperclip server itself is started from + // within a Claude Code session (e.g. `npx paperclipai run` in a terminal + // owned by Claude Code) or when cron inherits a contaminated shell env. + delete rawMerged.CLAUDECODE; + delete rawMerged.CLAUDE_CODE_ENTRYPOINT; + delete rawMerged.CLAUDE_CODE_SESSION; + delete rawMerged.CLAUDE_CODE_PARENT_SESSION; + + const mergedEnv = ensurePathInEnv(rawMerged); void resolveSpawnTarget(command, args, opts.cwd, mergedEnv) .then((target) => { const child = spawn(target.command, target.args, { From 1a53567cb6195a34f51aeb44979fe2f9aa7e5b9e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:24:48 -0500 Subject: [PATCH 066/874] Apply suggestions from code review Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- packages/adapter-utils/src/server-utils.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 8e28fbff..2b9de31f 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -279,10 +279,15 @@ export async function runChildProcess( // These vars leak in when the Paperclip server itself is started from // within a Claude Code session (e.g. `npx paperclipai run` in a terminal // owned by Claude Code) or when cron inherits a contaminated shell env. - delete rawMerged.CLAUDECODE; - delete rawMerged.CLAUDE_CODE_ENTRYPOINT; - delete rawMerged.CLAUDE_CODE_SESSION; - delete rawMerged.CLAUDE_CODE_PARENT_SESSION; + const CLAUDE_CODE_NESTING_VARS = [ + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_SESSION", + "CLAUDE_CODE_PARENT_SESSION", + ] as const; + for (const key of CLAUDE_CODE_NESTING_VARS) { + delete rawMerged[key]; + } const mergedEnv = ensurePathInEnv(rawMerged); void resolveSpawnTarget(command, args, opts.cwd, mergedEnv) From f6f5fee200ae79fdc62683664eb28a23dd5bcede Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Mar 2026 15:54:31 +0200 Subject: [PATCH 067/874] fix: wire parentId query filter into issues list endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parentId parameter on GET /api/companies/:companyId/issues was silently ignored — the filter was never extracted from the query string, never passed to the service layer, and the IssueFilters type did not include it. All other filters (status, assigneeAgentId, projectId, etc.) worked correctly. This caused subtask lookups to return every issue in the company instead of only children of the specified parent. Changes: - Add parentId to IssueFilters interface - Add eq(issues.parentId, filters.parentId) condition in list() - Extract parentId from req.query in the route handler Fixes: LAS-101 --- server/src/routes/issues.ts | 1 + server/src/services/issues.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index e4035dfc..c5c4e29d 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -230,6 +230,7 @@ export function issueRoutes(db: Db, storage: StorageService) { touchedByUserId, unreadForUserId, projectId: req.query.projectId as string | undefined, + parentId: req.query.parentId as string | undefined, labelId: req.query.labelId as string | undefined, q: req.query.q as string | undefined, }); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index cb258e23..8f34be18 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -53,6 +53,7 @@ export interface IssueFilters { touchedByUserId?: string; unreadForUserId?: string; projectId?: string; + parentId?: string; labelId?: string; q?: string; } @@ -458,6 +459,7 @@ export function issueService(db: Db) { conditions.push(unreadForUserCondition(companyId, unreadForUserId)); } if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); + if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId)); if (filters?.labelId) { const labeledIssueIds = await db .select({ issueId: issueLabels.issueId }) From dec02225f19d9114246629793e1880575a771dc2 Mon Sep 17 00:00:00 2001 From: Subhendu Kundu Date: Tue, 10 Mar 2026 19:40:22 +0530 Subject: [PATCH 068/874] feat: make attachment content types configurable via env var Add PAPERCLIP_ALLOWED_ATTACHMENT_TYPES env var to configure allowed MIME types for issue attachments and asset uploads. Supports exact types (application/pdf) and wildcard patterns (image/*, text/*). Falls back to the existing image-only defaults when the env var is unset, preserving backward compatibility. - Extract shared module `attachment-types.ts` with `isAllowedContentType()` and `matchesContentType()` (pure, testable) - Update `routes/issues.ts` and `routes/assets.ts` to use shared module - Add unit tests for parsing and wildcard matching Closes #487 --- server/src/__tests__/attachment-types.test.ts | 89 +++++++++++++++++++ server/src/attachment-types.ts | 67 ++++++++++++++ server/src/routes/assets.ts | 16 +--- server/src/routes/issues.ts | 12 +-- 4 files changed, 162 insertions(+), 22 deletions(-) create mode 100644 server/src/__tests__/attachment-types.test.ts create mode 100644 server/src/attachment-types.ts diff --git a/server/src/__tests__/attachment-types.test.ts b/server/src/__tests__/attachment-types.test.ts new file mode 100644 index 00000000..af0a58b3 --- /dev/null +++ b/server/src/__tests__/attachment-types.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { + parseAllowedTypes, + matchesContentType, + DEFAULT_ALLOWED_TYPES, +} from "../attachment-types.js"; + +describe("parseAllowedTypes", () => { + it("returns default image types when input is undefined", () => { + expect(parseAllowedTypes(undefined)).toEqual([...DEFAULT_ALLOWED_TYPES]); + }); + + it("returns default image types when input is empty string", () => { + expect(parseAllowedTypes("")).toEqual([...DEFAULT_ALLOWED_TYPES]); + }); + + it("parses comma-separated types", () => { + expect(parseAllowedTypes("image/*,application/pdf")).toEqual([ + "image/*", + "application/pdf", + ]); + }); + + it("trims whitespace", () => { + expect(parseAllowedTypes(" image/png , application/pdf ")).toEqual([ + "image/png", + "application/pdf", + ]); + }); + + it("lowercases entries", () => { + expect(parseAllowedTypes("Application/PDF")).toEqual(["application/pdf"]); + }); + + it("filters empty segments", () => { + expect(parseAllowedTypes("image/png,,application/pdf,")).toEqual([ + "image/png", + "application/pdf", + ]); + }); +}); + +describe("matchesContentType", () => { + it("matches exact types", () => { + const patterns = ["application/pdf", "image/png"]; + expect(matchesContentType("application/pdf", patterns)).toBe(true); + expect(matchesContentType("image/png", patterns)).toBe(true); + expect(matchesContentType("text/plain", patterns)).toBe(false); + }); + + it("matches /* wildcard patterns", () => { + const patterns = ["image/*"]; + expect(matchesContentType("image/png", patterns)).toBe(true); + expect(matchesContentType("image/jpeg", patterns)).toBe(true); + expect(matchesContentType("image/svg+xml", patterns)).toBe(true); + expect(matchesContentType("application/pdf", patterns)).toBe(false); + }); + + it("matches .* wildcard patterns", () => { + const patterns = ["application/vnd.openxmlformats-officedocument.*"]; + expect( + matchesContentType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + patterns, + ), + ).toBe(true); + expect( + matchesContentType( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + patterns, + ), + ).toBe(true); + expect(matchesContentType("application/pdf", patterns)).toBe(false); + }); + + it("is case-insensitive", () => { + const patterns = ["application/pdf"]; + expect(matchesContentType("APPLICATION/PDF", patterns)).toBe(true); + expect(matchesContentType("Application/Pdf", patterns)).toBe(true); + }); + + it("combines exact and wildcard patterns", () => { + const patterns = ["image/*", "application/pdf", "text/*"]; + expect(matchesContentType("image/webp", patterns)).toBe(true); + expect(matchesContentType("application/pdf", patterns)).toBe(true); + expect(matchesContentType("text/csv", patterns)).toBe(true); + expect(matchesContentType("application/zip", patterns)).toBe(false); + }); +}); diff --git a/server/src/attachment-types.ts b/server/src/attachment-types.ts new file mode 100644 index 00000000..3f95156b --- /dev/null +++ b/server/src/attachment-types.ts @@ -0,0 +1,67 @@ +/** + * Shared attachment content-type configuration. + * + * By default only image types are allowed. Set the + * `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a + * comma-separated list of MIME types or wildcard patterns to expand the + * allowed set. + * + * Examples: + * PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf + * PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf,text/* + * + * Supported pattern syntax: + * - Exact types: "application/pdf" + * - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*" + */ + +export const DEFAULT_ALLOWED_TYPES: readonly string[] = [ + "image/png", + "image/jpeg", + "image/jpg", + "image/webp", + "image/gif", +]; + +/** + * Parse a comma-separated list of MIME type patterns into a normalised array. + * Returns the default image-only list when the input is empty or undefined. + */ +export function parseAllowedTypes(raw: string | undefined): string[] { + if (!raw) return [...DEFAULT_ALLOWED_TYPES]; + const parsed = raw + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter((s) => s.length > 0); + return parsed.length > 0 ? parsed : [...DEFAULT_ALLOWED_TYPES]; +} + +/** + * Check whether `contentType` matches any entry in `allowedPatterns`. + * + * Supports exact matches ("application/pdf") and wildcard / prefix + * patterns ("image/*", "application/vnd.openxmlformats-officedocument.*"). + */ +export function matchesContentType(contentType: string, allowedPatterns: string[]): boolean { + const ct = contentType.toLowerCase(); + return allowedPatterns.some((pattern) => { + if (pattern.endsWith("/*") || pattern.endsWith(".*")) { + return ct.startsWith(pattern.slice(0, -1)); + } + return ct === pattern; + }); +} + +// ---------- Module-level singletons read once at startup ---------- + +const allowedPatterns: string[] = parseAllowedTypes( + process.env.PAPERCLIP_ALLOWED_ATTACHMENT_TYPES, +); + +/** Convenience wrapper using the process-level allowed list. */ +export function isAllowedContentType(contentType: string): boolean { + return matchesContentType(contentType, allowedPatterns); +} + +export const MAX_ATTACHMENT_BYTES = + Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index cde29ada..4c9847fe 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -5,22 +5,14 @@ import { createAssetImageMetadataSchema } from "@paperclipai/shared"; import type { StorageService } from "../storage/types.js"; import { assetService, logActivity } from "../services/index.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; - -const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; -const ALLOWED_IMAGE_CONTENT_TYPES = new Set([ - "image/png", - "image/jpeg", - "image/jpg", - "image/webp", - "image/gif", -]); +import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; export function assetRoutes(db: Db, storage: StorageService) { const router = Router(); const svc = assetService(db); const upload = multer({ storage: multer.memoryStorage(), - limits: { fileSize: MAX_ASSET_IMAGE_BYTES, files: 1 }, + limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 }, }); async function runSingleFileUpload(req: Request, res: Response) { @@ -41,7 +33,7 @@ export function assetRoutes(db: Db, storage: StorageService) { } catch (err) { if (err instanceof multer.MulterError) { if (err.code === "LIMIT_FILE_SIZE") { - res.status(422).json({ error: `Image exceeds ${MAX_ASSET_IMAGE_BYTES} bytes` }); + res.status(422).json({ error: `Image exceeds ${MAX_ATTACHMENT_BYTES} bytes` }); return; } res.status(400).json({ error: err.message }); @@ -57,7 +49,7 @@ export function assetRoutes(db: Db, storage: StorageService) { } const contentType = (file.mimetype || "").toLowerCase(); - if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) { + if (!isAllowedContentType(contentType)) { res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` }); return; } diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index e4035dfc..8e398afc 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -26,15 +26,7 @@ import { logger } from "../middleware/logger.js"; import { forbidden, HttpError, unauthorized } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; - -const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; -const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([ - "image/png", - "image/jpeg", - "image/jpg", - "image/webp", - "image/gif", -]); +import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; export function issueRoutes(db: Db, storage: StorageService) { const router = Router(); @@ -1067,7 +1059,7 @@ export function issueRoutes(db: Db, storage: StorageService) { return; } const contentType = (file.mimetype || "").toLowerCase(); - if (!ALLOWED_ATTACHMENT_CONTENT_TYPES.has(contentType)) { + if (!isAllowedContentType(contentType)) { res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` }); return; } From 3ff07c23d213eb5b1c8a5841b55e34dd387c4981 Mon Sep 17 00:00:00 2001 From: Subhendu Kundu Date: Tue, 10 Mar 2026 19:54:42 +0530 Subject: [PATCH 069/874] Update server/src/routes/assets.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- server/src/routes/assets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index 4c9847fe..ed8d5944 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -50,7 +50,7 @@ export function assetRoutes(db: Db, storage: StorageService) { const contentType = (file.mimetype || "").toLowerCase(); if (!isAllowedContentType(contentType)) { - res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` }); + res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` }); return; } if (file.buffer.length <= 0) { From 1959badde721a07c4e51a8fa05d84602959780d9 Mon Sep 17 00:00:00 2001 From: Subhendu Kundu Date: Tue, 10 Mar 2026 20:01:08 +0530 Subject: [PATCH 070/874] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20stale=20error=20message=20and=20*=20wildcard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assets.ts: change "Image exceeds" to "File exceeds" in size-limit error - attachment-types.ts: handle plain "*" as allow-all wildcard pattern - Add test for "*" wildcard (12 tests total) --- server/src/__tests__/attachment-types.test.ts | 8 ++++++++ server/src/attachment-types.ts | 1 + server/src/routes/assets.ts | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/attachment-types.test.ts b/server/src/__tests__/attachment-types.test.ts index af0a58b3..5a430102 100644 --- a/server/src/__tests__/attachment-types.test.ts +++ b/server/src/__tests__/attachment-types.test.ts @@ -86,4 +86,12 @@ describe("matchesContentType", () => { expect(matchesContentType("text/csv", patterns)).toBe(true); expect(matchesContentType("application/zip", patterns)).toBe(false); }); + + it("handles plain * as allow-all wildcard", () => { + const patterns = ["*"]; + expect(matchesContentType("image/png", patterns)).toBe(true); + expect(matchesContentType("application/pdf", patterns)).toBe(true); + expect(matchesContentType("text/plain", patterns)).toBe(true); + expect(matchesContentType("application/zip", patterns)).toBe(true); + }); }); diff --git a/server/src/attachment-types.ts b/server/src/attachment-types.ts index 3f95156b..f9625de1 100644 --- a/server/src/attachment-types.ts +++ b/server/src/attachment-types.ts @@ -45,6 +45,7 @@ export function parseAllowedTypes(raw: string | undefined): string[] { export function matchesContentType(contentType: string, allowedPatterns: string[]): boolean { const ct = contentType.toLowerCase(); return allowedPatterns.some((pattern) => { + if (pattern === "*") return true; if (pattern.endsWith("/*") || pattern.endsWith(".*")) { return ct.startsWith(pattern.slice(0, -1)); } diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index ed8d5944..bd2f154d 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -33,7 +33,7 @@ export function assetRoutes(db: Db, storage: StorageService) { } catch (err) { if (err instanceof multer.MulterError) { if (err.code === "LIMIT_FILE_SIZE") { - res.status(422).json({ error: `Image exceeds ${MAX_ATTACHMENT_BYTES} bytes` }); + res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` }); return; } res.status(400).json({ error: err.message }); From 0704854926fcebf512933ff741fc0788660a4381 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 10:08:13 -0500 Subject: [PATCH 071/874] Add worktree init CLI for isolated development instances --- cli/package.json | 1 + cli/src/__tests__/worktree.test.ts | 110 +++++++++ cli/src/commands/worktree-lib.ts | 172 +++++++++++++ cli/src/commands/worktree.ts | 384 +++++++++++++++++++++++++++++ cli/src/config/env.ts | 36 ++- cli/src/index.ts | 4 + doc/DEVELOPING.md | 36 +++ packages/db/src/backup-lib.ts | 22 +- packages/db/src/index.ts | 2 + 9 files changed, 760 insertions(+), 7 deletions(-) create mode 100644 cli/src/__tests__/worktree.test.ts create mode 100644 cli/src/commands/worktree-lib.ts create mode 100644 cli/src/commands/worktree.ts diff --git a/cli/package.json b/cli/package.json index 21de193a..24a8bf66 100644 --- a/cli/package.json +++ b/cli/package.json @@ -47,6 +47,7 @@ "drizzle-orm": "0.38.4", "dotenv": "^17.0.1", "commander": "^13.1.0", + "embedded-postgres": "^18.1.0-beta.16", "picocolors": "^1.1.1" }, "devDependencies": { diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts new file mode 100644 index 00000000..f393c10c --- /dev/null +++ b/cli/src/__tests__/worktree.test.ts @@ -0,0 +1,110 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildWorktreeConfig, + buildWorktreeEnvEntries, + formatShellExports, + resolveWorktreeLocalPaths, + rewriteLocalUrlPort, + sanitizeWorktreeInstanceId, +} from "../commands/worktree-lib.js"; +import type { PaperclipConfig } from "../config/schema.js"; + +function buildSourceConfig(): PaperclipConfig { + return { + $meta: { + version: 1, + updatedAt: "2026-03-09T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: "/tmp/main/db", + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: "/tmp/main/backups", + }, + }, + logging: { + mode: "file", + logDir: "/tmp/main/logs", + }, + server: { + deploymentMode: "authenticated", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: ["localhost"], + serveUi: true, + }, + auth: { + baseUrlMode: "explicit", + publicBaseUrl: "http://127.0.0.1:3100", + disableSignUp: false, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: "/tmp/main/storage", + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: "/tmp/main/secrets/master.key", + }, + }, + }; +} + +describe("worktree helpers", () => { + it("sanitizes instance ids", () => { + expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support"); + expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree"); + }); + + it("rewrites loopback auth URLs to the new port only", () => { + expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/"); + expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example"); + }); + + it("builds isolated config and env paths for a worktree", () => { + const paths = resolveWorktreeLocalPaths({ + cwd: "/tmp/paperclip-feature", + homeDir: "/tmp/paperclip-worktrees", + instanceId: "feature-worktree-support", + }); + const config = buildWorktreeConfig({ + sourceConfig: buildSourceConfig(), + paths, + serverPort: 3110, + databasePort: 54339, + now: new Date("2026-03-09T12:00:00.000Z"), + }); + + expect(config.database.embeddedPostgresDataDir).toBe( + path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"), + ); + expect(config.database.embeddedPostgresPort).toBe(54339); + expect(config.server.port).toBe(3110); + expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/"); + expect(config.storage.localDisk.baseDir).toBe( + path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"), + ); + + const env = buildWorktreeEnvEntries(paths); + expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees")); + expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support"); + expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'"); + }); +}); diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts new file mode 100644 index 00000000..bc96a700 --- /dev/null +++ b/cli/src/commands/worktree-lib.ts @@ -0,0 +1,172 @@ +import path from "node:path"; +import type { PaperclipConfig } from "../config/schema.js"; +import { expandHomePrefix } from "../config/home.js"; + +export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees"; + +export type WorktreeLocalPaths = { + cwd: string; + repoConfigDir: string; + configPath: string; + envPath: string; + homeDir: string; + instanceId: string; + instanceRoot: string; + contextPath: string; + embeddedPostgresDataDir: string; + backupDir: string; + logDir: string; + secretsKeyFilePath: string; + storageDir: string; +}; + +function nonEmpty(value: string | null | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "127.0.0.1" || value === "localhost" || value === "::1"; +} + +export function sanitizeWorktreeInstanceId(rawValue: string): string { + const trimmed = rawValue.trim().toLowerCase(); + const normalized = trimmed + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, ""); + return normalized || "worktree"; +} + +export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string): string { + return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd)); +} + +export function resolveWorktreeLocalPaths(opts: { + cwd: string; + homeDir?: string; + instanceId: string; +}): WorktreeLocalPaths { + const cwd = path.resolve(opts.cwd); + const homeDir = path.resolve(expandHomePrefix(opts.homeDir ?? DEFAULT_WORKTREE_HOME)); + const instanceRoot = path.resolve(homeDir, "instances", opts.instanceId); + const repoConfigDir = path.resolve(cwd, ".paperclip"); + return { + cwd, + repoConfigDir, + configPath: path.resolve(repoConfigDir, "config.json"), + envPath: path.resolve(repoConfigDir, ".env"), + homeDir, + instanceId: opts.instanceId, + instanceRoot, + contextPath: path.resolve(homeDir, "context.json"), + embeddedPostgresDataDir: path.resolve(instanceRoot, "db"), + backupDir: path.resolve(instanceRoot, "data", "backups"), + logDir: path.resolve(instanceRoot, "logs"), + secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"), + storageDir: path.resolve(instanceRoot, "data", "storage"), + }; +} + +export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined { + if (!rawUrl) return undefined; + try { + const parsed = new URL(rawUrl); + if (!isLoopbackHost(parsed.hostname)) return rawUrl; + parsed.port = String(port); + return parsed.toString(); + } catch { + return rawUrl; + } +} + +export function buildWorktreeConfig(input: { + sourceConfig: PaperclipConfig | null; + paths: WorktreeLocalPaths; + serverPort: number; + databasePort: number; + now?: Date; +}): PaperclipConfig { + const { sourceConfig, paths, serverPort, databasePort } = input; + const nowIso = (input.now ?? new Date()).toISOString(); + + const source = sourceConfig; + const authPublicBaseUrl = rewriteLocalUrlPort(source?.auth.publicBaseUrl, serverPort); + + return { + $meta: { + version: 1, + updatedAt: nowIso, + source: "configure", + }, + ...(source?.llm ? { llm: source.llm } : {}), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: paths.embeddedPostgresDataDir, + embeddedPostgresPort: databasePort, + backup: { + enabled: source?.database.backup.enabled ?? true, + intervalMinutes: source?.database.backup.intervalMinutes ?? 60, + retentionDays: source?.database.backup.retentionDays ?? 30, + dir: paths.backupDir, + }, + }, + logging: { + mode: source?.logging.mode ?? "file", + logDir: paths.logDir, + }, + server: { + deploymentMode: source?.server.deploymentMode ?? "local_trusted", + exposure: source?.server.exposure ?? "private", + host: source?.server.host ?? "127.0.0.1", + port: serverPort, + allowedHostnames: source?.server.allowedHostnames ?? [], + serveUi: source?.server.serveUi ?? true, + }, + auth: { + baseUrlMode: source?.auth.baseUrlMode ?? "auto", + ...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}), + disableSignUp: source?.auth.disableSignUp ?? false, + }, + storage: { + provider: source?.storage.provider ?? "local_disk", + localDisk: { + baseDir: paths.storageDir, + }, + s3: { + bucket: source?.storage.s3.bucket ?? "paperclip", + region: source?.storage.s3.region ?? "us-east-1", + endpoint: source?.storage.s3.endpoint, + prefix: source?.storage.s3.prefix ?? "", + forcePathStyle: source?.storage.s3.forcePathStyle ?? false, + }, + }, + secrets: { + provider: source?.secrets.provider ?? "local_encrypted", + strictMode: source?.secrets.strictMode ?? false, + localEncrypted: { + keyFilePath: paths.secretsKeyFilePath, + }, + }, + }; +} + +export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record { + return { + PAPERCLIP_HOME: paths.homeDir, + PAPERCLIP_INSTANCE_ID: paths.instanceId, + PAPERCLIP_CONFIG: paths.configPath, + PAPERCLIP_CONTEXT: paths.contextPath, + }; +} + +function shellEscape(value: string): string { + return `'${value.replaceAll("'", `'\"'\"'`)}'`; +} + +export function formatShellExports(entries: Record): string { + return Object.entries(entries) + .filter(([, value]) => typeof value === "string" && value.trim().length > 0) + .map(([key, value]) => `export ${key}=${shellEscape(value)}`) + .join("\n"); +} diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts new file mode 100644 index 00000000..6ccba042 --- /dev/null +++ b/cli/src/commands/worktree.ts @@ -0,0 +1,384 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { createServer } from "node:net"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { + ensurePostgresDatabase, + formatDatabaseBackupResult, + runDatabaseBackup, + runDatabaseRestore, +} from "@paperclipai/db"; +import type { Command } from "commander"; +import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js"; +import { expandHomePrefix } from "../config/home.js"; +import type { PaperclipConfig } from "../config/schema.js"; +import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; +import { printPaperclipCliBanner } from "../utils/banner.js"; +import { + buildWorktreeConfig, + buildWorktreeEnvEntries, + DEFAULT_WORKTREE_HOME, + formatShellExports, + resolveSuggestedWorktreeName, + resolveWorktreeLocalPaths, + sanitizeWorktreeInstanceId, + type WorktreeLocalPaths, +} from "./worktree-lib.js"; + +type WorktreeInitOptions = { + name?: string; + instance?: string; + home?: string; + fromConfig?: string; + fromDataDir?: string; + fromInstance?: string; + serverPort?: number; + dbPort?: number; + seed?: boolean; + force?: boolean; +}; + +type WorktreeEnvOptions = { + config?: string; + json?: boolean; +}; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +type EmbeddedPostgresHandle = { + port: number; + startedByThisProcess: boolean; + stop: () => Promise; +}; + +function nonEmpty(value: string | null | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readPidFilePort(postmasterPidFile: string): number | null { + if (!existsSync(postmasterPidFile)) return null; + try { + const lines = readFileSync(postmasterPidFile, "utf8").split("\n"); + const port = Number(lines[3]?.trim()); + return Number.isInteger(port) && port > 0 ? port : null; + } catch { + return null; + } +} + +function readRunningPostmasterPid(postmasterPidFile: string): number | null { + if (!existsSync(postmasterPidFile)) return null; + try { + const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim()); + if (!Number.isInteger(pid) || pid <= 0) return null; + process.kill(pid, 0); + return pid; + } catch { + return null; + } +} + +async function isPortAvailable(port: number): Promise { + return await new Promise((resolve) => { + const server = createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); +} + +async function findAvailablePort(preferredPort: number, reserved = new Set()): Promise { + let port = Math.max(1, Math.trunc(preferredPort)); + while (reserved.has(port) || !(await isPortAvailable(port))) { + port += 1; + } + return port; +} + +function detectGitBranchName(cwd: string): string | null { + try { + const value = execFileSync("git", ["branch", "--show-current"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return nonEmpty(value); + } catch { + return null; + } +} + +function resolveSourceConfigPath(opts: WorktreeInitOptions): string { + if (opts.fromConfig) return path.resolve(opts.fromConfig); + const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip")); + const sourceInstanceId = sanitizeWorktreeInstanceId(opts.fromInstance ?? "default"); + return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json"); +} + +function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record, portOverride?: number): string { + if (config.database.mode === "postgres") { + const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString); + if (!connectionString) { + throw new Error( + "Source instance uses postgres mode but has no connection string in config or adjacent .env.", + ); + } + return connectionString; + } + + const port = portOverride ?? config.database.embeddedPostgresPort; + return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; +} + +async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise { + const moduleName = "embedded-postgres"; + let EmbeddedPostgres: EmbeddedPostgresCtor; + try { + const mod = await import(moduleName); + EmbeddedPostgres = mod.default as EmbeddedPostgresCtor; + } catch { + throw new Error( + "Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.", + ); + } + + const postmasterPidFile = path.resolve(dataDir, "postmaster.pid"); + const runningPid = readRunningPostmasterPid(postmasterPidFile); + if (runningPid) { + return { + port: readPidFilePort(postmasterPidFile) ?? preferredPort, + startedByThisProcess: false, + stop: async () => {}, + }; + } + + const port = await findAvailablePort(preferredPort); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + }); + + if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) { + await instance.initialise(); + } + if (existsSync(postmasterPidFile)) { + rmSync(postmasterPidFile, { force: true }); + } + await instance.start(); + + return { + port, + startedByThisProcess: true, + stop: async () => { + await instance.stop(); + }, + }; +} + +async function seedWorktreeDatabase(input: { + sourceConfigPath: string; + sourceConfig: PaperclipConfig; + targetConfig: PaperclipConfig; + targetPaths: WorktreeLocalPaths; + instanceId: string; +}): Promise { + const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath); + const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile); + let sourceHandle: EmbeddedPostgresHandle | null = null; + let targetHandle: EmbeddedPostgresHandle | null = null; + + try { + if (input.sourceConfig.database.mode === "embedded-postgres") { + sourceHandle = await ensureEmbeddedPostgres( + input.sourceConfig.database.embeddedPostgresDataDir, + input.sourceConfig.database.embeddedPostgresPort, + ); + } + const sourceConnectionString = resolveSourceConnectionString( + input.sourceConfig, + sourceEnvEntries, + sourceHandle?.port, + ); + const backup = await runDatabaseBackup({ + connectionString: sourceConnectionString, + backupDir: path.resolve(input.targetPaths.backupDir, "seed"), + retentionDays: 7, + filenamePrefix: `${input.instanceId}-seed`, + includeMigrationJournal: true, + }); + + targetHandle = await ensureEmbeddedPostgres( + input.targetConfig.database.embeddedPostgresDataDir, + input.targetConfig.database.embeddedPostgresPort, + ); + + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const targetConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/paperclip`; + await runDatabaseRestore({ + connectionString: targetConnectionString, + backupFile: backup.backupFile, + }); + + return formatDatabaseBackupResult(backup); + } finally { + if (targetHandle?.startedByThisProcess) { + await targetHandle.stop(); + } + if (sourceHandle?.startedByThisProcess) { + await sourceHandle.stop(); + } + } +} + +export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise { + printPaperclipCliBanner(); + p.intro(pc.bgCyan(pc.black(" paperclipai worktree init "))); + + const cwd = process.cwd(); + const name = resolveSuggestedWorktreeName( + cwd, + opts.name ?? detectGitBranchName(cwd) ?? undefined, + ); + const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name); + const paths = resolveWorktreeLocalPaths({ + cwd, + homeDir: opts.home ?? DEFAULT_WORKTREE_HOME, + instanceId, + }); + const sourceConfigPath = resolveSourceConfigPath(opts); + const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null; + + if ((existsSync(paths.configPath) || existsSync(paths.instanceRoot)) && !opts.force) { + throw new Error( + `Worktree config already exists at ${paths.configPath} or instance data exists at ${paths.instanceRoot}. Re-run with --force to replace it.`, + ); + } + + if (opts.force) { + rmSync(paths.repoConfigDir, { recursive: true, force: true }); + rmSync(paths.instanceRoot, { recursive: true, force: true }); + } + + const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1); + const serverPort = await findAvailablePort(preferredServerPort); + const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1); + const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort])); + const targetConfig = buildWorktreeConfig({ + sourceConfig, + paths, + serverPort, + databasePort, + }); + + writeConfig(targetConfig, paths.configPath); + mergePaperclipEnvEntries(buildWorktreeEnvEntries(paths), paths.envPath); + ensureAgentJwtSecret(paths.configPath); + loadPaperclipEnvFile(paths.configPath); + + let seedSummary: string | null = null; + if (opts.seed !== false) { + if (!sourceConfig) { + throw new Error( + `Cannot seed worktree database because source config was not found at ${sourceConfigPath}. Use --no-seed or provide --from-config.`, + ); + } + const spinner = p.spinner(); + spinner.start("Seeding isolated worktree database from source instance..."); + try { + seedSummary = await seedWorktreeDatabase({ + sourceConfigPath, + sourceConfig, + targetConfig, + targetPaths: paths, + instanceId, + }); + spinner.stop("Seeded isolated worktree database."); + } catch (error) { + spinner.stop(pc.red("Failed to seed worktree database.")); + throw error; + } + } + + p.log.message(pc.dim(`Repo config: ${paths.configPath}`)); + p.log.message(pc.dim(`Repo env: ${paths.envPath}`)); + p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`)); + p.log.message(pc.dim(`Instance: ${paths.instanceId}`)); + p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`)); + if (seedSummary) { + p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`)); + } + p.outro( + pc.green( + `Worktree ready. Run Paperclip inside this repo and the CLI/server will use ${paths.instanceId} automatically.`, + ), + ); +} + +export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise { + const configPath = resolveConfigPath(opts.config); + const envPath = resolvePaperclipEnvFile(configPath); + const envEntries = readPaperclipEnvEntries(envPath); + const out = { + PAPERCLIP_CONFIG: configPath, + ...(envEntries.PAPERCLIP_HOME ? { PAPERCLIP_HOME: envEntries.PAPERCLIP_HOME } : {}), + ...(envEntries.PAPERCLIP_INSTANCE_ID ? { PAPERCLIP_INSTANCE_ID: envEntries.PAPERCLIP_INSTANCE_ID } : {}), + ...(envEntries.PAPERCLIP_CONTEXT ? { PAPERCLIP_CONTEXT: envEntries.PAPERCLIP_CONTEXT } : {}), + ...envEntries, + }; + + if (opts.json) { + console.log(JSON.stringify(out, null, 2)); + return; + } + + console.log(formatShellExports(out)); +} + +export function registerWorktreeCommands(program: Command): void { + const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers"); + + worktree + .command("init") + .description("Create repo-local config/env and an isolated instance for this worktree") + .option("--name ", "Display name used to derive the instance id") + .option("--instance ", "Explicit isolated instance id") + .option("--home ", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`) + .option("--from-config ", "Source config.json to seed from") + .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") + .option("--from-instance ", "Source instance id when deriving the source config", "default") + .option("--server-port ", "Preferred server port", (value) => Number(value)) + .option("--db-port ", "Preferred embedded Postgres port", (value) => Number(value)) + .option("--no-seed", "Skip database seeding from the source instance") + .option("--force", "Replace existing repo-local config and isolated instance data", false) + .action(worktreeInitCommand); + + worktree + .command("env") + .description("Print shell exports for the current worktree-local Paperclip instance") + .option("-c, --config ", "Path to config file") + .option("--json", "Print JSON instead of shell exports") + .action(worktreeEnvCommand); +} diff --git a/cli/src/config/env.ts b/cli/src/config/env.ts index 0ca4bcc1..4bc8f16e 100644 --- a/cli/src/config/env.ts +++ b/cli/src/config/env.ts @@ -25,13 +25,17 @@ function parseEnvFile(contents: string) { function renderEnvFile(entries: Record) { const lines = [ "# Paperclip environment variables", - "# Generated by `paperclipai onboard`", + "# Generated by Paperclip CLI commands", ...Object.entries(entries).map(([key, value]) => `${key}=${value}`), "", ]; return lines.join("\n"); } +export function resolvePaperclipEnvFile(configPath?: string): string { + return resolveEnvFilePath(configPath); +} + export function resolveAgentJwtEnvFile(configPath?: string): string { return resolveEnvFilePath(configPath); } @@ -82,13 +86,33 @@ export function ensureAgentJwtSecret(configPath?: string): { secret: string; cre } export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void { + mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath); +} + +export function readPaperclipEnvEntries(filePath = resolveEnvFilePath()): Record { + if (!fs.existsSync(filePath)) return {}; + return parseEnvFile(fs.readFileSync(filePath, "utf-8")); +} + +export function writePaperclipEnvEntries(entries: Record, filePath = resolveEnvFilePath()): void { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); - - const current = fs.existsSync(filePath) ? parseEnvFile(fs.readFileSync(filePath, "utf-8")) : {}; - current[JWT_SECRET_ENV_KEY] = secret; - - fs.writeFileSync(filePath, renderEnvFile(current), { + fs.writeFileSync(filePath, renderEnvFile(entries), { mode: 0o600, }); } + +export function mergePaperclipEnvEntries( + entries: Record, + filePath = resolveEnvFilePath(), +): Record { + const current = readPaperclipEnvEntries(filePath); + const next = { + ...current, + ...Object.fromEntries( + Object.entries(entries).filter(([, value]) => typeof value === "string" && value.trim().length > 0), + ), + }; + writePaperclipEnvEntries(next, filePath); + return next; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 9c31f5ae..19ef69f9 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -16,6 +16,8 @@ import { registerApprovalCommands } from "./commands/client/approval.js"; import { registerActivityCommands } from "./commands/client/activity.js"; import { registerDashboardCommands } from "./commands/client/dashboard.js"; import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js"; +import { loadPaperclipEnvFile } from "./config/env.js"; +import { registerWorktreeCommands } from "./commands/worktree.js"; const program = new Command(); const DATA_DIR_OPTION_HELP = @@ -33,6 +35,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => { hasConfigOption: optionNames.has("config"), hasContextOption: optionNames.has("context"), }); + loadPaperclipEnvFile(options.config); }); program @@ -132,6 +135,7 @@ registerAgentCommands(program); registerApprovalCommands(program); registerActivityCommands(program); registerDashboardCommands(program); +registerWorktreeCommands(program); const auth = program.command("auth").description("Authentication and bootstrap utilities"); diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index d3362600..9578ead2 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -124,6 +124,42 @@ When a local agent run has no resolved project/session workspace, Paperclip fall This path honors `PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` in non-default setups. +## Worktree-local Instances + +When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory. + +Instead, create a repo-local Paperclip config plus an isolated instance for the worktree: + +```sh +paperclipai worktree init +``` + +This command: + +- writes repo-local files at `.paperclip/config.json` and `.paperclip/.env` +- creates an isolated instance under `~/.paperclip-worktrees/instances//` +- picks a free app port and embedded PostgreSQL port +- by default seeds the isolated DB from your main instance via a logical SQL snapshot + +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. + +Print shell exports explicitly when needed: + +```sh +paperclipai worktree env +# or: +eval "$(paperclipai worktree env)" +``` + +Useful options: + +```sh +paperclipai worktree init --no-seed +paperclipai worktree init --from-instance default +paperclipai worktree init --from-data-dir ~/.paperclip +paperclipai worktree init --force +``` + ## Quick Health Checks In another terminal: diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 951540b1..de8c290d 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -9,6 +9,7 @@ export type RunDatabaseBackupOptions = { retentionDays: number; filenamePrefix?: string; connectTimeoutSeconds?: number; + includeMigrationJournal?: boolean; }; export type RunDatabaseBackupResult = { @@ -17,6 +18,12 @@ export type RunDatabaseBackupResult = { prunedCount: number; }; +export type RunDatabaseRestoreOptions = { + connectionString: string; + backupFile: string; + connectTimeoutSeconds?: number; +}; + function timestamp(date: Date = new Date()): string { const pad = (n: number) => String(n).padStart(2, "0"); return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; @@ -51,6 +58,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise const filenamePrefix = opts.filenamePrefix ?? "paperclip"; const retentionDays = Math.max(1, Math.trunc(opts.retentionDays)); const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); + const includeMigrationJournal = opts.includeMigrationJournal === true; const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); try { @@ -89,7 +97,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind = 'r' - AND c.relname != '__drizzle_migrations' + AND (${includeMigrationJournal}::boolean OR c.relname != '__drizzle_migrations') ORDER BY c.relname `; @@ -326,6 +334,18 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise } } +export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise { + const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); + const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); + + try { + await sql`SELECT 1`; + await sql.file(opts.backupFile).execute(); + } finally { + await sql.end(); + } +} + export function formatDatabaseBackupResult(result: RunDatabaseBackupResult): string { const size = formatBackupSize(result.sizeBytes); const pruned = result.prunedCount > 0 ? `; pruned ${result.prunedCount} old backup(s)` : ""; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3cafa7af..f280cee1 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -12,8 +12,10 @@ export { } from "./client.js"; export { runDatabaseBackup, + runDatabaseRestore, formatDatabaseBackupResult, type RunDatabaseBackupOptions, type RunDatabaseBackupResult, + type RunDatabaseRestoreOptions, } from "./backup-lib.js"; export * from "./schema/index.js"; From 4a67db6a4d1eedf5b96408e99c68b561056673f6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 07:41:01 -0500 Subject: [PATCH 072/874] Add minimal worktree seed mode --- cli/src/__tests__/worktree.test.ts | 15 ++ cli/src/commands/worktree-lib.ts | 45 ++++++ cli/src/commands/worktree.ts | 21 ++- doc/DEVELOPING.md | 10 +- packages/db/src/backup-lib.ts | 219 ++++++++++++++++++++++++----- 5 files changed, 269 insertions(+), 41 deletions(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index f393c10c..18bdabf1 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -4,6 +4,7 @@ import { buildWorktreeConfig, buildWorktreeEnvEntries, formatShellExports, + resolveWorktreeSeedPlan, resolveWorktreeLocalPaths, rewriteLocalUrlPort, sanitizeWorktreeInstanceId, @@ -107,4 +108,18 @@ describe("worktree helpers", () => { expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support"); expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'"); }); + + it("uses minimal seed mode to keep app state but drop heavy runtime history", () => { + const minimal = resolveWorktreeSeedPlan("minimal"); + const full = resolveWorktreeSeedPlan("full"); + + expect(minimal.excludedTables).toContain("heartbeat_runs"); + expect(minimal.excludedTables).toContain("heartbeat_run_events"); + expect(minimal.excludedTables).toContain("workspace_runtime_services"); + expect(minimal.excludedTables).toContain("agent_task_sessions"); + expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]); + + expect(full.excludedTables).toEqual([]); + expect(full.nullifyColumns).toEqual({}); + }); }); diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index bc96a700..63509371 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -3,6 +3,30 @@ import type { PaperclipConfig } from "../config/schema.js"; import { expandHomePrefix } from "../config/home.js"; export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees"; +export const WORKTREE_SEED_MODES = ["minimal", "full"] as const; + +export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number]; + +export type WorktreeSeedPlan = { + mode: WorktreeSeedMode; + excludedTables: string[]; + nullifyColumns: Record; +}; + +const MINIMAL_WORKTREE_EXCLUDED_TABLES = [ + "activity_log", + "agent_runtime_state", + "agent_task_sessions", + "agent_wakeup_requests", + "cost_events", + "heartbeat_run_events", + "heartbeat_runs", + "workspace_runtime_services", +]; + +const MINIMAL_WORKTREE_NULLIFIED_COLUMNS: Record = { + issues: ["checkout_run_id", "execution_run_id"], +}; export type WorktreeLocalPaths = { cwd: string; @@ -20,6 +44,27 @@ export type WorktreeLocalPaths = { storageDir: string; }; +export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode { + return (WORKTREE_SEED_MODES as readonly string[]).includes(value); +} + +export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPlan { + if (mode === "full") { + return { + mode, + excludedTables: [], + nullifyColumns: {}, + }; + } + return { + mode, + excludedTables: [...MINIMAL_WORKTREE_EXCLUDED_TABLES], + nullifyColumns: { + ...MINIMAL_WORKTREE_NULLIFIED_COLUMNS, + }, + }; +} + function nonEmpty(value: string | null | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 6ccba042..3c699dc0 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -22,9 +22,12 @@ import { buildWorktreeEnvEntries, DEFAULT_WORKTREE_HOME, formatShellExports, + isWorktreeSeedMode, resolveSuggestedWorktreeName, + resolveWorktreeSeedPlan, resolveWorktreeLocalPaths, sanitizeWorktreeInstanceId, + type WorktreeSeedMode, type WorktreeLocalPaths, } from "./worktree-lib.js"; @@ -38,6 +41,7 @@ type WorktreeInitOptions = { serverPort?: number; dbPort?: number; seed?: boolean; + seedMode?: string; force?: boolean; }; @@ -178,6 +182,8 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P password: "paperclip", port, persistent: true, + onLog: () => {}, + onError: () => {}, }); if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) { @@ -203,7 +209,9 @@ async function seedWorktreeDatabase(input: { targetConfig: PaperclipConfig; targetPaths: WorktreeLocalPaths; instanceId: string; + seedMode: WorktreeSeedMode; }): Promise { + const seedPlan = resolveWorktreeSeedPlan(input.seedMode); const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath); const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile); let sourceHandle: EmbeddedPostgresHandle | null = null; @@ -227,6 +235,8 @@ async function seedWorktreeDatabase(input: { retentionDays: 7, filenamePrefix: `${input.instanceId}-seed`, includeMigrationJournal: true, + excludeTables: seedPlan.excludedTables, + nullifyColumns: seedPlan.nullifyColumns, }); targetHandle = await ensureEmbeddedPostgres( @@ -262,6 +272,10 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise", "Source instance id when deriving the source config", "default") .option("--server-port ", "Preferred server port", (value) => Number(value)) .option("--db-port ", "Preferred embedded Postgres port", (value) => Number(value)) + .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") .option("--no-seed", "Skip database seeding from the source instance") .option("--force", "Replace existing repo-local config and isolated instance data", false) .action(worktreeInitCommand); diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 9578ead2..0ce30684 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -139,7 +139,13 @@ This command: - writes repo-local files at `.paperclip/config.json` and `.paperclip/.env` - creates an isolated instance under `~/.paperclip-worktrees/instances//` - picks a free app port and embedded PostgreSQL port -- by default seeds the isolated DB from your main instance via a logical SQL snapshot +- by default seeds the isolated DB in `minimal` mode from your main instance via a logical SQL snapshot + +Seed modes: + +- `minimal` keeps core app state like companies, projects, issues, comments, approvals, and auth state, but drops heavy operational history such as heartbeat runs, wake requests, activity logs, runtime services, and agent session state +- `full` makes a full logical clone of the source instance +- `--no-seed` creates an empty isolated instance 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. @@ -155,6 +161,8 @@ Useful options: ```sh paperclipai worktree init --no-seed +paperclipai worktree init --seed-mode minimal +paperclipai worktree init --seed-mode full paperclipai worktree init --from-instance default paperclipai worktree init --from-data-dir ~/.paperclip paperclipai worktree init --force diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index de8c290d..810703a7 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; -import { writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { readFile, writeFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; import postgres from "postgres"; export type RunDatabaseBackupOptions = { @@ -10,6 +10,8 @@ export type RunDatabaseBackupOptions = { filenamePrefix?: string; connectTimeoutSeconds?: number; includeMigrationJournal?: boolean; + excludeTables?: string[]; + nullifyColumns?: Record; }; export type RunDatabaseBackupResult = { @@ -24,6 +26,34 @@ export type RunDatabaseRestoreOptions = { connectTimeoutSeconds?: number; }; +type SequenceDefinition = { + sequence_name: string; + data_type: string; + start_value: string; + minimum_value: string; + maximum_value: string; + increment: string; + cycle_option: "YES" | "NO"; + owner_table: string | null; + owner_column: string | null; +}; + +const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900"; + +function sanitizeRestoreErrorMessage(error: unknown): string { + if (error && typeof error === "object") { + const record = error as Record; + const firstLine = typeof record.message === "string" + ? record.message.split(/\r?\n/, 1)[0]?.trim() + : ""; + const detail = typeof record.detail === "string" ? record.detail.trim() : ""; + const severity = typeof record.severity === "string" ? record.severity.trim() : ""; + const message = firstLine || detail || (error instanceof Error ? error.message : String(error)); + return severity ? `${severity}: ${message}` : message; + } + return error instanceof Error ? error.message : String(error); +} + function timestamp(date: Date = new Date()): string { const pad = (n: number) => String(n).padStart(2, "0"); return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; @@ -54,11 +84,48 @@ function formatBackupSize(sizeBytes: number): string { return `${(sizeBytes / (1024 * 1024)).toFixed(1)}M`; } +function formatSqlLiteral(value: string): string { + const sanitized = value.replace(/\u0000/g, ""); + let tag = "$paperclip$"; + while (sanitized.includes(tag)) { + tag = `$paperclip_${Math.random().toString(36).slice(2, 8)}$`; + } + return `${tag}${sanitized}${tag}`; +} + +function normalizeTableNameSet(values: string[] | undefined): Set { + return new Set( + (values ?? []) + .map((value) => value.trim()) + .filter((value) => value.length > 0), + ); +} + +function normalizeNullifyColumnMap(values: Record | undefined): Map> { + const out = new Map>(); + if (!values) return out; + for (const [tableName, columns] of Object.entries(values)) { + const normalizedTable = tableName.trim(); + if (normalizedTable.length === 0) continue; + const normalizedColumns = new Set( + columns + .map((column) => column.trim()) + .filter((column) => column.length > 0), + ); + if (normalizedColumns.size > 0) { + out.set(normalizedTable, normalizedColumns); + } + } + return out; +} + export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise { const filenamePrefix = opts.filenamePrefix ?? "paperclip"; const retentionDays = Math.max(1, Math.trunc(opts.retentionDays)); 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 }); try { @@ -66,13 +133,36 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise const lines: string[] = []; const emit = (line: string) => lines.push(line); + const emitStatement = (statement: string) => { + emit(statement); + emit(STATEMENT_BREAKPOINT); + }; + const emitStatementBoundary = () => { + emit(STATEMENT_BREAKPOINT); + }; emit("-- Paperclip database backup"); emit(`-- Created: ${new Date().toISOString()}`); emit(""); - emit("BEGIN;"); + emitStatement("BEGIN;"); + emitStatement("SET LOCAL session_replication_role = replica;"); + emitStatement("SET LOCAL client_min_messages = warning;"); emit(""); + const allTables = await sql<{ tablename: string }[]>` + SELECT c.relname AS tablename + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind = 'r' + ORDER BY c.relname + `; + const tables = allTables.filter(({ tablename }) => { + if (!includeMigrationJournal && tablename === "__drizzle_migrations") return false; + return !excludedTableNames.has(tablename); + }); + const includedTableNames = new Set(tables.map(({ tablename }) => tablename)); + // Get all enums const enums = await sql<{ typname: string; labels: string[] }[]>` SELECT t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) AS labels @@ -86,20 +176,42 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise for (const e of enums) { const labels = e.labels.map((l) => `'${l.replace(/'/g, "''")}'`).join(", "); - emit(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`); + emitStatement(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`); } if (enums.length > 0) emit(""); - // Get tables in dependency order (referenced tables first) - const tables = await sql<{ tablename: string }[]>` - SELECT c.relname AS tablename - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = 'public' - AND c.relkind = 'r' - AND (${includeMigrationJournal}::boolean OR c.relname != '__drizzle_migrations') - ORDER BY c.relname + const allSequences = await sql` + SELECT + s.sequence_name, + s.data_type, + s.start_value, + s.minimum_value, + s.maximum_value, + s.increment, + s.cycle_option, + tbl.relname AS owner_table, + attr.attname AS owner_column + FROM information_schema.sequences s + JOIN pg_class seq ON seq.relname = s.sequence_name + JOIN pg_namespace n ON n.oid = seq.relnamespace AND n.nspname = s.sequence_schema + LEFT JOIN pg_depend dep ON dep.objid = seq.oid AND dep.deptype = 'a' + LEFT JOIN pg_class tbl ON tbl.oid = dep.refobjid + LEFT JOIN pg_attribute attr ON attr.attrelid = tbl.oid AND attr.attnum = dep.refobjsubid + WHERE s.sequence_schema = 'public' + ORDER BY s.sequence_name `; + const sequences = allSequences.filter((seq) => !seq.owner_table || includedTableNames.has(seq.owner_table)); + + if (sequences.length > 0) { + emit("-- Sequences"); + for (const seq of sequences) { + emitStatement(`DROP SEQUENCE IF EXISTS "${seq.sequence_name}" CASCADE;`); + emitStatement( + `CREATE SEQUENCE "${seq.sequence_name}" AS ${seq.data_type} INCREMENT BY ${seq.increment} MINVALUE ${seq.minimum_value} MAXVALUE ${seq.maximum_value} START WITH ${seq.start_value}${seq.cycle_option === "YES" ? " CYCLE" : " NO CYCLE"};`, + ); + } + emit(""); + } // Get full CREATE TABLE DDL via column info for (const { tablename } of tables) { @@ -121,7 +233,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise `; emit(`-- Table: ${tablename}`); - emit(`DROP TABLE IF EXISTS "${tablename}" CASCADE;`); + emitStatement(`DROP TABLE IF EXISTS "${tablename}" CASCADE;`); const colDefs: string[] = []; for (const col of columns) { @@ -168,11 +280,23 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emit(`CREATE TABLE "${tablename}" (`); emit(colDefs.join(",\n")); emit(");"); + emitStatementBoundary(); + emit(""); + } + + const ownedSequences = sequences.filter((seq) => seq.owner_table && seq.owner_column); + if (ownedSequences.length > 0) { + emit("-- Sequence ownership"); + for (const seq of ownedSequences) { + emitStatement( + `ALTER SEQUENCE "${seq.sequence_name}" OWNED BY "${seq.owner_table!}"."${seq.owner_column!}";`, + ); + } emit(""); } // Foreign keys (after all tables created) - const fks = await sql<{ + const allForeignKeys = await sql<{ constraint_name: string; source_table: string; source_columns: string[]; @@ -199,13 +323,16 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise GROUP BY c.conname, src.relname, tgt.relname, c.confupdtype, c.confdeltype ORDER BY src.relname, c.conname `; + const fks = allForeignKeys.filter( + (fk) => includedTableNames.has(fk.source_table) && includedTableNames.has(fk.target_table), + ); if (fks.length > 0) { emit("-- Foreign keys"); for (const fk of fks) { const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", "); const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", "); - emit( + emitStatement( `ALTER TABLE "${fk.source_table}" ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES "${fk.target_table}" (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`, ); } @@ -213,7 +340,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise } // Unique constraints - const uniques = await sql<{ + const allUniqueConstraints = await sql<{ constraint_name: string; tablename: string; column_names: string[]; @@ -229,19 +356,20 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise GROUP BY c.conname, t.relname ORDER BY t.relname, c.conname `; + const uniques = allUniqueConstraints.filter((entry) => includedTableNames.has(entry.tablename)); if (uniques.length > 0) { emit("-- Unique constraints"); for (const u of uniques) { const cols = u.column_names.map((c) => `"${c}"`).join(", "); - emit(`ALTER TABLE "${u.tablename}" ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`); + emitStatement(`ALTER TABLE "${u.tablename}" ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`); } emit(""); } // Indexes (non-primary, non-unique-constraint) - const indexes = await sql<{ indexdef: string }[]>` - SELECT indexdef + const allIndexes = await sql<{ tablename: string; indexdef: string }[]>` + SELECT tablename, indexdef FROM pg_indexes WHERE schemaname = 'public' AND indexname NOT IN ( @@ -250,11 +378,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise ) ORDER BY tablename, indexname `; + const indexes = allIndexes.filter((entry) => includedTableNames.has(entry.tablename)); if (indexes.length > 0) { emit("-- Indexes"); for (const idx of indexes) { - emit(`${idx.indexdef};`); + emitStatement(`${idx.indexdef};`); } emit(""); } @@ -278,42 +407,38 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emit(`-- Data for: ${tablename} (${count[0]!.n} rows)`); const rows = await sql`SELECT * FROM ${sql(tablename)}`.values(); + const nullifiedColumns = nullifiedColumnsByTable.get(tablename) ?? new Set(); for (const row of rows) { - const values = row.map((val: unknown) => { + const values = row.map((rawValue: unknown, index) => { + const columnName = cols[index]?.column_name; + const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue; if (val === null || val === undefined) return "NULL"; if (typeof val === "boolean") return val ? "true" : "false"; if (typeof val === "number") return String(val); - if (val instanceof Date) return `'${val.toISOString()}'`; - if (typeof val === "object") return `'${JSON.stringify(val).replace(/'/g, "''")}'`; - return `'${String(val).replace(/'/g, "''")}'`; + if (val instanceof Date) return formatSqlLiteral(val.toISOString()); + if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val)); + return formatSqlLiteral(String(val)); }); - emit(`INSERT INTO "${tablename}" (${colNames}) VALUES (${values.join(", ")});`); + emitStatement(`INSERT INTO "${tablename}" (${colNames}) VALUES (${values.join(", ")});`); } emit(""); } // Sequence values - const sequences = await sql<{ sequence_name: string }[]>` - SELECT sequence_name - FROM information_schema.sequences - WHERE sequence_schema = 'public' - ORDER BY sequence_name - `; - if (sequences.length > 0) { emit("-- Sequence values"); for (const seq of sequences) { - const val = await sql<{ last_value: string }[]>` - SELECT last_value::text FROM ${sql(seq.sequence_name)} + const val = await sql<{ last_value: string; is_called: boolean }[]>` + SELECT last_value::text, is_called FROM ${sql(seq.sequence_name)} `; if (val[0]) { - emit(`SELECT setval('"${seq.sequence_name}"', ${val[0].last_value});`); + emitStatement(`SELECT setval('"${seq.sequence_name}"', ${val[0].last_value}, ${val[0].is_called ? "true" : "false"});`); } } emit(""); } - emit("COMMIT;"); + emitStatement("COMMIT;"); emit(""); // Write the backup file @@ -340,7 +465,25 @@ export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promi try { await sql`SELECT 1`; - await sql.file(opts.backupFile).execute(); + const contents = await readFile(opts.backupFile, "utf8"); + const statements = contents + .split(STATEMENT_BREAKPOINT) + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0); + + for (const statement of statements) { + await sql.unsafe(statement).execute(); + } + } catch (error) { + const statementPreview = typeof error === "object" && error !== null && typeof (error as Record).query === "string" + ? String((error as Record).query) + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0 && !line.startsWith("--")) + : null; + throw new Error( + `Failed to restore ${basename(opts.backupFile)}: ${sanitizeRestoreErrorMessage(error)}${statementPreview ? ` [statement: ${statementPreview.slice(0, 120)}]` : ""}`, + ); } finally { await sql.end(); } From 83738b45cd567248ec0b0461cf3a7ad06bc4fee9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 10:08:58 -0500 Subject: [PATCH 073/874] Fix worktree minimal clone startup --- cli/src/commands/worktree.ts | 2 + doc/DEVELOPING.md | 2 +- packages/db/src/backup-lib.ts | 174 +++++++++++++++++++++++----------- packages/db/src/client.ts | 16 ++-- server/src/app.ts | 7 ++ server/src/index.ts | 3 +- 6 files changed, 140 insertions(+), 64 deletions(-) diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 3c699dc0..f9363ff8 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -6,6 +6,7 @@ import { createServer } from "node:net"; import * as p from "@clack/prompts"; import pc from "picocolors"; import { + applyPendingMigrations, ensurePostgresDatabase, formatDatabaseBackupResult, runDatabaseBackup, @@ -251,6 +252,7 @@ async function seedWorktreeDatabase(input: { connectionString: targetConnectionString, backupFile: backup.backupFile, }); + await applyPendingMigrations(targetConnectionString); return formatDatabaseBackupResult(backup); } finally { diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 0ce30684..334306c2 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -143,7 +143,7 @@ This command: Seed modes: -- `minimal` keeps core app state like companies, projects, issues, comments, approvals, and auth state, but drops heavy operational history such as heartbeat runs, wake requests, activity logs, runtime services, and agent session state +- `minimal` keeps core app state like companies, projects, issues, comments, approvals, and auth state, preserves schema for all tables, but omits row data from heavy operational history such as heartbeat runs, wake requests, activity logs, runtime services, and agent session state - `full` makes a full logical clone of the source instance - `--no-seed` creates an empty isolated instance diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 810703a7..26f918c3 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -27,6 +27,7 @@ export type RunDatabaseRestoreOptions = { }; type SequenceDefinition = { + sequence_schema: string; sequence_name: string; data_type: string; start_value: string; @@ -34,10 +35,19 @@ type SequenceDefinition = { maximum_value: string; increment: string; cycle_option: "YES" | "NO"; + owner_schema: string | null; owner_table: string | null; owner_column: string | null; }; +type TableDefinition = { + schema_name: string; + tablename: string; +}; + +const DRIZZLE_SCHEMA = "drizzle"; +const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; + const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900"; function sanitizeRestoreErrorMessage(error: unknown): string { @@ -119,6 +129,18 @@ function normalizeNullifyColumnMap(values: Record | undefined) return out; } +function quoteIdentifier(value: string): string { + return `"${value.replaceAll("\"", "\"\"")}"`; +} + +function quoteQualifiedName(schemaName: string, objectName: string): string { + return `${quoteIdentifier(schemaName)}.${quoteIdentifier(objectName)}`; +} + +function tableKey(schemaName: string, tableName: string): string { + return `${schemaName}.${tableName}`; +} + export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise { const filenamePrefix = opts.filenamePrefix ?? "paperclip"; const retentionDays = Math.max(1, Math.trunc(opts.retentionDays)); @@ -149,19 +171,18 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emitStatement("SET LOCAL client_min_messages = warning;"); emit(""); - const allTables = await sql<{ tablename: string }[]>` - SELECT c.relname AS tablename - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = 'public' - AND c.relkind = 'r' - ORDER BY c.relname + const allTables = await sql` + SELECT table_schema AS schema_name, table_name AS tablename + FROM information_schema.tables + WHERE table_type = 'BASE TABLE' + AND ( + table_schema = 'public' + OR (${includeMigrationJournal}::boolean AND table_schema = ${DRIZZLE_SCHEMA} AND table_name = ${DRIZZLE_MIGRATIONS_TABLE}) + ) + ORDER BY table_schema, table_name `; - const tables = allTables.filter(({ tablename }) => { - if (!includeMigrationJournal && tablename === "__drizzle_migrations") return false; - return !excludedTableNames.has(tablename); - }); - const includedTableNames = new Set(tables.map(({ tablename }) => tablename)); + const tables = allTables; + const includedTableNames = new Set(tables.map(({ schema_name, tablename }) => tableKey(schema_name, tablename))); // Get all enums const enums = await sql<{ typname: string; labels: string[] }[]>` @@ -182,6 +203,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise const allSequences = await sql` SELECT + s.sequence_schema, s.sequence_name, s.data_type, s.start_value, @@ -189,6 +211,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise s.maximum_value, s.increment, s.cycle_option, + tblns.nspname AS owner_schema, tbl.relname AS owner_table, attr.attname AS owner_column FROM information_schema.sequences s @@ -196,25 +219,43 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise JOIN pg_namespace n ON n.oid = seq.relnamespace AND n.nspname = s.sequence_schema LEFT JOIN pg_depend dep ON dep.objid = seq.oid AND dep.deptype = 'a' LEFT JOIN pg_class tbl ON tbl.oid = dep.refobjid + LEFT JOIN pg_namespace tblns ON tblns.oid = tbl.relnamespace LEFT JOIN pg_attribute attr ON attr.attrelid = tbl.oid AND attr.attnum = dep.refobjsubid WHERE s.sequence_schema = 'public' - ORDER BY s.sequence_name + OR (${includeMigrationJournal}::boolean AND s.sequence_schema = ${DRIZZLE_SCHEMA}) + ORDER BY s.sequence_schema, s.sequence_name `; - const sequences = allSequences.filter((seq) => !seq.owner_table || includedTableNames.has(seq.owner_table)); + const sequences = allSequences.filter( + (seq) => !seq.owner_table || includedTableNames.has(tableKey(seq.owner_schema ?? "public", seq.owner_table)), + ); + + const schemas = new Set(); + for (const table of tables) schemas.add(table.schema_name); + for (const seq of sequences) schemas.add(seq.sequence_schema); + const extraSchemas = [...schemas].filter((schemaName) => schemaName !== "public"); + if (extraSchemas.length > 0) { + emit("-- Schemas"); + for (const schemaName of extraSchemas) { + emitStatement(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schemaName)};`); + } + emit(""); + } if (sequences.length > 0) { emit("-- Sequences"); for (const seq of sequences) { - emitStatement(`DROP SEQUENCE IF EXISTS "${seq.sequence_name}" CASCADE;`); + const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name); + emitStatement(`DROP SEQUENCE IF EXISTS ${qualifiedSequenceName} CASCADE;`); emitStatement( - `CREATE SEQUENCE "${seq.sequence_name}" AS ${seq.data_type} INCREMENT BY ${seq.increment} MINVALUE ${seq.minimum_value} MAXVALUE ${seq.maximum_value} START WITH ${seq.start_value}${seq.cycle_option === "YES" ? " CYCLE" : " NO CYCLE"};`, + `CREATE SEQUENCE ${qualifiedSequenceName} AS ${seq.data_type} INCREMENT BY ${seq.increment} MINVALUE ${seq.minimum_value} MAXVALUE ${seq.maximum_value} START WITH ${seq.start_value}${seq.cycle_option === "YES" ? " CYCLE" : " NO CYCLE"};`, ); } emit(""); } // Get full CREATE TABLE DDL via column info - for (const { tablename } of tables) { + for (const { schema_name, tablename } of tables) { + const qualifiedTableName = quoteQualifiedName(schema_name, tablename); const columns = await sql<{ column_name: string; data_type: string; @@ -228,12 +269,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise SELECT column_name, data_type, udt_name, is_nullable, column_default, character_maximum_length, numeric_precision, numeric_scale FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = ${tablename} + WHERE table_schema = ${schema_name} AND table_name = ${tablename} ORDER BY ordinal_position `; - emit(`-- Table: ${tablename}`); - emitStatement(`DROP TABLE IF EXISTS "${tablename}" CASCADE;`); + emit(`-- Table: ${schema_name}.${tablename}`); + emitStatement(`DROP TABLE IF EXISTS ${qualifiedTableName} CASCADE;`); const colDefs: string[] = []; for (const col of columns) { @@ -269,7 +310,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise JOIN pg_class t ON t.oid = c.conrelid JOIN pg_namespace n ON n.oid = t.relnamespace JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey) - WHERE n.nspname = 'public' AND t.relname = ${tablename} AND c.contype = 'p' + WHERE n.nspname = ${schema_name} AND t.relname = ${tablename} AND c.contype = 'p' GROUP BY c.conname `; for (const p of pk) { @@ -277,7 +318,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise colDefs.push(` CONSTRAINT "${p.constraint_name}" PRIMARY KEY (${cols})`); } - emit(`CREATE TABLE "${tablename}" (`); + emit(`CREATE TABLE ${qualifiedTableName} (`); emit(colDefs.join(",\n")); emit(");"); emitStatementBoundary(); @@ -289,7 +330,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emit("-- Sequence ownership"); for (const seq of ownedSequences) { emitStatement( - `ALTER SEQUENCE "${seq.sequence_name}" OWNED BY "${seq.owner_table!}"."${seq.owner_column!}";`, + `ALTER SEQUENCE ${quoteQualifiedName(seq.sequence_schema, seq.sequence_name)} OWNED BY ${quoteQualifiedName(seq.owner_schema ?? "public", seq.owner_table!)}.${quoteIdentifier(seq.owner_column!)};`, ); } emit(""); @@ -298,8 +339,10 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise // Foreign keys (after all tables created) const allForeignKeys = await sql<{ constraint_name: string; + source_schema: string; source_table: string; source_columns: string[]; + target_schema: string; target_table: string; target_columns: string[]; update_rule: string; @@ -307,24 +350,31 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise }[]>` SELECT c.conname AS constraint_name, + srcn.nspname AS source_schema, src.relname AS source_table, array_agg(sa.attname ORDER BY array_position(c.conkey, sa.attnum)) AS source_columns, + tgtn.nspname AS target_schema, tgt.relname AS target_table, array_agg(ta.attname ORDER BY array_position(c.confkey, ta.attnum)) AS target_columns, CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule, CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule FROM pg_constraint c JOIN pg_class src ON src.oid = c.conrelid + JOIN pg_namespace srcn ON srcn.oid = src.relnamespace JOIN pg_class tgt ON tgt.oid = c.confrelid - JOIN pg_namespace n ON n.oid = src.relnamespace + JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = ANY(c.conkey) JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = ANY(c.confkey) - WHERE c.contype = 'f' AND n.nspname = 'public' - GROUP BY c.conname, src.relname, tgt.relname, c.confupdtype, c.confdeltype - ORDER BY src.relname, c.conname + WHERE c.contype = 'f' AND ( + srcn.nspname = 'public' + OR (${includeMigrationJournal}::boolean AND srcn.nspname = ${DRIZZLE_SCHEMA}) + ) + GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype + ORDER BY srcn.nspname, src.relname, c.conname `; const fks = allForeignKeys.filter( - (fk) => includedTableNames.has(fk.source_table) && includedTableNames.has(fk.target_table), + (fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table)) + && includedTableNames.has(tableKey(fk.target_schema, fk.target_table)), ); if (fks.length > 0) { @@ -333,7 +383,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", "); const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", "); emitStatement( - `ALTER TABLE "${fk.source_table}" ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES "${fk.target_table}" (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`, + `ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`, ); } emit(""); @@ -342,43 +392,52 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise // Unique constraints const allUniqueConstraints = await sql<{ constraint_name: string; + schema_name: string; tablename: string; column_names: string[]; }[]>` SELECT c.conname AS constraint_name, + n.nspname AS schema_name, t.relname AS tablename, array_agg(a.attname ORDER BY array_position(c.conkey, a.attnum)) AS column_names FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid JOIN pg_namespace n ON n.oid = t.relnamespace JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey) - WHERE n.nspname = 'public' AND c.contype = 'u' - GROUP BY c.conname, t.relname - ORDER BY t.relname, c.conname + WHERE c.contype = 'u' AND ( + n.nspname = 'public' + OR (${includeMigrationJournal}::boolean AND n.nspname = ${DRIZZLE_SCHEMA}) + ) + GROUP BY c.conname, n.nspname, t.relname + ORDER BY n.nspname, t.relname, c.conname `; - const uniques = allUniqueConstraints.filter((entry) => includedTableNames.has(entry.tablename)); + const uniques = allUniqueConstraints.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename))); if (uniques.length > 0) { emit("-- Unique constraints"); for (const u of uniques) { const cols = u.column_names.map((c) => `"${c}"`).join(", "); - emitStatement(`ALTER TABLE "${u.tablename}" ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`); + emitStatement(`ALTER TABLE ${quoteQualifiedName(u.schema_name, u.tablename)} ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`); } emit(""); } // Indexes (non-primary, non-unique-constraint) - const allIndexes = await sql<{ tablename: string; indexdef: string }[]>` - SELECT tablename, indexdef + const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>` + SELECT schemaname AS schema_name, tablename, indexdef FROM pg_indexes - WHERE schemaname = 'public' - AND indexname NOT IN ( - SELECT conname FROM pg_constraint - WHERE connamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public') + WHERE ( + schemaname = 'public' + OR (${includeMigrationJournal}::boolean AND schemaname = ${DRIZZLE_SCHEMA}) ) - ORDER BY tablename, indexname + AND indexname NOT IN ( + SELECT conname FROM pg_constraint c + JOIN pg_namespace n ON n.oid = c.connamespace + WHERE n.nspname = pg_indexes.schemaname + ) + ORDER BY schemaname, tablename, indexname `; - const indexes = allIndexes.filter((entry) => includedTableNames.has(entry.tablename)); + const indexes = allIndexes.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename))); if (indexes.length > 0) { emit("-- Indexes"); @@ -389,24 +448,23 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise } // Dump data for each table - for (const { tablename } of tables) { - const count = await sql<{ n: number }[]>` - SELECT count(*)::int AS n FROM ${sql(tablename)} - `; - if ((count[0]?.n ?? 0) === 0) continue; + for (const { schema_name, tablename } of tables) { + const qualifiedTableName = quoteQualifiedName(schema_name, tablename); + const count = await sql.unsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM ${qualifiedTableName}`); + if (excludedTableNames.has(tablename) || (count[0]?.n ?? 0) === 0) continue; // Get column info for this table const cols = await sql<{ column_name: string; data_type: string }[]>` SELECT column_name, data_type FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = ${tablename} + WHERE table_schema = ${schema_name} AND table_name = ${tablename} ORDER BY ordinal_position `; const colNames = cols.map((c) => `"${c.column_name}"`).join(", "); - emit(`-- Data for: ${tablename} (${count[0]!.n} rows)`); + emit(`-- Data for: ${schema_name}.${tablename} (${count[0]!.n} rows)`); - const rows = await sql`SELECT * FROM ${sql(tablename)}`.values(); + const rows = await sql.unsafe(`SELECT * FROM ${qualifiedTableName}`).values(); const nullifiedColumns = nullifiedColumnsByTable.get(tablename) ?? new Set(); for (const row of rows) { const values = row.map((rawValue: unknown, index) => { @@ -419,7 +477,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val)); return formatSqlLiteral(String(val)); }); - emitStatement(`INSERT INTO "${tablename}" (${colNames}) VALUES (${values.join(", ")});`); + emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`); } emit(""); } @@ -428,11 +486,15 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise if (sequences.length > 0) { emit("-- Sequence values"); for (const seq of sequences) { - const val = await sql<{ last_value: string; is_called: boolean }[]>` - SELECT last_value::text, is_called FROM ${sql(seq.sequence_name)} - `; - if (val[0]) { - emitStatement(`SELECT setval('"${seq.sequence_name}"', ${val[0].last_value}, ${val[0].is_called ? "true" : "false"});`); + const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name); + const val = await sql.unsafe<{ last_value: string; is_called: boolean }[]>( + `SELECT last_value::text, is_called FROM ${qualifiedSequenceName}`, + ); + const skipSequenceValue = + seq.owner_table !== null + && excludedTableNames.has(seq.owner_table); + if (val[0] && !skipSequenceValue) { + emitStatement(`SELECT setval('${qualifiedSequenceName.replaceAll("'", "''")}', ${val[0].last_value}, ${val[0].is_called ? "true" : "false"});`); } } emit(""); diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 8fa979d2..c4275dc4 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -10,6 +10,10 @@ const MIGRATIONS_FOLDER = fileURLToPath(new URL("./migrations", import.meta.url) const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; const MIGRATIONS_JOURNAL_JSON = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url)); +function createUtilitySql(url: string) { + return postgres(url, { max: 1, onnotice: () => {} }); +} + function isSafeIdentifier(value: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); } @@ -223,7 +227,7 @@ async function applyPendingMigrationsManually( journalEntries.map((entry) => [entry.fileName, normalizeFolderMillis(entry.folderMillis)]), ); - const sql = postgres(url, { max: 1 }); + const sql = createUtilitySql(url); try { const { migrationTableSchema, columnNames } = await ensureMigrationJournalTable(sql); const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`; @@ -472,7 +476,7 @@ export async function reconcilePendingMigrationHistory( return { repairedMigrations: [], remainingMigrations: [] }; } - const sql = postgres(url, { max: 1 }); + const sql = createUtilitySql(url); const repairedMigrations: string[] = []; try { @@ -579,7 +583,7 @@ async function discoverMigrationTableSchema(sql: ReturnType): P } export async function inspectMigrations(url: string): Promise { - const sql = postgres(url, { max: 1 }); + const sql = createUtilitySql(url); try { const availableMigrations = await listMigrationFiles(); @@ -642,7 +646,7 @@ export async function applyPendingMigrations(url: string): Promise { const initialState = await inspectMigrations(url); if (initialState.status === "upToDate") return; - const sql = postgres(url, { max: 1 }); + const sql = createUtilitySql(url); try { const db = drizzlePg(sql); @@ -680,7 +684,7 @@ export type MigrationBootstrapResult = | { migrated: false; reason: "not-empty-no-migration-journal"; tableCount: number }; export async function migratePostgresIfEmpty(url: string): Promise { - const sql = postgres(url, { max: 1 }); + const sql = createUtilitySql(url); try { const migrationTableSchema = await discoverMigrationTableSchema(sql); @@ -719,7 +723,7 @@ export async function ensurePostgresDatabase( throw new Error(`Unsafe database name: ${databaseName}`); } - const sql = postgres(url, { max: 1 }); + const sql = createUtilitySql(url); try { const existing = await sql<{ one: number }[]>` select 1 as one from pg_database where datname = ${databaseName} limit 1 diff --git a/server/src/app.ts b/server/src/app.ts index b21ec39f..32b3e3bc 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -32,6 +32,7 @@ export async function createApp( db: Db, opts: { uiMode: UiMode; + serverPort: number; storageService: StorageService; deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; @@ -146,12 +147,18 @@ export async function createApp( if (opts.uiMode === "vite-dev") { const uiRoot = path.resolve(__dirname, "../../ui"); + const hmrPort = opts.serverPort + 10000; const { createServer: createViteServer } = await import("vite"); const vite = await createViteServer({ root: uiRoot, appType: "spa", server: { middlewareMode: true, + hmr: { + host: opts.bindHost, + port: hmrPort, + clientPort: hmrPort, + }, allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined, }, }); diff --git a/server/src/index.ts b/server/src/index.ts index 71992ce2..5220c4b1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -460,10 +460,12 @@ export async function startServer(): Promise { authReady = true; } + const listenPort = await detectPort(config.port); const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none"; const storageService = createStorageServiceFromConfig(config); const app = await createApp(db as any, { uiMode, + serverPort: listenPort, storageService, deploymentMode: config.deploymentMode, deploymentExposure: config.deploymentExposure, @@ -475,7 +477,6 @@ export async function startServer(): Promise { resolveSession, }); const server = createServer(app as unknown as Parameters[0]); - const listenPort = await detectPort(config.port); if (listenPort !== config.port) { logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`); From d9574fea715d2a19d3ab83bb23357823c10bb741 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 08:09:36 -0500 Subject: [PATCH 074/874] Fix doctor summary after repairs --- cli/src/__tests__/doctor.test.ts | 99 ++++++++++++++++++++++++++++++++ cli/src/commands/doctor.ts | 83 ++++++++++++++++++-------- 2 files changed, 158 insertions(+), 24 deletions(-) create mode 100644 cli/src/__tests__/doctor.test.ts diff --git a/cli/src/__tests__/doctor.test.ts b/cli/src/__tests__/doctor.test.ts new file mode 100644 index 00000000..83a67831 --- /dev/null +++ b/cli/src/__tests__/doctor.test.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { doctor } from "../commands/doctor.js"; +import { writeConfig } from "../config/store.js"; +import type { PaperclipConfig } from "../config/schema.js"; + +const ORIGINAL_ENV = { ...process.env }; + +function createTempConfig(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-")); + const configPath = path.join(root, ".paperclip", "config.json"); + const runtimeRoot = path.join(root, "runtime"); + + const config: PaperclipConfig = { + $meta: { + version: 1, + updatedAt: "2026-03-10T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(runtimeRoot, "db"), + embeddedPostgresPort: 55432, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(runtimeRoot, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(runtimeRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3199, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(runtimeRoot, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(runtimeRoot, "secrets", "master.key"), + }, + }, + }; + + writeConfig(config, configPath); + return configPath; +} + +describe("doctor", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("re-runs repairable checks so repaired failures do not remain blocking", async () => { + const configPath = createTempConfig(); + + const summary = await doctor({ + config: configPath, + repair: true, + yes: true, + }); + + expect(summary.failed).toBe(0); + expect(summary.warned).toBe(0); + expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBeTruthy(); + }); +}); diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index ab99b012..3ace070e 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -66,28 +66,40 @@ export async function doctor(opts: { printResult(deploymentAuthResult); // 3. Agent JWT check - const jwtResult = agentJwtSecretCheck(opts.config); - results.push(jwtResult); - printResult(jwtResult); - await maybeRepair(jwtResult, opts); + results.push( + await runRepairableCheck({ + run: () => agentJwtSecretCheck(opts.config), + configPath, + opts, + }), + ); // 4. Secrets adapter check - const secretsResult = secretsCheck(config, configPath); - results.push(secretsResult); - printResult(secretsResult); - await maybeRepair(secretsResult, opts); + results.push( + await runRepairableCheck({ + run: () => secretsCheck(config, configPath), + configPath, + opts, + }), + ); // 5. Storage check - const storageResult = storageCheck(config, configPath); - results.push(storageResult); - printResult(storageResult); - await maybeRepair(storageResult, opts); + results.push( + await runRepairableCheck({ + run: () => storageCheck(config, configPath), + configPath, + opts, + }), + ); // 6. Database check - const dbResult = await databaseCheck(config, configPath); - results.push(dbResult); - printResult(dbResult); - await maybeRepair(dbResult, opts); + results.push( + await runRepairableCheck({ + run: () => databaseCheck(config, configPath), + configPath, + opts, + }), + ); // 7. LLM check const llmResult = await llmCheck(config); @@ -95,10 +107,13 @@ export async function doctor(opts: { printResult(llmResult); // 8. Log directory check - const logResult = logCheck(config, configPath); - results.push(logResult); - printResult(logResult); - await maybeRepair(logResult, opts); + results.push( + await runRepairableCheck({ + run: () => logCheck(config, configPath), + configPath, + opts, + }), + ); // 9. Port check const portResult = await portCheck(config); @@ -120,9 +135,9 @@ function printResult(result: CheckResult): void { async function maybeRepair( result: CheckResult, opts: { repair?: boolean; yes?: boolean }, -): Promise { - if (result.status === "pass" || !result.canRepair || !result.repair) return; - if (!opts.repair) return; +): Promise { + if (result.status === "pass" || !result.canRepair || !result.repair) return false; + if (!opts.repair) return false; let shouldRepair = opts.yes; if (!shouldRepair) { @@ -130,7 +145,7 @@ async function maybeRepair( message: `Repair "${result.name}"?`, initialValue: true, }); - if (p.isCancel(answer)) return; + if (p.isCancel(answer)) return false; shouldRepair = answer; } @@ -138,10 +153,30 @@ async function maybeRepair( try { await result.repair(); p.log.success(`Repaired: ${result.name}`); + return true; } catch (err) { p.log.error(`Repair failed: ${err instanceof Error ? err.message : String(err)}`); } } + return false; +} + +async function runRepairableCheck(input: { + run: () => CheckResult | Promise; + configPath: string; + opts: { repair?: boolean; yes?: boolean }; +}): Promise { + let result = await input.run(); + printResult(result); + + const repaired = await maybeRepair(result, input.opts); + if (!repaired) return result; + + // Repairs may create/update the adjacent .env file or other local resources. + loadPaperclipEnvFile(input.configPath); + result = await input.run(); + printResult(result); + return result; } function printSummary(results: CheckResult[]): { passed: number; warned: number; failed: number } { From 3120c7237224cd635ff6c31a64eb1b73fe4d2645 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 10:58:38 -0500 Subject: [PATCH 075/874] Add worktree-aware workspace runtime support --- packages/adapter-utils/src/index.ts | 1 + packages/adapter-utils/src/types.ts | 28 + packages/adapters/claude-local/src/index.ts | 5 + .../claude-local/src/server/execute.ts | 32 + .../claude-local/src/ui/build-config.ts | 24 + packages/adapters/codex-local/src/index.ts | 3 + .../codex-local/src/server/execute.ts | 32 + .../codex-local/src/ui/build-config.ts | 24 + .../adapters/openclaw-gateway/src/index.ts | 12 + .../openclaw-gateway/src/server/execute.ts | 170 +- .../openclaw-gateway/src/ui/build-config.ts | 18 + .../src/migrations/0026_lying_pete_wisdom.sql | 39 + .../db/src/migrations/meta/0026_snapshot.json | 6193 +++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/index.ts | 1 + .../src/schema/workspace_runtime_services.ts | 64 + packages/shared/src/index.ts | 1 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/project.ts | 2 + .../shared/src/types/workspace-runtime.ts | 28 + .../openclaw-gateway-adapter.test.ts | 165 +- .../src/__tests__/workspace-runtime.test.ts | 300 + server/src/index.ts | 17 +- server/src/services/heartbeat.ts | 189 +- server/src/services/index.ts | 1 + server/src/services/projects.ts | 83 +- server/src/services/workspace-runtime.ts | 962 +++ .../adapters/claude-local/config-fields.tsx | 65 +- ui/src/adapters/codex-local/config-fields.tsx | 15 + .../local-workspace-runtime-fields.tsx | 136 + .../openclaw-gateway/config-fields.tsx | 20 + ui/src/adapters/runtime-json-fields.tsx | 115 + ui/src/components/ProjectProperties.tsx | 45 + ui/src/components/agent-config-defaults.ts | 6 + ui/src/components/agent-config-primitives.tsx | 7 + 35 files changed, 8750 insertions(+), 61 deletions(-) create mode 100644 packages/db/src/migrations/0026_lying_pete_wisdom.sql create mode 100644 packages/db/src/migrations/meta/0026_snapshot.json create mode 100644 packages/db/src/schema/workspace_runtime_services.ts create mode 100644 packages/shared/src/types/workspace-runtime.ts create mode 100644 server/src/__tests__/workspace-runtime.test.ts create mode 100644 server/src/services/workspace-runtime.ts create mode 100644 ui/src/adapters/local-workspace-runtime-fields.tsx create mode 100644 ui/src/adapters/runtime-json-fields.tsx diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 83605307..89f03fb4 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -3,6 +3,7 @@ export type { AdapterRuntime, UsageSummary, AdapterBillingType, + AdapterRuntimeServiceReport, AdapterExecutionResult, AdapterInvocationMeta, AdapterExecutionContext, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index bf9b7748..8eb01190 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -32,6 +32,27 @@ export interface UsageSummary { export type AdapterBillingType = "api" | "subscription" | "unknown"; +export interface AdapterRuntimeServiceReport { + id?: string | null; + projectId?: string | null; + projectWorkspaceId?: string | null; + issueId?: string | null; + scopeType?: "project_workspace" | "execution_workspace" | "run" | "agent"; + scopeId?: string | null; + serviceName: string; + status?: "starting" | "running" | "stopped" | "failed"; + lifecycle?: "shared" | "ephemeral"; + reuseKey?: string | null; + command?: string | null; + cwd?: string | null; + port?: number | null; + url?: string | null; + providerRef?: string | null; + ownerAgentId?: string | null; + stopPolicy?: Record | null; + healthStatus?: "unknown" | "healthy" | "unhealthy"; +} + export interface AdapterExecutionResult { exitCode: number | null; signal: string | null; @@ -51,6 +72,7 @@ export interface AdapterExecutionResult { billingType?: AdapterBillingType | null; costUsd?: number | null; resultJson?: Record | null; + runtimeServices?: AdapterRuntimeServiceReport[]; summary?: string | null; clearSession?: boolean; } @@ -208,6 +230,12 @@ export interface CreateConfigValues { envBindings: Record; url: string; bootstrapPrompt: string; + payloadTemplateJson?: string; + workspaceStrategyType?: string; + workspaceBaseRef?: string; + workspaceBranchTemplate?: string; + worktreeParentDir?: string; + runtimeServicesJson?: string; maxTurnsPerRun: number; heartbeatEnabled: boolean; intervalSec: number; diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index f8b59bad..b28ae180 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -25,8 +25,13 @@ Core fields: - command (string, optional): defaults to "claude" - extraArgs (string[], optional): additional CLI args - env (object, optional): KEY=VALUE environment variables +- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? } +- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env Operational fields: - timeoutSec (number, optional): run timeout in seconds - graceSec (number, optional): SIGTERM grace period in seconds + +Notes: +- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. `; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 32fa6bd4..be85439d 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -115,14 +115,28 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise => typeof value === "object" && value !== null, ) : []; + const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents) + ? context.paperclipRuntimeServiceIntents.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const runtimeServices = Array.isArray(context.paperclipRuntimeServices) + ? context.paperclipRuntimeServices.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, ""); const configuredCwd = asString(config.cwd, ""); const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; @@ -183,6 +197,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0) { env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); } + if (runtimeServiceIntents.length > 0) { + env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); + } + if (runtimeServices.length > 0) { + env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices); + } + if (runtimePrimaryUrl) { + env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl; + } for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; diff --git a/packages/adapters/claude-local/src/ui/build-config.ts b/packages/adapters/claude-local/src/ui/build-config.ts index 00368c28..0c45e156 100644 --- a/packages/adapters/claude-local/src/ui/build-config.ts +++ b/packages/adapters/claude-local/src/ui/build-config.ts @@ -50,6 +50,18 @@ function parseEnvBindings(bindings: unknown): Record { return env; } +function parseJsonObject(text: string): Record | null { + const trimmed = text.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + export function buildClaudeLocalConfig(v: CreateConfigValues): Record { const ac: Record = {}; if (v.cwd) ac.cwd = v.cwd; @@ -70,6 +82,18 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; ac.maxTurnsPerRun = v.maxTurnsPerRun; ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions; + if (v.workspaceStrategyType === "git_worktree") { + ac.workspaceStrategy = { + type: "git_worktree", + ...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}), + ...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}), + ...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}), + }; + } + const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? ""); + if (runtimeServices && Array.isArray(runtimeServices.services)) { + ac.workspaceRuntime = runtimeServices; + } if (v.command) ac.command = v.command; if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); return ac; diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index f09e50d9..ac0726ad 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -31,6 +31,8 @@ Core fields: - command (string, optional): defaults to "codex" - extraArgs (string[], optional): additional CLI args - env (object, optional): KEY=VALUE environment variables +- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? } +- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env Operational fields: - timeoutSec (number, optional): run timeout in seconds @@ -40,4 +42,5 @@ Notes: - Prompts are piped via stdin (Codex receives "-" prompt argument). - Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills. - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). +- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. `; diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index f9d871c9..3dec4ff7 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -126,14 +126,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise => typeof value === "object" && value !== null, ) : []; + const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents) + ? context.paperclipRuntimeServiceIntents.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const runtimeServices = Array.isArray(context.paperclipRuntimeServices) + ? context.paperclipRuntimeServices.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, ""); const configuredCwd = asString(config.cwd, ""); const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; @@ -192,6 +206,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); } + if (runtimeServiceIntents.length > 0) { + env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); + } + if (runtimeServices.length > 0) { + env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices); + } + if (runtimePrimaryUrl) { + env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl; + } for (const [k, v] of Object.entries(envConfig)) { if (typeof v === "string") env[k] = v; } diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index 4555dcbd..a5b834d3 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -54,6 +54,18 @@ function parseEnvBindings(bindings: unknown): Record { return env; } +function parseJsonObject(text: string): Record | null { + const trimmed = text.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + export function buildCodexLocalConfig(v: CreateConfigValues): Record { const ac: Record = {}; if (v.cwd) ac.cwd = v.cwd; @@ -76,6 +88,18 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText; } +function buildStandardPaperclipPayload( + ctx: AdapterExecutionContext, + wakePayload: WakePayload, + paperclipEnv: Record, + payloadTemplate: Record, +): Record { + const templatePaperclip = parseObject(payloadTemplate.paperclip); + const workspace = asRecord(ctx.context.paperclipWorkspace); + const workspaces = Array.isArray(ctx.context.paperclipWorkspaces) + ? ctx.context.paperclipWorkspaces.filter((entry): entry is Record => Boolean(asRecord(entry))) + : []; + const configuredWorkspaceRuntime = parseObject(ctx.config.workspaceRuntime); + const runtimeServiceIntents = Array.isArray(ctx.context.paperclipRuntimeServiceIntents) + ? ctx.context.paperclipRuntimeServiceIntents.filter( + (entry): entry is Record => Boolean(asRecord(entry)), + ) + : []; + + const standardPaperclip: Record = { + runId: ctx.runId, + companyId: ctx.agent.companyId, + agentId: ctx.agent.id, + agentName: ctx.agent.name, + taskId: wakePayload.taskId, + issueId: wakePayload.issueId, + issueIds: wakePayload.issueIds, + wakeReason: wakePayload.wakeReason, + wakeCommentId: wakePayload.wakeCommentId, + approvalId: wakePayload.approvalId, + approvalStatus: wakePayload.approvalStatus, + apiUrl: paperclipEnv.PAPERCLIP_API_URL ?? null, + }; + + if (workspace) { + standardPaperclip.workspace = workspace; + } + if (workspaces.length > 0) { + standardPaperclip.workspaces = workspaces; + } + if (runtimeServiceIntents.length > 0 || Object.keys(configuredWorkspaceRuntime).length > 0) { + standardPaperclip.workspaceRuntime = { + ...configuredWorkspaceRuntime, + ...(runtimeServiceIntents.length > 0 ? { services: runtimeServiceIntents } : {}), + }; + } + + return { + ...templatePaperclip, + ...standardPaperclip, + }; +} + function normalizeUrl(input: string): URL | null { try { return new URL(input); @@ -835,6 +891,91 @@ function parseUsage(value: unknown): AdapterExecutionResult["usage"] | undefined }; } +function extractRuntimeServicesFromMeta(meta: Record | null): AdapterRuntimeServiceReport[] { + if (!meta) return []; + const reports: AdapterRuntimeServiceReport[] = []; + + const runtimeServices = Array.isArray(meta.runtimeServices) + ? meta.runtimeServices.filter((entry): entry is Record => Boolean(asRecord(entry))) + : []; + for (const entry of runtimeServices) { + const serviceName = nonEmpty(entry.serviceName) ?? nonEmpty(entry.name); + if (!serviceName) continue; + const rawStatus = nonEmpty(entry.status)?.toLowerCase(); + const status = + rawStatus === "starting" || rawStatus === "running" || rawStatus === "stopped" || rawStatus === "failed" + ? rawStatus + : "running"; + const rawLifecycle = nonEmpty(entry.lifecycle)?.toLowerCase(); + const lifecycle = rawLifecycle === "shared" ? "shared" : "ephemeral"; + const rawScopeType = nonEmpty(entry.scopeType)?.toLowerCase(); + const scopeType = + rawScopeType === "project_workspace" || + rawScopeType === "execution_workspace" || + rawScopeType === "agent" + ? rawScopeType + : "run"; + const rawHealth = nonEmpty(entry.healthStatus)?.toLowerCase(); + const healthStatus = + rawHealth === "healthy" || rawHealth === "unhealthy" || rawHealth === "unknown" + ? rawHealth + : status === "running" + ? "healthy" + : "unknown"; + + reports.push({ + id: nonEmpty(entry.id), + projectId: nonEmpty(entry.projectId), + projectWorkspaceId: nonEmpty(entry.projectWorkspaceId), + issueId: nonEmpty(entry.issueId), + scopeType, + scopeId: nonEmpty(entry.scopeId), + serviceName, + status, + lifecycle, + reuseKey: nonEmpty(entry.reuseKey), + command: nonEmpty(entry.command), + cwd: nonEmpty(entry.cwd), + port: parseOptionalPositiveInteger(entry.port), + url: nonEmpty(entry.url), + providerRef: nonEmpty(entry.providerRef) ?? nonEmpty(entry.previewId), + ownerAgentId: nonEmpty(entry.ownerAgentId), + stopPolicy: asRecord(entry.stopPolicy), + healthStatus, + }); + } + + const previewUrl = nonEmpty(meta.previewUrl); + if (previewUrl) { + reports.push({ + serviceName: "preview", + status: "running", + lifecycle: "ephemeral", + scopeType: "run", + url: previewUrl, + providerRef: nonEmpty(meta.previewId) ?? previewUrl, + healthStatus: "healthy", + }); + } + + const previewUrls = Array.isArray(meta.previewUrls) + ? meta.previewUrls.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + : []; + previewUrls.forEach((url, index) => { + reports.push({ + serviceName: index === 0 ? "preview" : `preview-${index + 1}`, + status: "running", + lifecycle: "ephemeral", + scopeType: "run", + url, + providerRef: `${url}#${index}`, + healthStatus: "healthy", + }); + }); + + return reports; +} + function extractResultText(value: unknown): string | null { const record = asRecord(value); if (!record) return null; @@ -924,9 +1065,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { ...payloadTemplate, + paperclip: paperclipPayload, message, sessionKey, idempotencyKey: ctx.runId, @@ -1188,12 +1331,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? { costUsd } : {}), resultJson: asRecord(latestResultPayload), + ...(runtimeServices.length > 0 ? { runtimeServices } : {}), ...(summary ? { summary } : {}), }; } catch (err) { diff --git a/packages/adapters/openclaw-gateway/src/ui/build-config.ts b/packages/adapters/openclaw-gateway/src/ui/build-config.ts index 6a749f84..70604f20 100644 --- a/packages/adapters/openclaw-gateway/src/ui/build-config.ts +++ b/packages/adapters/openclaw-gateway/src/ui/build-config.ts @@ -1,5 +1,17 @@ import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +function parseJsonObject(text: string): Record | null { + const trimmed = text.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record { const ac: Record = {}; if (v.url) ac.url = v.url; @@ -8,5 +20,11 @@ export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record statement-breakpoint +ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk" FOREIGN KEY ("project_workspace_id") REFERENCES "public"."project_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("started_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "workspace_runtime_services_company_workspace_status_idx" ON "workspace_runtime_services" USING btree ("company_id","project_workspace_id","status");--> statement-breakpoint +CREATE INDEX "workspace_runtime_services_company_project_status_idx" ON "workspace_runtime_services" USING btree ("company_id","project_id","status");--> statement-breakpoint +CREATE INDEX "workspace_runtime_services_run_idx" ON "workspace_runtime_services" USING btree ("started_by_run_id");--> statement-breakpoint +CREATE INDEX "workspace_runtime_services_company_updated_idx" ON "workspace_runtime_services" USING btree ("company_id","updated_at"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0026_snapshot.json b/packages/db/src/migrations/meta/0026_snapshot.json new file mode 100644 index 00000000..a3ebaad7 --- /dev/null +++ b/packages/db/src/migrations/meta/0026_snapshot.json @@ -0,0 +1,6193 @@ +{ + "id": "5f8dd541-9e28-4a42-890b-fc4a301604ac", + "prevId": "bd8d9b8d-3012-4c58-bcfd-b3215c164f82", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_log_company_created_idx": { + "name": "activity_log_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_run_id_idx": { + "name": "activity_log_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_entity_type_id_idx": { + "name": "activity_log_entity_type_id_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_company_id_companies_id_fk": { + "name": "activity_log_company_id_companies_id_fk", + "tableFrom": "activity_log", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_agent_id_agents_id_fk": { + "name": "activity_log_agent_id_agents_id_fk", + "tableFrom": "activity_log", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_run_id_heartbeat_runs_id_fk": { + "name": "activity_log_run_id_heartbeat_runs_id_fk", + "tableFrom": "activity_log", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_api_keys": { + "name": "agent_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_api_keys_key_hash_idx": { + "name": "agent_api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_api_keys_company_agent_idx": { + "name": "agent_api_keys_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_api_keys_agent_id_agents_id_fk": { + "name": "agent_api_keys_agent_id_agents_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_api_keys_company_id_companies_id_fk": { + "name": "agent_api_keys_company_id_companies_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config_revisions": { + "name": "agent_config_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'patch'" + }, + "rolled_back_from_revision_id": { + "name": "rolled_back_from_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "changed_keys": { + "name": "changed_keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "before_config": { + "name": "before_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "after_config": { + "name": "after_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_config_revisions_company_agent_created_idx": { + "name": "agent_config_revisions_company_agent_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_config_revisions_agent_created_idx": { + "name": "agent_config_revisions_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_revisions_company_id_companies_id_fk": { + "name": "agent_config_revisions_company_id_companies_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_config_revisions_agent_id_agents_id_fk": { + "name": "agent_config_revisions_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_config_revisions_created_by_agent_id_agents_id_fk": { + "name": "agent_config_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_runtime_state": { + "name": "agent_runtime_state", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_json": { + "name": "state_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_input_tokens": { + "name": "total_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_output_tokens": { + "name": "total_output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cached_input_tokens": { + "name": "total_cached_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost_cents": { + "name": "total_cost_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_runtime_state_company_agent_idx": { + "name": "agent_runtime_state_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_runtime_state_company_updated_idx": { + "name": "agent_runtime_state_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_runtime_state_agent_id_agents_id_fk": { + "name": "agent_runtime_state_agent_id_agents_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_runtime_state_company_id_companies_id_fk": { + "name": "agent_runtime_state_company_id_companies_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task_sessions": { + "name": "agent_task_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_key": { + "name": "task_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_params_json": { + "name": "session_params_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_display_id": { + "name": "session_display_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_task_sessions_company_agent_adapter_task_uniq": { + "name": "agent_task_sessions_company_agent_adapter_task_uniq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_agent_updated_idx": { + "name": "agent_task_sessions_company_agent_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_task_updated_idx": { + "name": "agent_task_sessions_company_task_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task_sessions_company_id_companies_id_fk": { + "name": "agent_task_sessions_company_id_companies_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_agent_id_agents_id_fk": { + "name": "agent_task_sessions_agent_id_agents_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_last_run_id_heartbeat_runs_id_fk": { + "name": "agent_task_sessions_last_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "last_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_wakeup_requests": { + "name": "agent_wakeup_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "coalesced_count": { + "name": "coalesced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "requested_by_actor_type": { + "name": "requested_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_actor_id": { + "name": "requested_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_wakeup_requests_company_agent_status_idx": { + "name": "agent_wakeup_requests_company_agent_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_company_requested_idx": { + "name": "agent_wakeup_requests_company_requested_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_agent_requested_idx": { + "name": "agent_wakeup_requests_agent_requested_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_wakeup_requests_company_id_companies_id_fk": { + "name": "agent_wakeup_requests_company_id_companies_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_wakeup_requests_agent_id_agents_id_fk": { + "name": "agent_wakeup_requests_agent_id_agents_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "reports_to": { + "name": "reports_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'process'" + }, + "adapter_config": { + "name": "adapter_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "runtime_config": { + "name": "runtime_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_company_status_idx": { + "name": "agents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_company_reports_to_idx": { + "name": "agents_company_reports_to_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reports_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_company_id_companies_id_fk": { + "name": "agents_company_id_companies_id_fk", + "tableFrom": "agents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agents_reports_to_agents_id_fk": { + "name": "agents_reports_to_agents_id_fk", + "tableFrom": "agents", + "tableTo": "agents", + "columnsFrom": [ + "reports_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_comments": { + "name": "approval_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_comments_company_idx": { + "name": "approval_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_idx": { + "name": "approval_comments_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_created_idx": { + "name": "approval_comments_approval_created_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_comments_company_id_companies_id_fk": { + "name": "approval_comments_company_id_companies_id_fk", + "tableFrom": "approval_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_approval_id_approvals_id_fk": { + "name": "approval_comments_approval_id_approvals_id_fk", + "tableFrom": "approval_comments", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_author_agent_id_agents_id_fk": { + "name": "approval_comments_author_agent_id_agents_id_fk", + "tableFrom": "approval_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approvals": { + "name": "approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_agent_id": { + "name": "requested_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decision_note": { + "name": "decision_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approvals_company_status_type_idx": { + "name": "approvals_company_status_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approvals_company_id_companies_id_fk": { + "name": "approvals_company_id_companies_id_fk", + "tableFrom": "approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approvals_requested_by_agent_id_agents_id_fk": { + "name": "approvals_requested_by_agent_id_agents_id_fk", + "tableFrom": "approvals", + "tableTo": "agents", + "columnsFrom": [ + "requested_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_company_created_idx": { + "name": "assets_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_provider_idx": { + "name": "assets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_object_key_uq": { + "name": "assets_company_object_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_company_id_companies_id_fk": { + "name": "assets_company_id_companies_id_fk", + "tableFrom": "assets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "assets_created_by_agent_id_agents_id_fk": { + "name": "assets_created_by_agent_id_agents_id_fk", + "tableFrom": "assets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issue_prefix": { + "name": "issue_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PAP'" + }, + "issue_counter": { + "name": "issue_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "companies_issue_prefix_idx": { + "name": "companies_issue_prefix_idx", + "columns": [ + { + "expression": "issue_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_memberships_company_principal_unique_idx": { + "name": "company_memberships_company_principal_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_principal_status_idx": { + "name": "company_memberships_principal_status_idx", + "columns": [ + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_company_status_idx": { + "name": "company_memberships_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_versions": { + "name": "company_secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "material": { + "name": "material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "value_sha256": { + "name": "value_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_secret_versions_secret_idx": { + "name": "company_secret_versions_secret_idx", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_value_sha256_idx": { + "name": "company_secret_versions_value_sha256_idx", + "columns": [ + { + "expression": "value_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_secret_version_uq": { + "name": "company_secret_versions_secret_version_uq", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_versions_secret_id_company_secrets_id_fk": { + "name": "company_secret_versions_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_versions_created_by_agent_id_agents_id_fk": { + "name": "company_secret_versions_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secrets": { + "name": "company_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_encrypted'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version": { + "name": "latest_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_secrets_company_idx": { + "name": "company_secrets_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_provider_idx": { + "name": "company_secrets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_name_uq": { + "name": "company_secrets_company_name_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secrets_company_id_companies_id_fk": { + "name": "company_secrets_company_id_companies_id_fk", + "tableFrom": "company_secrets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secrets_created_by_agent_id_agents_id_fk": { + "name": "company_secrets_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secrets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_events": { + "name": "cost_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_events_company_occurred_idx": { + "name": "cost_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_agent_occurred_idx": { + "name": "cost_events_company_agent_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_events_company_id_companies_id_fk": { + "name": "cost_events_company_id_companies_id_fk", + "tableFrom": "cost_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_agent_id_agents_id_fk": { + "name": "cost_events_agent_id_agents_id_fk", + "tableFrom": "cost_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_issue_id_issues_id_fk": { + "name": "cost_events_issue_id_issues_id_fk", + "tableFrom": "cost_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_project_id_projects_id_fk": { + "name": "cost_events_project_id_projects_id_fk", + "tableFrom": "cost_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_goal_id_goals_id_fk": { + "name": "cost_events_goal_id_goals_id_fk", + "tableFrom": "cost_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goals_company_idx": { + "name": "goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_company_id_companies_id_fk": { + "name": "goals_company_id_companies_id_fk", + "tableFrom": "goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_parent_id_goals_id_fk": { + "name": "goals_parent_id_goals_id_fk", + "tableFrom": "goals", + "tableTo": "goals", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_owner_agent_id_agents_id_fk": { + "name": "goals_owner_agent_id_agents_id_fk", + "tableFrom": "goals", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_run_events": { + "name": "heartbeat_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_events_run_seq_idx": { + "name": "heartbeat_run_events_run_seq_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_run_idx": { + "name": "heartbeat_run_events_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_created_idx": { + "name": "heartbeat_run_events_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_events_company_id_companies_id_fk": { + "name": "heartbeat_run_events_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_agent_id_agents_id_fk": { + "name": "heartbeat_run_events_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_runs": { + "name": "heartbeat_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invocation_source": { + "name": "invocation_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'on_demand'" + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wakeup_request_id": { + "name": "wakeup_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signal": { + "name": "signal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id_before": { + "name": "session_id_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id_after": { + "name": "session_id_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_run_id": { + "name": "external_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_snapshot": { + "name": "context_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_runs_company_agent_started_idx": { + "name": "heartbeat_runs_company_agent_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_runs_company_id_companies_id_fk": { + "name": "heartbeat_runs_company_id_companies_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_agent_id_agents_id_fk": { + "name": "heartbeat_runs_agent_id_agents_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { + "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agent_wakeup_requests", + "columnsFrom": [ + "wakeup_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_user_roles": { + "name": "instance_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'instance_admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_user_roles_user_role_unique_idx": { + "name": "instance_user_roles_user_role_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "instance_user_roles_role_idx": { + "name": "instance_user_roles_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invite_type": { + "name": "invite_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company_join'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_join_types": { + "name": "allowed_join_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "defaults_payload": { + "name": "defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique_idx": { + "name": "invites_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_company_invite_state_idx": { + "name": "invites_company_invite_state_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invite_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_approvals": { + "name": "issue_approvals", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "linked_by_agent_id": { + "name": "linked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_approvals_issue_idx": { + "name": "issue_approvals_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_approval_idx": { + "name": "issue_approvals_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_company_idx": { + "name": "issue_approvals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_approvals_company_id_companies_id_fk": { + "name": "issue_approvals_company_id_companies_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_approvals_issue_id_issues_id_fk": { + "name": "issue_approvals_issue_id_issues_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_approval_id_approvals_id_fk": { + "name": "issue_approvals_approval_id_approvals_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_linked_by_agent_id_agents_id_fk": { + "name": "issue_approvals_linked_by_agent_id_agents_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "agents", + "columnsFrom": [ + "linked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_approvals_pk": { + "name": "issue_approvals_pk", + "columns": [ + "issue_id", + "approval_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_attachments": { + "name": "issue_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_comment_id": { + "name": "issue_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_attachments_company_issue_idx": { + "name": "issue_attachments_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_issue_comment_idx": { + "name": "issue_attachments_issue_comment_idx", + "columns": [ + { + "expression": "issue_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_asset_uq": { + "name": "issue_attachments_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_attachments_company_id_companies_id_fk": { + "name": "issue_attachments_company_id_companies_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_attachments_issue_id_issues_id_fk": { + "name": "issue_attachments_issue_id_issues_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_asset_id_assets_id_fk": { + "name": "issue_attachments_asset_id_assets_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_issue_comment_id_issue_comments_id_fk": { + "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issue_comments", + "columnsFrom": [ + "issue_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_comments": { + "name": "issue_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_comments_issue_idx": { + "name": "issue_comments_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_idx": { + "name": "issue_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_issue_created_at_idx": { + "name": "issue_comments_company_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_author_issue_created_at_idx": { + "name": "issue_comments_company_author_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_comments_company_id_companies_id_fk": { + "name": "issue_comments_company_id_companies_id_fk", + "tableFrom": "issue_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_issue_id_issues_id_fk": { + "name": "issue_comments_issue_id_issues_id_fk", + "tableFrom": "issue_comments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_author_agent_id_agents_id_fk": { + "name": "issue_comments_author_agent_id_agents_id_fk", + "tableFrom": "issue_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_labels": { + "name": "issue_labels", + "schema": "", + "columns": { + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_labels_issue_idx": { + "name": "issue_labels_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_label_idx": { + "name": "issue_labels_label_idx", + "columns": [ + { + "expression": "label_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_company_idx": { + "name": "issue_labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_labels_issue_id_issues_id_fk": { + "name": "issue_labels_issue_id_issues_id_fk", + "tableFrom": "issue_labels", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_label_id_labels_id_fk": { + "name": "issue_labels_label_id_labels_id_fk", + "tableFrom": "issue_labels", + "tableTo": "labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_company_id_companies_id_fk": { + "name": "issue_labels_company_id_companies_id_fk", + "tableFrom": "issue_labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_labels_pk": { + "name": "issue_labels_pk", + "columns": [ + "issue_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_read_states": { + "name": "issue_read_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_read_states_company_issue_idx": { + "name": "issue_read_states_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_user_idx": { + "name": "issue_read_states_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_issue_user_idx": { + "name": "issue_read_states_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_read_states_company_id_companies_id_fk": { + "name": "issue_read_states_company_id_companies_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_read_states_issue_id_issues_id_fk": { + "name": "issue_read_states_issue_id_issues_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issues": { + "name": "issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_run_id": { + "name": "checkout_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_run_id": { + "name": "execution_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_agent_name_key": { + "name": "execution_agent_name_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_locked_at": { + "name": "execution_locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_depth": { + "name": "request_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_adapter_overrides": { + "name": "assignee_adapter_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issues_company_status_idx": { + "name": "issues_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_status_idx": { + "name": "issues_company_assignee_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_user_status_idx": { + "name": "issues_company_assignee_user_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_parent_idx": { + "name": "issues_company_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_idx": { + "name": "issues_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_identifier_idx": { + "name": "issues_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_company_id_companies_id_fk": { + "name": "issues_company_id_companies_id_fk", + "tableFrom": "issues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_goal_id_goals_id_fk": { + "name": "issues_goal_id_goals_id_fk", + "tableFrom": "issues", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_agent_id_agents_id_fk": { + "name": "issues_assignee_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_checkout_run_id_heartbeat_runs_id_fk": { + "name": "issues_checkout_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "checkout_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_execution_run_id_heartbeat_runs_id_fk": { + "name": "issues_execution_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "execution_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_created_by_agent_id_agents_id_fk": { + "name": "issues_created_by_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_approval'" + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requesting_user_id": { + "name": "requesting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_email_snapshot": { + "name": "request_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_defaults_payload": { + "name": "agent_defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_secret_expires_at": { + "name": "claim_secret_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_secret_consumed_at": { + "name": "claim_secret_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_agent_id": { + "name": "created_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "join_requests_invite_unique_idx": { + "name": "join_requests_invite_unique_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_company_status_type_created_idx": { + "name": "join_requests_company_status_type_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "join_requests_invite_id_invites_id_fk": { + "name": "join_requests_invite_id_invites_id_fk", + "tableFrom": "join_requests", + "tableTo": "invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_company_id_companies_id_fk": { + "name": "join_requests_company_id_companies_id_fk", + "tableFrom": "join_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_created_agent_id_agents_id_fk": { + "name": "join_requests_created_agent_id_agents_id_fk", + "tableFrom": "join_requests", + "tableTo": "agents", + "columnsFrom": [ + "created_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.labels": { + "name": "labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "labels_company_idx": { + "name": "labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "labels_company_name_idx": { + "name": "labels_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "labels_company_id_companies_id_fk": { + "name": "labels_company_id_companies_id_fk", + "tableFrom": "labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_permission_grants": { + "name": "principal_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_key": { + "name": "permission_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "principal_permission_grants_unique_idx": { + "name": "principal_permission_grants_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "principal_permission_grants_company_permission_idx": { + "name": "principal_permission_grants_company_permission_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "principal_permission_grants_company_id_companies_id_fk": { + "name": "principal_permission_grants_company_id_companies_id_fk", + "tableFrom": "principal_permission_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_goals": { + "name": "project_goals", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_goals_project_idx": { + "name": "project_goals_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_goal_idx": { + "name": "project_goals_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_company_idx": { + "name": "project_goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_goals_project_id_projects_id_fk": { + "name": "project_goals_project_id_projects_id_fk", + "tableFrom": "project_goals", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_goal_id_goals_id_fk": { + "name": "project_goals_goal_id_goals_id_fk", + "tableFrom": "project_goals", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_company_id_companies_id_fk": { + "name": "project_goals_company_id_companies_id_fk", + "tableFrom": "project_goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_goals_project_id_goal_id_pk": { + "name": "project_goals_project_id_goal_id_pk", + "columns": [ + "project_id", + "goal_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_workspaces": { + "name": "project_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_ref": { + "name": "repo_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_workspaces_company_project_idx": { + "name": "project_workspaces_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_primary_idx": { + "name": "project_workspaces_project_primary_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_primary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_workspaces_company_id_companies_id_fk": { + "name": "project_workspaces_company_id_companies_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_workspaces_project_id_projects_id_fk": { + "name": "project_workspaces_project_id_projects_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "lead_agent_id": { + "name": "lead_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_date": { + "name": "target_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_company_idx": { + "name": "projects_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_company_id_companies_id_fk": { + "name": "projects_company_id_companies_id_fk", + "tableFrom": "projects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_goal_id_goals_id_fk": { + "name": "projects_goal_id_goals_id_fk", + "tableFrom": "projects", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_lead_agent_id_agents_id_fk": { + "name": "projects_lead_agent_id_agents_id_fk", + "tableFrom": "projects", + "tableTo": "agents", + "columnsFrom": [ + "lead_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_runtime_services": { + "name": "workspace_runtime_services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reuse_key": { + "name": "reuse_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_by_run_id": { + "name": "started_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stop_policy": { + "name": "stop_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_runtime_services_company_workspace_status_idx": { + "name": "workspace_runtime_services_company_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_project_status_idx": { + "name": "workspace_runtime_services_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_run_idx": { + "name": "workspace_runtime_services_run_idx", + "columns": [ + { + "expression": "started_by_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_updated_idx": { + "name": "workspace_runtime_services_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_runtime_services_company_id_companies_id_fk": { + "name": "workspace_runtime_services_company_id_companies_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_id_projects_id_fk": { + "name": "workspace_runtime_services_project_id_projects_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": { + "name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_issue_id_issues_id_fk": { + "name": "workspace_runtime_services_issue_id_issues_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_owner_agent_id_agents_id_fk": { + "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { + "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "started_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index c3e25050..d94811ab 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1772807461603, "tag": "0025_nasty_salo", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1773089625430, + "tag": "0026_lying_pete_wisdom", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index eb12c064..3416ea9a 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -13,6 +13,7 @@ export { agentTaskSessions } from "./agent_task_sessions.js"; export { agentWakeupRequests } from "./agent_wakeup_requests.js"; export { projects } from "./projects.js"; export { projectWorkspaces } from "./project_workspaces.js"; +export { workspaceRuntimeServices } from "./workspace_runtime_services.js"; export { projectGoals } from "./project_goals.js"; export { goals } from "./goals.js"; export { issues } from "./issues.js"; diff --git a/packages/db/src/schema/workspace_runtime_services.ts b/packages/db/src/schema/workspace_runtime_services.ts new file mode 100644 index 00000000..0837855f --- /dev/null +++ b/packages/db/src/schema/workspace_runtime_services.ts @@ -0,0 +1,64 @@ +import { + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { projects } from "./projects.js"; +import { projectWorkspaces } from "./project_workspaces.js"; +import { issues } from "./issues.js"; +import { agents } from "./agents.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; + +export const workspaceRuntimeServices = pgTable( + "workspace_runtime_services", + { + id: uuid("id").primaryKey(), + companyId: uuid("company_id").notNull().references(() => companies.id), + projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }), + projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }), + issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }), + scopeType: text("scope_type").notNull(), + scopeId: text("scope_id"), + serviceName: text("service_name").notNull(), + status: text("status").notNull(), + lifecycle: text("lifecycle").notNull(), + reuseKey: text("reuse_key"), + command: text("command"), + cwd: text("cwd"), + port: integer("port"), + url: text("url"), + provider: text("provider").notNull(), + providerRef: text("provider_ref"), + ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }), + startedByRunId: uuid("started_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull().defaultNow(), + startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(), + stoppedAt: timestamp("stopped_at", { withTimezone: true }), + stopPolicy: jsonb("stop_policy").$type>(), + healthStatus: text("health_status").notNull().default("unknown"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyWorkspaceStatusIdx: index("workspace_runtime_services_company_workspace_status_idx").on( + table.companyId, + table.projectWorkspaceId, + table.status, + ), + companyProjectStatusIdx: index("workspace_runtime_services_company_project_status_idx").on( + table.companyId, + table.projectId, + table.status, + ), + runIdx: index("workspace_runtime_services_run_idx").on(table.startedByRunId), + companyUpdatedIdx: index("workspace_runtime_services_company_updated_idx").on( + table.companyId, + table.updatedAt, + ), + }), +); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1a594cb1..65389313 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -77,6 +77,7 @@ export type { Project, ProjectGoalRef, ProjectWorkspace, + WorkspaceRuntimeService, Issue, IssueAssigneeAdapterOverrides, IssueComment, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index dd123fa3..f7daca5c 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -11,6 +11,7 @@ export type { } from "./agent.js"; export type { AssetImage } from "./asset.js"; export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.js"; +export type { WorkspaceRuntimeService } from "./workspace-runtime.js"; export type { Issue, IssueAssigneeAdapterOverrides, diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index b209c77f..cd95ff33 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -1,4 +1,5 @@ import type { ProjectStatus } from "../constants.js"; +import type { WorkspaceRuntimeService } from "./workspace-runtime.js"; export interface ProjectGoalRef { id: string; @@ -15,6 +16,7 @@ export interface ProjectWorkspace { repoRef: string | null; metadata: Record | null; isPrimary: boolean; + runtimeServices?: WorkspaceRuntimeService[]; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts new file mode 100644 index 00000000..eb8d6662 --- /dev/null +++ b/packages/shared/src/types/workspace-runtime.ts @@ -0,0 +1,28 @@ +export interface WorkspaceRuntimeService { + id: string; + companyId: string; + projectId: string | null; + projectWorkspaceId: string | null; + issueId: string | null; + scopeType: "project_workspace" | "execution_workspace" | "run" | "agent"; + scopeId: string | null; + serviceName: string; + status: "starting" | "running" | "stopped" | "failed"; + lifecycle: "shared" | "ephemeral"; + reuseKey: string | null; + command: string | null; + cwd: string | null; + port: number | null; + url: string | null; + provider: "local_process" | "adapter_managed"; + providerRef: string | null; + ownerAgentId: string | null; + startedByRunId: string | null; + lastUsedAt: Date; + startedAt: Date; + stoppedAt: Date | null; + stopPolicy: Record | null; + healthStatus: "unknown" | "healthy" | "unhealthy"; + createdAt: Date; + updatedAt: Date; +} diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index 364f5a97..04a44e72 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it } from "vitest"; import { createServer } from "node:http"; import { WebSocketServer } from "ws"; import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server"; -import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui"; +import { + buildOpenClawGatewayConfig, + parseOpenClawGatewayStdoutLine, +} from "@paperclipai/adapter-openclaw-gateway/ui"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; function buildContext( @@ -36,7 +39,9 @@ function buildContext( }; } -async function createMockGatewayServer() { +async function createMockGatewayServer(options?: { + waitPayload?: Record; +}) { const server = createServer(); const wss = new WebSocketServer({ server }); @@ -136,7 +141,7 @@ async function createMockGatewayServer() { type: "res", id: frame.id, ok: true, - payload: { + payload: options?.waitPayload ?? { runId: frame.params?.runId, status: "ok", startedAt: 1, @@ -412,6 +417,29 @@ describe("openclaw gateway adapter execute", () => { onLog: async (_stream, chunk) => { logs.push(chunk); }, + context: { + taskId: "task-123", + issueId: "issue-123", + wakeReason: "issue_assigned", + issueIds: ["issue-123"], + paperclipWorkspace: { + cwd: "/tmp/worktrees/pap-123", + strategy: "git_worktree", + branchName: "pap-123-test", + }, + paperclipWorkspaces: [ + { + id: "workspace-1", + cwd: "/tmp/project", + }, + ], + paperclipRuntimeServiceIntents: [ + { + name: "preview", + lifecycle: "ephemeral", + }, + ], + }, }, ), ); @@ -428,6 +456,33 @@ describe("openclaw gateway adapter execute", () => { expect(String(payload?.message ?? "")).toContain("wake now"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123"); + expect(payload?.paperclip).toEqual( + expect.objectContaining({ + runId: "run-123", + companyId: "company-123", + agentId: "agent-123", + taskId: "task-123", + issueId: "issue-123", + workspace: expect.objectContaining({ + cwd: "/tmp/worktrees/pap-123", + strategy: "git_worktree", + }), + workspaces: [ + expect.objectContaining({ + id: "workspace-1", + cwd: "/tmp/project", + }), + ], + workspaceRuntime: expect.objectContaining({ + services: [ + expect.objectContaining({ + name: "preview", + lifecycle: "ephemeral", + }), + ], + }), + }), + ); expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true); } finally { @@ -441,6 +496,54 @@ describe("openclaw gateway adapter execute", () => { expect(result.errorCode).toBe("openclaw_gateway_url_missing"); }); + it("returns adapter-managed runtime services from gateway result meta", async () => { + const gateway = await createMockGatewayServer({ + waitPayload: { + runId: "run-123", + status: "ok", + startedAt: 1, + endedAt: 2, + meta: { + runtimeServices: [ + { + name: "preview", + scopeType: "run", + url: "https://preview.example/run-123", + providerRef: "sandbox-123", + lifecycle: "ephemeral", + }, + ], + }, + }, + }); + + try { + const result = await execute( + buildContext({ + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + waitTimeoutMs: 2000, + }), + ); + + expect(result.exitCode).toBe(0); + expect(result.runtimeServices).toEqual([ + expect.objectContaining({ + serviceName: "preview", + scopeType: "run", + url: "https://preview.example/run-123", + providerRef: "sandbox-123", + lifecycle: "ephemeral", + status: "running", + }), + ]); + } finally { + await gateway.close(); + } + }); + it("auto-approves pairing once and retries the run", async () => { const gateway = await createMockGatewayServerWithPairing(); const logs: string[] = []; @@ -479,6 +582,62 @@ describe("openclaw gateway adapter execute", () => { }); }); +describe("openclaw gateway ui build config", () => { + it("parses payload template and runtime services json", () => { + const config = buildOpenClawGatewayConfig({ + adapterType: "openclaw_gateway", + cwd: "", + promptTemplate: "", + model: "", + thinkingEffort: "", + chrome: false, + dangerouslySkipPermissions: false, + search: false, + dangerouslyBypassSandbox: false, + command: "", + args: "", + extraArgs: "", + envVars: "", + envBindings: {}, + url: "wss://gateway.example/ws", + payloadTemplateJson: JSON.stringify({ + agentId: "remote-agent-123", + metadata: { team: "platform" }, + }), + runtimeServicesJson: JSON.stringify({ + services: [ + { + name: "preview", + lifecycle: "shared", + }, + ], + }), + bootstrapPrompt: "", + maxTurnsPerRun: 0, + heartbeatEnabled: true, + intervalSec: 300, + }); + + expect(config).toEqual( + expect.objectContaining({ + url: "wss://gateway.example/ws", + payloadTemplate: { + agentId: "remote-agent-123", + metadata: { team: "platform" }, + }, + workspaceRuntime: { + services: [ + { + name: "preview", + lifecycle: "shared", + }, + ], + }, + }), + ); + }); +}); + describe("openclaw gateway testEnvironment", () => { it("reports missing url as failure", async () => { const result = await testEnvironment({ diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts new file mode 100644 index 00000000..e148d664 --- /dev/null +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -0,0 +1,300 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { + ensureRuntimeServicesForRun, + normalizeAdapterManagedRuntimeServices, + realizeExecutionWorkspace, + releaseRuntimeServicesForRun, + type RealizedExecutionWorkspace, +} from "../services/workspace-runtime.ts"; + +const execFileAsync = promisify(execFile); +const leasedRunIds = new Set(); + +async function runGit(cwd: string, args: string[]) { + await execFileAsync("git", args, { cwd }); +} + +async function createTempRepo() { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-")); + await runGit(repoRoot, ["init"]); + await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]); + await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]); + await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8"); + await runGit(repoRoot, ["add", "README.md"]); + await runGit(repoRoot, ["commit", "-m", "Initial commit"]); + await runGit(repoRoot, ["checkout", "-B", "main"]); + return repoRoot; +} + +function buildWorkspace(cwd: string): RealizedExecutionWorkspace { + return { + baseCwd: cwd, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + strategy: "project_primary", + cwd, + branchName: null, + worktreePath: null, + warnings: [], + created: false, + }; +} + +afterEach(async () => { + await Promise.all( + Array.from(leasedRunIds).map(async (runId) => { + await releaseRuntimeServicesForRun(runId); + leasedRunIds.delete(runId); + }), + ); +}); + +describe("realizeExecutionWorkspace", () => { + it("creates and reuses a git worktree for an issue-scoped branch", async () => { + const repoRoot = await createTempRepo(); + + const first = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-447", + title: "Add Worktree Support", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + expect(first.strategy).toBe("git_worktree"); + expect(first.created).toBe(true); + expect(first.branchName).toBe("PAP-447-add-worktree-support"); + expect(first.cwd).toContain(path.join(".paperclip", "worktrees")); + await expect(fs.stat(path.join(first.cwd, ".git"))).resolves.toBeTruthy(); + + const second = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-447", + title: "Add Worktree Support", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + expect(second.created).toBe(false); + expect(second.cwd).toBe(first.cwd); + expect(second.branchName).toBe(first.branchName); + }); +}); + +describe("ensureRuntimeServicesForRun", () => { + it("reuses shared runtime services across runs and starts a new service after release", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-")); + const workspace = buildWorkspace(workspaceRoot); + const serviceCommand = + "node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\""; + + const config = { + workspaceRuntime: { + services: [ + { + name: "web", + command: serviceCommand, + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + expose: { + type: "url", + urlTemplate: "http://127.0.0.1:{{port}}", + }, + lifecycle: "shared", + reuseScope: "project_workspace", + stopPolicy: { + type: "on_run_finish", + }, + }, + ], + }, + }; + + const run1 = "run-1"; + const run2 = "run-2"; + leasedRunIds.add(run1); + leasedRunIds.add(run2); + + const first = await ensureRuntimeServicesForRun({ + runId: run1, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + issue: null, + workspace, + config, + adapterEnv: {}, + }); + + expect(first).toHaveLength(1); + expect(first[0]?.reused).toBe(false); + expect(first[0]?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + const response = await fetch(first[0]!.url!); + expect(await response.text()).toBe("ok"); + + const second = await ensureRuntimeServicesForRun({ + runId: run2, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + issue: null, + workspace, + config, + adapterEnv: {}, + }); + + expect(second).toHaveLength(1); + expect(second[0]?.reused).toBe(true); + expect(second[0]?.id).toBe(first[0]?.id); + + await releaseRuntimeServicesForRun(run1); + leasedRunIds.delete(run1); + await releaseRuntimeServicesForRun(run2); + leasedRunIds.delete(run2); + + const run3 = "run-3"; + leasedRunIds.add(run3); + const third = await ensureRuntimeServicesForRun({ + runId: run3, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + issue: null, + workspace, + config, + adapterEnv: {}, + }); + + expect(third).toHaveLength(1); + expect(third[0]?.reused).toBe(false); + expect(third[0]?.id).not.toBe(first[0]?.id); + }); +}); + +describe("normalizeAdapterManagedRuntimeServices", () => { + it("fills workspace defaults and derives stable ids for adapter-managed services", () => { + const workspace = buildWorkspace("/tmp/project"); + const now = new Date("2026-03-09T12:00:00.000Z"); + + const first = normalizeAdapterManagedRuntimeServices({ + adapterType: "openclaw_gateway", + runId: "run-1", + agent: { + id: "agent-1", + name: "Gateway Agent", + companyId: "company-1", + }, + issue: { + id: "issue-1", + identifier: "PAP-447", + title: "Worktree support", + }, + workspace, + reports: [ + { + serviceName: "preview", + url: "https://preview.example/run-1", + providerRef: "sandbox-123", + scopeType: "run", + }, + ], + now, + }); + + const second = normalizeAdapterManagedRuntimeServices({ + adapterType: "openclaw_gateway", + runId: "run-1", + agent: { + id: "agent-1", + name: "Gateway Agent", + companyId: "company-1", + }, + issue: { + id: "issue-1", + identifier: "PAP-447", + title: "Worktree support", + }, + workspace, + reports: [ + { + serviceName: "preview", + url: "https://preview.example/run-1", + providerRef: "sandbox-123", + scopeType: "run", + }, + ], + now, + }); + + expect(first).toHaveLength(1); + expect(first[0]).toMatchObject({ + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + issueId: "issue-1", + serviceName: "preview", + provider: "adapter_managed", + status: "running", + healthStatus: "healthy", + startedByRunId: "run-1", + }); + expect(first[0]?.id).toBe(second[0]?.id); + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 5220c4b1..c220df92 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -25,7 +25,7 @@ import { createApp } from "./app.js"; import { loadConfig } from "./config.js"; import { logger } from "./middleware/logger.js"; import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; -import { heartbeatService } from "./services/index.js"; +import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup } from "./services/index.js"; import { createStorageServiceFromConfig } from "./storage/index.js"; import { printStartupBanner } from "./startup-banner.js"; import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js"; @@ -495,6 +495,19 @@ export async function startServer(): Promise { deploymentMode: config.deploymentMode, resolveSessionFromHeaders, }); + + void reconcilePersistedRuntimeServicesOnStartup(db as any) + .then((result) => { + if (result.reconciled > 0) { + logger.warn( + { reconciled: result.reconciled }, + "reconciled persisted runtime services from a previous server process", + ); + } + }) + .catch((err) => { + logger.error({ err }, "startup reconciliation of persisted runtime services failed"); + }); if (config.heartbeatSchedulerEnabled) { const heartbeat = heartbeatService(db as any); @@ -503,7 +516,7 @@ export async function startServer(): Promise { void heartbeat.reapOrphanedRuns().catch((err) => { logger.error({ err }, "startup reap of orphaned heartbeat runs failed"); }); - + setInterval(() => { void heartbeat .tickTimers(new Date()) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index dbba40b2..7636bfb7 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -23,6 +23,14 @@ import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; import { secretService } from "./secrets.js"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; +import { + buildWorkspaceReadyComment, + ensureRuntimeServicesForRun, + persistAdapterManagedRuntimeServices, + realizeExecutionWorkspace, + releaseRuntimeServicesForRun, +} from "./workspace-runtime.js"; +import { issueService } from "./issues.js"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; @@ -406,6 +414,7 @@ function resolveNextSessionState(input: { export function heartbeatService(db: Db) { const runLogStore = getRunLogStore(); const secretsSvc = secretService(db); + const issuesSvc = issueService(db); async function getAgent(agentId: string) { return db @@ -1099,14 +1108,54 @@ export function heartbeatService(db: Db) { previousSessionParams, { useProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null }, ); + const config = parseObject(agent.adapterConfig); + const mergedConfig = issueAssigneeOverrides?.adapterConfig + ? { ...config, ...issueAssigneeOverrides.adapterConfig } + : config; + const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime( + agent.companyId, + mergedConfig, + ); + const issueRef = issueId + ? await db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) + .then((rows) => rows[0] ?? null) + : null; + const executionWorkspace = await realizeExecutionWorkspace({ + base: { + baseCwd: resolvedWorkspace.cwd, + source: resolvedWorkspace.source, + projectId: resolvedWorkspace.projectId, + workspaceId: resolvedWorkspace.workspaceId, + repoUrl: resolvedWorkspace.repoUrl, + repoRef: resolvedWorkspace.repoRef, + }, + config: resolvedConfig, + issue: issueRef, + agent: { + id: agent.id, + name: agent.name, + companyId: agent.companyId, + }, + }); const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({ agentId: agent.id, previousSessionParams, - resolvedWorkspace, + resolvedWorkspace: { + ...resolvedWorkspace, + cwd: executionWorkspace.cwd, + }, }); const runtimeSessionParams = runtimeSessionResolution.sessionParams; const runtimeWorkspaceWarnings = [ ...resolvedWorkspace.warnings, + ...executionWorkspace.warnings, ...(runtimeSessionResolution.warning ? [runtimeSessionResolution.warning] : []), ...(resetTaskSession && sessionResetReason ? [ @@ -1117,16 +1166,32 @@ export function heartbeatService(db: Db) { : []), ]; context.paperclipWorkspace = { - cwd: resolvedWorkspace.cwd, - source: resolvedWorkspace.source, - projectId: resolvedWorkspace.projectId, - workspaceId: resolvedWorkspace.workspaceId, - repoUrl: resolvedWorkspace.repoUrl, - repoRef: resolvedWorkspace.repoRef, + cwd: executionWorkspace.cwd, + source: executionWorkspace.source, + strategy: executionWorkspace.strategy, + projectId: executionWorkspace.projectId, + workspaceId: executionWorkspace.workspaceId, + repoUrl: executionWorkspace.repoUrl, + repoRef: executionWorkspace.repoRef, + branchName: executionWorkspace.branchName, + worktreePath: executionWorkspace.worktreePath, }; context.paperclipWorkspaces = resolvedWorkspace.workspaceHints; - if (resolvedWorkspace.projectId && !readNonEmptyString(context.projectId)) { - context.projectId = resolvedWorkspace.projectId; + const runtimeServiceIntents = (() => { + const runtimeConfig = parseObject(resolvedConfig.workspaceRuntime); + return Array.isArray(runtimeConfig.services) + ? runtimeConfig.services.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + })(); + if (runtimeServiceIntents.length > 0) { + context.paperclipRuntimeServiceIntents = runtimeServiceIntents; + } else { + delete context.paperclipRuntimeServiceIntents; + } + if (executionWorkspace.projectId && !readNonEmptyString(context.projectId)) { + context.projectId = executionWorkspace.projectId; } const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId; const previousSessionDisplayId = truncateDisplayId( @@ -1146,7 +1211,6 @@ export function heartbeatService(db: Db) { let handle: RunLogHandle | null = null; let stdoutExcerpt = ""; let stderrExcerpt = ""; - try { const startedAt = run.startedAt ?? new Date(); const runningWithSession = await db @@ -1154,6 +1218,7 @@ export function heartbeatService(db: Db) { .set({ startedAt, sessionIdBefore: runtimeForAdapter.sessionDisplayId ?? runtimeForAdapter.sessionId, + contextSnapshot: context, updatedAt: new Date(), }) .where(eq(heartbeatRuns.id, run.id)) @@ -1235,15 +1300,54 @@ export function heartbeatService(db: Db) { for (const warning of runtimeWorkspaceWarnings) { await onLog("stderr", `[paperclip] ${warning}\n`); } - - const config = parseObject(agent.adapterConfig); - const mergedConfig = issueAssigneeOverrides?.adapterConfig - ? { ...config, ...issueAssigneeOverrides.adapterConfig } - : config; - const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime( - agent.companyId, - mergedConfig, + const adapterEnv = Object.fromEntries( + Object.entries(parseObject(resolvedConfig.env)).filter( + (entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string", + ), ); + const runtimeServices = await ensureRuntimeServicesForRun({ + db, + runId: run.id, + agent: { + id: agent.id, + name: agent.name, + companyId: agent.companyId, + }, + issue: issueRef, + workspace: executionWorkspace, + config: resolvedConfig, + adapterEnv, + onLog, + }); + if (runtimeServices.length > 0) { + context.paperclipRuntimeServices = runtimeServices; + context.paperclipRuntimePrimaryUrl = + runtimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null; + await db + .update(heartbeatRuns) + .set({ + contextSnapshot: context, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, run.id)); + } + if (issueId && (executionWorkspace.created || runtimeServices.some((service) => !service.reused))) { + try { + await issuesSvc.addComment( + issueId, + buildWorkspaceReadyComment({ + workspace: executionWorkspace, + runtimeServices, + }), + { agentId: agent.id }, + ); + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to post workspace-ready comment: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } const onAdapterMeta = async (meta: AdapterInvocationMeta) => { if (meta.env && secretKeys.size > 0) { for (const key of secretKeys) { @@ -1284,6 +1388,54 @@ export function heartbeatService(db: Db) { onMeta: onAdapterMeta, authToken: authToken ?? undefined, }); + const adapterManagedRuntimeServices = adapterResult.runtimeServices + ? await persistAdapterManagedRuntimeServices({ + db, + adapterType: agent.adapterType, + runId: run.id, + agent: { + id: agent.id, + name: agent.name, + companyId: agent.companyId, + }, + issue: issueRef, + workspace: executionWorkspace, + reports: adapterResult.runtimeServices, + }) + : []; + if (adapterManagedRuntimeServices.length > 0) { + const combinedRuntimeServices = [ + ...runtimeServices, + ...adapterManagedRuntimeServices, + ]; + context.paperclipRuntimeServices = combinedRuntimeServices; + context.paperclipRuntimePrimaryUrl = + combinedRuntimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null; + await db + .update(heartbeatRuns) + .set({ + contextSnapshot: context, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, run.id)); + if (issueId) { + try { + await issuesSvc.addComment( + issueId, + buildWorkspaceReadyComment({ + workspace: executionWorkspace, + runtimeServices: adapterManagedRuntimeServices, + }), + { agentId: agent.id }, + ); + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to post adapter-managed runtime comment: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } + } const nextSessionState = resolveNextSessionState({ codec: sessionCodec, adapterResult, @@ -1460,6 +1612,7 @@ export function heartbeatService(db: Db) { await finalizeAgentStatus(agent.id, "failed"); } finally { + await releaseRuntimeServicesForRun(run.id); await startNextQueuedRunForAgent(agent.id); } } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 0dfe46ab..99a950c5 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -17,4 +17,5 @@ export { companyPortabilityService } from "./company-portability.js"; export { logActivity, type LogActivityInput } from "./activity-log.js"; export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js"; export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js"; +export { reconcilePersistedRuntimeServicesOnStartup } from "./workspace-runtime.js"; export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js"; diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 54d5cd82..b5f5662b 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -1,6 +1,6 @@ import { and, asc, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { projects, projectGoals, goals, projectWorkspaces } from "@paperclipai/db"; +import { projects, projectGoals, goals, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; import { PROJECT_COLORS, deriveProjectUrlKey, @@ -8,10 +8,13 @@ import { normalizeProjectUrlKey, type ProjectGoalRef, type ProjectWorkspace, + type WorkspaceRuntimeService, } from "@paperclipai/shared"; +import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js"; type ProjectRow = typeof projects.$inferSelect; type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; +type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; type CreateWorkspaceInput = { name?: string | null; @@ -78,7 +81,41 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise | null) ?? null, + healthStatus: row.healthStatus as WorkspaceRuntimeService["healthStatus"], + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function toWorkspace( + row: ProjectWorkspaceRow, + runtimeServices: WorkspaceRuntimeService[] = [], +): ProjectWorkspace { return { id: row.id, companyId: row.companyId, @@ -89,15 +126,20 @@ function toWorkspace(row: ProjectWorkspaceRow): ProjectWorkspace { repoRef: row.repoRef ?? null, metadata: (row.metadata as Record | null) ?? null, isPrimary: row.isPrimary, + runtimeServices, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } -function pickPrimaryWorkspace(rows: ProjectWorkspaceRow[]): ProjectWorkspace | null { +function pickPrimaryWorkspace( + rows: ProjectWorkspaceRow[], + runtimeServicesByWorkspaceId?: Map, +): ProjectWorkspace | null { if (rows.length === 0) return null; const explicitPrimary = rows.find((row) => row.isPrimary); - return toWorkspace(explicitPrimary ?? rows[0]); + const primary = explicitPrimary ?? rows[0]; + return toWorkspace(primary, runtimeServicesByWorkspaceId?.get(primary.id) ?? []); } /** Batch-load workspace refs for a set of projects. */ @@ -110,6 +152,17 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise workspace.id), + ); + const sharedRuntimeServicesByWorkspaceId = new Map( + Array.from(runtimeServicesByWorkspaceId.entries()).map(([workspaceId, services]) => [ + workspaceId, + services.map(toRuntimeService), + ]), + ); const map = new Map(); for (const row of workspaceRows) { @@ -123,11 +176,16 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise { const projectWorkspaceRows = map.get(row.id) ?? []; - const workspaces = projectWorkspaceRows.map(toWorkspace); + const workspaces = projectWorkspaceRows.map((workspace) => + toWorkspace( + workspace, + sharedRuntimeServicesByWorkspaceId.get(workspace.id) ?? [], + ), + ); return { ...row, workspaces, - primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows), + primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows, sharedRuntimeServicesByWorkspaceId), }; }); } @@ -402,7 +460,18 @@ export function projectService(db: Db) { .from(projectWorkspaces) .where(eq(projectWorkspaces.projectId, projectId)) .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); - return rows.map(toWorkspace); + if (rows.length === 0) return []; + const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces( + db, + rows[0]!.companyId, + rows.map((workspace) => workspace.id), + ); + return rows.map((row) => + toWorkspace( + row, + (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService), + ), + ); }, createWorkspace: async ( diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts new file mode 100644 index 00000000..8c9d875c --- /dev/null +++ b/server/src/services/workspace-runtime.ts @@ -0,0 +1,962 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs/promises"; +import net from "node:net"; +import { createHash, randomUUID } from "node:crypto"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils"; +import type { Db } from "@paperclipai/db"; +import { workspaceRuntimeServices } from "@paperclipai/db"; +import { and, desc, eq, inArray } from "drizzle-orm"; +import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js"; +import { resolveHomeAwarePath } from "../home-paths.js"; + +export interface ExecutionWorkspaceInput { + baseCwd: string; + source: "project_primary" | "task_session" | "agent_home"; + projectId: string | null; + workspaceId: string | null; + repoUrl: string | null; + repoRef: string | null; +} + +export interface ExecutionWorkspaceIssueRef { + id: string; + identifier: string | null; + title: string | null; +} + +export interface ExecutionWorkspaceAgentRef { + id: string; + name: string; + companyId: string; +} + +export interface RealizedExecutionWorkspace extends ExecutionWorkspaceInput { + strategy: "project_primary" | "git_worktree"; + cwd: string; + branchName: string | null; + worktreePath: string | null; + warnings: string[]; + created: boolean; +} + +export interface RuntimeServiceRef { + id: string; + companyId: string; + projectId: string | null; + projectWorkspaceId: string | null; + issueId: string | null; + serviceName: string; + status: "starting" | "running" | "stopped" | "failed"; + lifecycle: "shared" | "ephemeral"; + scopeType: "project_workspace" | "execution_workspace" | "run" | "agent"; + scopeId: string | null; + reuseKey: string | null; + command: string | null; + cwd: string | null; + port: number | null; + url: string | null; + provider: "local_process" | "adapter_managed"; + providerRef: string | null; + ownerAgentId: string | null; + startedByRunId: string | null; + lastUsedAt: string; + startedAt: string; + stoppedAt: string | null; + stopPolicy: Record | null; + healthStatus: "unknown" | "healthy" | "unhealthy"; + reused: boolean; +} + +interface RuntimeServiceRecord extends RuntimeServiceRef { + db?: Db; + child: ChildProcess | null; + leaseRunIds: Set; + idleTimer: ReturnType | null; + envFingerprint: string; +} + +const runtimeServicesById = new Map(); +const runtimeServicesByReuseKey = new Map(); +const runtimeServiceLeasesByRun = new Map(); + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + } + if (value && typeof value === "object") { + const rec = value as Record; + return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(rec[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function stableRuntimeServiceId(input: { + adapterType: string; + runId: string; + scopeType: RuntimeServiceRef["scopeType"]; + scopeId: string | null; + serviceName: string; + reportId: string | null; + providerRef: string | null; + reuseKey: string | null; +}) { + if (input.reportId) return input.reportId; + const digest = createHash("sha256") + .update( + stableStringify({ + adapterType: input.adapterType, + runId: input.runId, + scopeType: input.scopeType, + scopeId: input.scopeId, + serviceName: input.serviceName, + providerRef: input.providerRef, + reuseKey: input.reuseKey, + }), + ) + .digest("hex") + .slice(0, 32); + return `${input.adapterType}-${digest}`; +} + +function toRuntimeServiceRef(record: RuntimeServiceRecord, overrides?: Partial): RuntimeServiceRef { + return { + id: record.id, + companyId: record.companyId, + projectId: record.projectId, + projectWorkspaceId: record.projectWorkspaceId, + issueId: record.issueId, + serviceName: record.serviceName, + status: record.status, + lifecycle: record.lifecycle, + scopeType: record.scopeType, + scopeId: record.scopeId, + reuseKey: record.reuseKey, + command: record.command, + cwd: record.cwd, + port: record.port, + url: record.url, + provider: record.provider, + providerRef: record.providerRef, + ownerAgentId: record.ownerAgentId, + startedByRunId: record.startedByRunId, + lastUsedAt: record.lastUsedAt, + startedAt: record.startedAt, + stoppedAt: record.stoppedAt, + stopPolicy: record.stopPolicy, + healthStatus: record.healthStatus, + reused: record.reused, + ...overrides, + }; +} + +function sanitizeSlugPart(value: string | null | undefined, fallback: string): string { + const raw = (value ?? "").trim().toLowerCase(); + const normalized = raw + .replace(/[^a-z0-9/_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-/]+|[-/]+$/g, ""); + return normalized.length > 0 ? normalized : fallback; +} + +function renderWorkspaceTemplate(template: string, input: { + issue: ExecutionWorkspaceIssueRef | null; + agent: ExecutionWorkspaceAgentRef; + projectId: string | null; + repoRef: string | null; +}) { + const issueIdentifier = input.issue?.identifier ?? input.issue?.id ?? "issue"; + const slug = sanitizeSlugPart(input.issue?.title, sanitizeSlugPart(issueIdentifier, "issue")); + return renderTemplate(template, { + issue: { + id: input.issue?.id ?? "", + identifier: input.issue?.identifier ?? "", + title: input.issue?.title ?? "", + }, + agent: { + id: input.agent.id, + name: input.agent.name, + }, + project: { + id: input.projectId ?? "", + }, + workspace: { + repoRef: input.repoRef ?? "", + }, + slug, + }); +} + +function sanitizeBranchName(value: string): string { + return value + .trim() + .replace(/[^A-Za-z0-9._/-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-/.]+|[-/.]+$/g, "") + .slice(0, 120) || "paperclip-work"; +} + +function isAbsolutePath(value: string) { + return path.isAbsolute(value) || value.startsWith("~"); +} + +function resolveConfiguredPath(value: string, baseDir: string): string { + if (isAbsolutePath(value)) { + return resolveHomeAwarePath(value); + } + return path.resolve(baseDir, value); +} + +async function runGit(args: string[], cwd: string): Promise { + const proc = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => { + const child = spawn("git", args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("error", reject); + child.on("close", (code) => resolve({ stdout, stderr, code })); + }); + if (proc.code !== 0) { + throw new Error(proc.stderr.trim() || proc.stdout.trim() || `git ${args.join(" ")} failed`); + } + return proc.stdout.trim(); +} + +async function directoryExists(value: string) { + return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false); +} + +export async function realizeExecutionWorkspace(input: { + base: ExecutionWorkspaceInput; + config: Record; + issue: ExecutionWorkspaceIssueRef | null; + agent: ExecutionWorkspaceAgentRef; +}): Promise { + const rawStrategy = parseObject(input.config.workspaceStrategy); + const strategyType = asString(rawStrategy.type, "project_primary"); + if (strategyType !== "git_worktree") { + return { + ...input.base, + strategy: "project_primary", + cwd: input.base.baseCwd, + branchName: null, + worktreePath: null, + warnings: [], + created: false, + }; + } + + const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd); + const branchTemplate = asString(rawStrategy.branchTemplate, "{{issue.identifier}}-{{slug}}"); + const renderedBranch = renderWorkspaceTemplate(branchTemplate, { + issue: input.issue, + agent: input.agent, + projectId: input.base.projectId, + repoRef: input.base.repoRef, + }); + const branchName = sanitizeBranchName(renderedBranch); + const configuredParentDir = asString(rawStrategy.worktreeParentDir, ""); + const worktreeParentDir = configuredParentDir + ? resolveConfiguredPath(configuredParentDir, repoRoot) + : path.join(repoRoot, ".paperclip", "worktrees"); + const worktreePath = path.join(worktreeParentDir, branchName); + const baseRef = asString(rawStrategy.baseRef, input.base.repoRef ?? "HEAD"); + + await fs.mkdir(worktreeParentDir, { recursive: true }); + + const existingWorktree = await directoryExists(worktreePath); + if (existingWorktree) { + const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null); + if (existingGitDir) { + return { + ...input.base, + strategy: "git_worktree", + cwd: worktreePath, + branchName, + worktreePath, + warnings: [], + created: false, + }; + } + throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a git worktree.`); + } + + await runGit(["worktree", "add", "-B", branchName, worktreePath, baseRef], repoRoot); + + return { + ...input.base, + strategy: "git_worktree", + cwd: worktreePath, + branchName, + worktreePath, + warnings: [], + created: true, + }; +} + +async function allocatePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close((err) => { + if (err) { + reject(err); + return; + } + if (!address || typeof address === "string") { + reject(new Error("Failed to allocate port")); + return; + } + resolve(address.port); + }); + }); + server.on("error", reject); + }); +} + +function buildTemplateData(input: { + workspace: RealizedExecutionWorkspace; + agent: ExecutionWorkspaceAgentRef; + issue: ExecutionWorkspaceIssueRef | null; + adapterEnv: Record; + port: number | null; +}) { + return { + workspace: { + cwd: input.workspace.cwd, + branchName: input.workspace.branchName ?? "", + worktreePath: input.workspace.worktreePath ?? "", + repoUrl: input.workspace.repoUrl ?? "", + repoRef: input.workspace.repoRef ?? "", + env: input.adapterEnv, + }, + issue: { + id: input.issue?.id ?? "", + identifier: input.issue?.identifier ?? "", + title: input.issue?.title ?? "", + }, + agent: { + id: input.agent.id, + name: input.agent.name, + }, + port: input.port ?? "", + }; +} + +function resolveServiceScopeId(input: { + service: Record; + workspace: RealizedExecutionWorkspace; + issue: ExecutionWorkspaceIssueRef | null; + runId: string; + agent: ExecutionWorkspaceAgentRef; +}): { + scopeType: "project_workspace" | "execution_workspace" | "run" | "agent"; + scopeId: string | null; +} { + const scopeTypeRaw = asString(input.service.reuseScope, input.service.lifecycle === "shared" ? "project_workspace" : "run"); + const scopeType = + scopeTypeRaw === "project_workspace" || + scopeTypeRaw === "execution_workspace" || + scopeTypeRaw === "agent" + ? scopeTypeRaw + : "run"; + if (scopeType === "project_workspace") return { scopeType, scopeId: input.workspace.workspaceId ?? input.workspace.projectId }; + if (scopeType === "execution_workspace") return { scopeType, scopeId: input.workspace.cwd }; + if (scopeType === "agent") return { scopeType, scopeId: input.agent.id }; + return { scopeType: "run" as const, scopeId: input.runId }; +} + +async function waitForReadiness(input: { + service: Record; + url: string | null; +}) { + const readiness = parseObject(input.service.readiness); + const readinessType = asString(readiness.type, ""); + if (readinessType !== "http" || !input.url) return; + const timeoutSec = Math.max(1, asNumber(readiness.timeoutSec, 30)); + const intervalMs = Math.max(100, asNumber(readiness.intervalMs, 500)); + const deadline = Date.now() + timeoutSec * 1000; + let lastError = "service did not become ready"; + while (Date.now() < deadline) { + try { + const response = await fetch(input.url); + if (response.ok) return; + lastError = `received HTTP ${response.status}`; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + await delay(intervalMs); + } + throw new Error(`Readiness check failed for ${input.url}: ${lastError}`); +} + +function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert { + return { + id: record.id, + companyId: record.companyId, + projectId: record.projectId, + projectWorkspaceId: record.projectWorkspaceId, + issueId: record.issueId, + scopeType: record.scopeType, + scopeId: record.scopeId, + serviceName: record.serviceName, + status: record.status, + lifecycle: record.lifecycle, + reuseKey: record.reuseKey, + command: record.command, + cwd: record.cwd, + port: record.port, + url: record.url, + provider: record.provider, + providerRef: record.providerRef, + ownerAgentId: record.ownerAgentId, + startedByRunId: record.startedByRunId, + lastUsedAt: new Date(record.lastUsedAt), + startedAt: new Date(record.startedAt), + stoppedAt: record.stoppedAt ? new Date(record.stoppedAt) : null, + stopPolicy: record.stopPolicy, + healthStatus: record.healthStatus, + updatedAt: new Date(), + }; +} + +async function persistRuntimeServiceRecord(db: Db | undefined, record: RuntimeServiceRecord) { + if (!db) return; + const values = toPersistedWorkspaceRuntimeService(record); + await db + .insert(workspaceRuntimeServices) + .values(values) + .onConflictDoUpdate({ + target: workspaceRuntimeServices.id, + set: { + projectId: values.projectId, + projectWorkspaceId: values.projectWorkspaceId, + issueId: values.issueId, + scopeType: values.scopeType, + scopeId: values.scopeId, + serviceName: values.serviceName, + status: values.status, + lifecycle: values.lifecycle, + reuseKey: values.reuseKey, + command: values.command, + cwd: values.cwd, + port: values.port, + url: values.url, + provider: values.provider, + providerRef: values.providerRef, + ownerAgentId: values.ownerAgentId, + startedByRunId: values.startedByRunId, + lastUsedAt: values.lastUsedAt, + startedAt: values.startedAt, + stoppedAt: values.stoppedAt, + stopPolicy: values.stopPolicy, + healthStatus: values.healthStatus, + updatedAt: values.updatedAt, + }, + }); +} + +function clearIdleTimer(record: RuntimeServiceRecord) { + if (!record.idleTimer) return; + clearTimeout(record.idleTimer); + record.idleTimer = null; +} + +export function normalizeAdapterManagedRuntimeServices(input: { + adapterType: string; + runId: string; + agent: ExecutionWorkspaceAgentRef; + issue: ExecutionWorkspaceIssueRef | null; + workspace: RealizedExecutionWorkspace; + reports: AdapterRuntimeServiceReport[]; + now?: Date; +}): RuntimeServiceRef[] { + const nowIso = (input.now ?? new Date()).toISOString(); + return input.reports.map((report) => { + const scopeType = report.scopeType ?? "run"; + const scopeId = + report.scopeId ?? + (scopeType === "project_workspace" + ? input.workspace.workspaceId + : scopeType === "execution_workspace" + ? input.workspace.cwd + : scopeType === "agent" + ? input.agent.id + : input.runId) ?? + null; + const serviceName = asString(report.serviceName, "").trim() || "service"; + const status = report.status ?? "running"; + const lifecycle = report.lifecycle ?? "ephemeral"; + const healthStatus = + report.healthStatus ?? + (status === "running" ? "healthy" : status === "failed" ? "unhealthy" : "unknown"); + return { + id: stableRuntimeServiceId({ + adapterType: input.adapterType, + runId: input.runId, + scopeType, + scopeId, + serviceName, + reportId: report.id ?? null, + providerRef: report.providerRef ?? null, + reuseKey: report.reuseKey ?? null, + }), + companyId: input.agent.companyId, + projectId: report.projectId ?? input.workspace.projectId, + projectWorkspaceId: report.projectWorkspaceId ?? input.workspace.workspaceId, + issueId: report.issueId ?? input.issue?.id ?? null, + serviceName, + status, + lifecycle, + scopeType, + scopeId, + reuseKey: report.reuseKey ?? null, + command: report.command ?? null, + cwd: report.cwd ?? null, + port: report.port ?? null, + url: report.url ?? null, + provider: "adapter_managed", + providerRef: report.providerRef ?? null, + ownerAgentId: report.ownerAgentId ?? input.agent.id, + startedByRunId: input.runId, + lastUsedAt: nowIso, + startedAt: nowIso, + stoppedAt: status === "running" || status === "starting" ? null : nowIso, + stopPolicy: report.stopPolicy ?? null, + healthStatus, + reused: false, + }; + }); +} + +async function startLocalRuntimeService(input: { + db?: Db; + runId: string; + agent: ExecutionWorkspaceAgentRef; + issue: ExecutionWorkspaceIssueRef | null; + workspace: RealizedExecutionWorkspace; + adapterEnv: Record; + service: Record; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + reuseKey: string | null; + scopeType: "project_workspace" | "execution_workspace" | "run" | "agent"; + scopeId: string | null; +}): Promise { + const serviceName = asString(input.service.name, "service"); + const lifecycle = asString(input.service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared"; + const command = asString(input.service.command, ""); + if (!command) throw new Error(`Runtime service "${serviceName}" is missing command`); + const serviceCwdTemplate = asString(input.service.cwd, "."); + const portConfig = parseObject(input.service.port); + const port = asString(portConfig.type, "") === "auto" ? await allocatePort() : null; + const envConfig = parseObject(input.service.env); + const templateData = buildTemplateData({ + workspace: input.workspace, + agent: input.agent, + issue: input.issue, + adapterEnv: input.adapterEnv, + port, + }); + const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd); + const env: Record = { ...process.env, ...input.adapterEnv } as Record; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") { + env[key] = renderTemplate(value, templateData); + } + } + if (port) { + const portEnvKey = asString(portConfig.envKey, "PORT"); + env[portEnvKey] = String(port); + } + const shell = process.env.SHELL?.trim() || "/bin/sh"; + const child = spawn(shell, ["-lc", command], { + cwd: serviceCwd, + env, + detached: false, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderrExcerpt = ""; + let stdoutExcerpt = ""; + child.stdout?.on("data", async (chunk) => { + const text = String(chunk); + stdoutExcerpt = (stdoutExcerpt + text).slice(-4096); + if (input.onLog) await input.onLog("stdout", `[service:${serviceName}] ${text}`); + }); + child.stderr?.on("data", async (chunk) => { + const text = String(chunk); + stderrExcerpt = (stderrExcerpt + text).slice(-4096); + if (input.onLog) await input.onLog("stderr", `[service:${serviceName}] ${text}`); + }); + + const expose = parseObject(input.service.expose); + const readiness = parseObject(input.service.readiness); + const urlTemplate = + asString(expose.urlTemplate, "") || + asString(readiness.urlTemplate, ""); + const url = urlTemplate ? renderTemplate(urlTemplate, templateData) : null; + + try { + await waitForReadiness({ service: input.service, url }); + } catch (err) { + child.kill("SIGTERM"); + throw new Error( + `Failed to start runtime service "${serviceName}": ${err instanceof Error ? err.message : String(err)}${stderrExcerpt ? ` | stderr: ${stderrExcerpt.trim()}` : ""}`, + ); + } + + const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex"); + return { + id: randomUUID(), + companyId: input.agent.companyId, + projectId: input.workspace.projectId, + projectWorkspaceId: input.workspace.workspaceId, + issueId: input.issue?.id ?? null, + serviceName, + status: "running", + lifecycle, + scopeType: input.scopeType, + scopeId: input.scopeId, + reuseKey: input.reuseKey, + command, + cwd: serviceCwd, + port, + url, + provider: "local_process", + providerRef: child.pid ? String(child.pid) : null, + ownerAgentId: input.agent.id, + startedByRunId: input.runId, + lastUsedAt: new Date().toISOString(), + startedAt: new Date().toISOString(), + stoppedAt: null, + stopPolicy: parseObject(input.service.stopPolicy), + healthStatus: "healthy", + reused: false, + db: input.db, + child, + leaseRunIds: new Set([input.runId]), + idleTimer: null, + envFingerprint, + }; +} + +function scheduleIdleStop(record: RuntimeServiceRecord) { + clearIdleTimer(record); + const stopType = asString(record.stopPolicy?.type, "manual"); + if (stopType !== "idle_timeout") return; + const idleSeconds = Math.max(1, asNumber(record.stopPolicy?.idleSeconds, 1800)); + record.idleTimer = setTimeout(() => { + stopRuntimeService(record.id).catch(() => undefined); + }, idleSeconds * 1000); +} + +async function stopRuntimeService(serviceId: string) { + const record = runtimeServicesById.get(serviceId); + if (!record) return; + clearIdleTimer(record); + record.status = "stopped"; + record.lastUsedAt = new Date().toISOString(); + record.stoppedAt = new Date().toISOString(); + if (record.child && !record.child.killed) { + record.child.kill("SIGTERM"); + } + runtimeServicesById.delete(serviceId); + if (record.reuseKey) { + runtimeServicesByReuseKey.delete(record.reuseKey); + } + await persistRuntimeServiceRecord(record.db, record); +} + +function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord) { + record.db = db; + runtimeServicesById.set(record.id, record); + if (record.reuseKey) { + runtimeServicesByReuseKey.set(record.reuseKey, record.id); + } + + record.child?.on("exit", (code, signal) => { + const current = runtimeServicesById.get(record.id); + if (!current) return; + clearIdleTimer(current); + current.status = code === 0 || signal === "SIGTERM" ? "stopped" : "failed"; + current.healthStatus = current.status === "failed" ? "unhealthy" : "unknown"; + current.lastUsedAt = new Date().toISOString(); + current.stoppedAt = new Date().toISOString(); + runtimeServicesById.delete(current.id); + if (current.reuseKey && runtimeServicesByReuseKey.get(current.reuseKey) === current.id) { + runtimeServicesByReuseKey.delete(current.reuseKey); + } + void persistRuntimeServiceRecord(db, current); + }); +} + +export async function ensureRuntimeServicesForRun(input: { + db?: Db; + runId: string; + agent: ExecutionWorkspaceAgentRef; + issue: ExecutionWorkspaceIssueRef | null; + workspace: RealizedExecutionWorkspace; + config: Record; + adapterEnv: Record; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; +}): Promise { + const runtime = parseObject(input.config.workspaceRuntime); + const rawServices = Array.isArray(runtime.services) + ? runtime.services.filter((entry): entry is Record => typeof entry === "object" && entry !== null) + : []; + const acquiredServiceIds: string[] = []; + const refs: RuntimeServiceRef[] = []; + runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds); + + try { + for (const service of rawServices) { + const lifecycle = asString(service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared"; + const { scopeType, scopeId } = resolveServiceScopeId({ + service, + workspace: input.workspace, + issue: input.issue, + runId: input.runId, + agent: input.agent, + }); + const envConfig = parseObject(service.env); + const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex"); + const serviceName = asString(service.name, "service"); + const reuseKey = + lifecycle === "shared" + ? [scopeType, scopeId ?? "", serviceName, envFingerprint].join(":") + : null; + + if (reuseKey) { + const existingId = runtimeServicesByReuseKey.get(reuseKey); + const existing = existingId ? runtimeServicesById.get(existingId) : null; + if (existing && existing.status === "running") { + existing.leaseRunIds.add(input.runId); + existing.lastUsedAt = new Date().toISOString(); + existing.stoppedAt = null; + clearIdleTimer(existing); + await persistRuntimeServiceRecord(input.db, existing); + acquiredServiceIds.push(existing.id); + refs.push(toRuntimeServiceRef(existing, { reused: true })); + continue; + } + } + + const record = await startLocalRuntimeService({ + db: input.db, + runId: input.runId, + agent: input.agent, + issue: input.issue, + workspace: input.workspace, + adapterEnv: input.adapterEnv, + service, + onLog: input.onLog, + reuseKey, + scopeType, + scopeId, + }); + registerRuntimeService(input.db, record); + await persistRuntimeServiceRecord(input.db, record); + acquiredServiceIds.push(record.id); + refs.push(toRuntimeServiceRef(record)); + } + } catch (err) { + await releaseRuntimeServicesForRun(input.runId); + throw err; + } + + return refs; +} + +export async function releaseRuntimeServicesForRun(runId: string) { + const acquired = runtimeServiceLeasesByRun.get(runId) ?? []; + runtimeServiceLeasesByRun.delete(runId); + for (const serviceId of acquired) { + const record = runtimeServicesById.get(serviceId); + if (!record) continue; + record.leaseRunIds.delete(runId); + record.lastUsedAt = new Date().toISOString(); + const stopType = asString(record.stopPolicy?.type, record.lifecycle === "ephemeral" ? "on_run_finish" : "manual"); + await persistRuntimeServiceRecord(record.db, record); + if (record.leaseRunIds.size === 0) { + if (record.lifecycle === "ephemeral" || stopType === "on_run_finish") { + await stopRuntimeService(serviceId); + continue; + } + scheduleIdleStop(record); + } + } +} + +export async function listWorkspaceRuntimeServicesForProjectWorkspaces( + db: Db, + companyId: string, + projectWorkspaceIds: string[], +) { + if (projectWorkspaceIds.length === 0) return new Map(); + const rows = await db + .select() + .from(workspaceRuntimeServices) + .where( + and( + eq(workspaceRuntimeServices.companyId, companyId), + inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds), + ), + ) + .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); + + const grouped = new Map(); + for (const row of rows) { + if (!row.projectWorkspaceId) continue; + const existing = grouped.get(row.projectWorkspaceId); + if (existing) existing.push(row); + else grouped.set(row.projectWorkspaceId, [row]); + } + return grouped; +} + +export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) { + const staleRows = await db + .select({ id: workspaceRuntimeServices.id }) + .from(workspaceRuntimeServices) + .where( + and( + eq(workspaceRuntimeServices.provider, "local_process"), + inArray(workspaceRuntimeServices.status, ["starting", "running"]), + ), + ); + + if (staleRows.length === 0) return { reconciled: 0 }; + + const now = new Date(); + await db + .update(workspaceRuntimeServices) + .set({ + status: "stopped", + healthStatus: "unknown", + stoppedAt: now, + lastUsedAt: now, + updatedAt: now, + }) + .where( + and( + eq(workspaceRuntimeServices.provider, "local_process"), + inArray(workspaceRuntimeServices.status, ["starting", "running"]), + ), + ); + + return { reconciled: staleRows.length }; +} + +export async function persistAdapterManagedRuntimeServices(input: { + db: Db; + adapterType: string; + runId: string; + agent: ExecutionWorkspaceAgentRef; + issue: ExecutionWorkspaceIssueRef | null; + workspace: RealizedExecutionWorkspace; + reports: AdapterRuntimeServiceReport[]; +}) { + const refs = normalizeAdapterManagedRuntimeServices(input); + if (refs.length === 0) return refs; + + const existingRows = await input.db + .select() + .from(workspaceRuntimeServices) + .where(inArray(workspaceRuntimeServices.id, refs.map((ref) => ref.id))); + const existingById = new Map(existingRows.map((row) => [row.id, row])); + + for (const ref of refs) { + const existing = existingById.get(ref.id); + const startedAt = existing?.startedAt ?? new Date(ref.startedAt); + const createdAt = existing?.createdAt ?? new Date(); + await input.db + .insert(workspaceRuntimeServices) + .values({ + id: ref.id, + companyId: ref.companyId, + projectId: ref.projectId, + projectWorkspaceId: ref.projectWorkspaceId, + issueId: ref.issueId, + scopeType: ref.scopeType, + scopeId: ref.scopeId, + serviceName: ref.serviceName, + status: ref.status, + lifecycle: ref.lifecycle, + reuseKey: ref.reuseKey, + command: ref.command, + cwd: ref.cwd, + port: ref.port, + url: ref.url, + provider: ref.provider, + providerRef: ref.providerRef, + ownerAgentId: ref.ownerAgentId, + startedByRunId: ref.startedByRunId, + lastUsedAt: new Date(ref.lastUsedAt), + startedAt, + stoppedAt: ref.stoppedAt ? new Date(ref.stoppedAt) : null, + stopPolicy: ref.stopPolicy, + healthStatus: ref.healthStatus, + createdAt, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: workspaceRuntimeServices.id, + set: { + projectId: ref.projectId, + projectWorkspaceId: ref.projectWorkspaceId, + issueId: ref.issueId, + scopeType: ref.scopeType, + scopeId: ref.scopeId, + serviceName: ref.serviceName, + status: ref.status, + lifecycle: ref.lifecycle, + reuseKey: ref.reuseKey, + command: ref.command, + cwd: ref.cwd, + port: ref.port, + url: ref.url, + provider: ref.provider, + providerRef: ref.providerRef, + ownerAgentId: ref.ownerAgentId, + startedByRunId: ref.startedByRunId, + lastUsedAt: new Date(ref.lastUsedAt), + startedAt, + stoppedAt: ref.stoppedAt ? new Date(ref.stoppedAt) : null, + stopPolicy: ref.stopPolicy, + healthStatus: ref.healthStatus, + updatedAt: new Date(), + }, + }); + } + + return refs; +} + +export function buildWorkspaceReadyComment(input: { + workspace: RealizedExecutionWorkspace; + runtimeServices: RuntimeServiceRef[]; +}) { + const lines = ["## Workspace Ready", ""]; + lines.push(`- Strategy: \`${input.workspace.strategy}\``); + if (input.workspace.branchName) lines.push(`- Branch: \`${input.workspace.branchName}\``); + lines.push(`- CWD: \`${input.workspace.cwd}\``); + if (input.workspace.worktreePath && input.workspace.worktreePath !== input.workspace.cwd) { + lines.push(`- Worktree: \`${input.workspace.worktreePath}\``); + } + for (const service of input.runtimeServices) { + const detail = service.url ? `${service.serviceName}: ${service.url}` : `${service.serviceName}: running`; + const suffix = service.reused ? " (reused)" : ""; + lines.push(`- Service: ${detail}${suffix}`); + } + return lines.join("\n"); +} diff --git a/ui/src/adapters/claude-local/config-fields.tsx b/ui/src/adapters/claude-local/config-fields.tsx index 5dd4f5ab..33d8a896 100644 --- a/ui/src/adapters/claude-local/config-fields.tsx +++ b/ui/src/adapters/claude-local/config-fields.tsx @@ -7,6 +7,7 @@ import { help, } from "../../components/agent-config-primitives"; import { ChoosePathButton } from "../../components/PathInstructionsModal"; +import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields"; const inputClass = "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; @@ -15,38 +16,54 @@ const instructionsFileHint = "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; export function ClaudeLocalConfigFields({ + mode, isCreate, + adapterType, values, set, config, eff, mark, + models, }: AdapterConfigFieldsProps) { return ( - -
- - isCreate - ? set!({ instructionsFilePath: v }) - : mark("adapterConfig", "instructionsFilePath", v || undefined) - } - immediate - className={inputClass} - placeholder="/absolute/path/to/AGENTS.md" - /> - -
-
+ <> + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ + ); } diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 86baff6c..77a930f2 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -6,6 +6,7 @@ import { help, } from "../../components/agent-config-primitives"; import { ChoosePathButton } from "../../components/PathInstructionsModal"; +import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields"; const inputClass = "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; @@ -13,12 +14,15 @@ const instructionsFileHint = "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; export function CodexLocalConfigFields({ + mode, isCreate, + adapterType, values, set, config, eff, mark, + models, }: AdapterConfigFieldsProps) { const bypassEnabled = config.dangerouslyBypassApprovalsAndSandbox === true || config.dangerouslyBypassSandbox === true; @@ -81,6 +85,17 @@ export function CodexLocalConfigFields({ : mark("adapterConfig", "search", v) } /> + ); } diff --git a/ui/src/adapters/local-workspace-runtime-fields.tsx b/ui/src/adapters/local-workspace-runtime-fields.tsx new file mode 100644 index 00000000..feba1024 --- /dev/null +++ b/ui/src/adapters/local-workspace-runtime-fields.tsx @@ -0,0 +1,136 @@ +import type { AdapterConfigFieldsProps } from "./types"; +import { DraftInput, Field, help } from "../components/agent-config-primitives"; +import { RuntimeServicesJsonField } from "./runtime-json-fields"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function readWorkspaceStrategy(config: Record) { + const strategy = asRecord(config.workspaceStrategy); + const type = asString(strategy.type) || "project_primary"; + return { + type, + baseRef: asString(strategy.baseRef), + branchTemplate: asString(strategy.branchTemplate), + worktreeParentDir: asString(strategy.worktreeParentDir), + }; +} + +function buildWorkspaceStrategyPatch(input: { + type: string; + baseRef?: string; + branchTemplate?: string; + worktreeParentDir?: string; +}) { + if (input.type !== "git_worktree") return undefined; + return { + type: "git_worktree", + ...(input.baseRef ? { baseRef: input.baseRef } : {}), + ...(input.branchTemplate ? { branchTemplate: input.branchTemplate } : {}), + ...(input.worktreeParentDir ? { worktreeParentDir: input.worktreeParentDir } : {}), + }; +} + +export function LocalWorkspaceRuntimeFields({ + isCreate, + values, + set, + config, + mark, +}: AdapterConfigFieldsProps) { + const existing = readWorkspaceStrategy(config); + const strategyType = isCreate ? values!.workspaceStrategyType ?? "project_primary" : existing.type; + const updateEditWorkspaceStrategy = (patch: Partial) => { + const next = { + ...existing, + ...patch, + }; + mark( + "adapterConfig", + "workspaceStrategy", + buildWorkspaceStrategyPatch(next), + ); + }; + return ( + <> + + + + + {strategyType === "git_worktree" && ( + <> + + + isCreate + ? set!({ workspaceBaseRef: v }) + : updateEditWorkspaceStrategy({ baseRef: v || "" }) + } + immediate + className={inputClass} + placeholder="origin/main" + /> + + + + isCreate + ? set!({ workspaceBranchTemplate: v }) + : updateEditWorkspaceStrategy({ branchTemplate: v || "" }) + } + immediate + className={inputClass} + placeholder="{{issue.identifier}}-{{slug}}" + /> + + + + isCreate + ? set!({ worktreeParentDir: v }) + : updateEditWorkspaceStrategy({ worktreeParentDir: v || "" }) + } + immediate + className={inputClass} + placeholder=".paperclip/worktrees" + /> + + + )} + + + ); +} diff --git a/ui/src/adapters/openclaw-gateway/config-fields.tsx b/ui/src/adapters/openclaw-gateway/config-fields.tsx index 178f9f61..19780d94 100644 --- a/ui/src/adapters/openclaw-gateway/config-fields.tsx +++ b/ui/src/adapters/openclaw-gateway/config-fields.tsx @@ -6,6 +6,10 @@ import { DraftInput, help, } from "../../components/agent-config-primitives"; +import { + PayloadTemplateJsonField, + RuntimeServicesJsonField, +} from "../runtime-json-fields"; const inputClass = "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; @@ -112,6 +116,22 @@ export function OpenClawGatewayConfigFields({ /> + + + + {!isCreate && ( <> diff --git a/ui/src/adapters/runtime-json-fields.tsx b/ui/src/adapters/runtime-json-fields.tsx new file mode 100644 index 00000000..5cbc959b --- /dev/null +++ b/ui/src/adapters/runtime-json-fields.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from "react"; +import type { AdapterConfigFieldsProps } from "./types"; +import { Field, help } from "../components/agent-config-primitives"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function formatJsonObject(value: unknown): string { + const record = asRecord(value); + return Object.keys(record).length > 0 ? JSON.stringify(record, null, 2) : ""; +} + +function updateJsonConfig( + isCreate: boolean, + key: "runtimeServicesJson" | "payloadTemplateJson", + next: string, + set: AdapterConfigFieldsProps["set"], + mark: AdapterConfigFieldsProps["mark"], + configKey: string, +) { + if (isCreate) { + set?.({ [key]: next }); + return; + } + + const trimmed = next.trim(); + if (!trimmed) { + mark("adapterConfig", configKey, undefined); + return; + } + + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + mark("adapterConfig", configKey, parsed); + } + } catch { + // Keep local draft until JSON is valid. + } +} + +type JsonFieldProps = Pick< + AdapterConfigFieldsProps, + "isCreate" | "values" | "set" | "config" | "mark" +>; + +export function RuntimeServicesJsonField({ + isCreate, + values, + set, + config, + mark, +}: JsonFieldProps) { + const existing = formatJsonObject(config.workspaceRuntime); + const [draft, setDraft] = useState(existing); + + useEffect(() => { + if (!isCreate) setDraft(existing); + }, [existing, isCreate]); + + const value = isCreate ? values?.runtimeServicesJson ?? "" : draft; + + return ( + +