Merge public-gh/master into paperclip-company-import-export

This commit is contained in:
Dotta
2026-03-17 10:45:14 -05:00
88 changed files with 29002 additions and 888 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,882 @@
# Workspace Technical Implementation Spec
## Role of This Document
This document translates [workspace-product-model-and-work-product.md](/Users/dotta/paperclip-subissues/doc/plans/workspace-product-model-and-work-product.md) into an implementation-ready engineering plan.
It is intentionally concrete:
- schema and migration shape
- shared contract updates
- route and service changes
- UI changes
- rollout and compatibility rules
This is the implementation target for the first workspace-aware delivery slice.
## Locked Decisions
These decisions are treated as settled for this implementation:
1. Add a new durable `execution_workspaces` table now.
2. Each issue has at most one current execution workspace at a time.
3. `issues` get explicit `project_workspace_id` and `execution_workspace_id`.
4. Workspace reuse is in scope for V1.
5. The feature is gated in the UI by `/instance/settings > Experimental > Workspaces`.
6. The gate is UI-only. Backend model changes and migrations always ship.
7. Existing users upgrade into compatibility-preserving defaults.
8. `project_workspaces` evolves in place rather than being replaced.
9. Work product is issue-first, with optional links to execution workspaces and runtime services.
10. GitHub is the only PR provider in the first slice.
11. Both `adapter_managed` and `cloud_sandbox` execution modes are in scope.
12. Workspace controls ship first inside existing project properties, not in a new global navigation area.
13. Subissues are out of scope for this implementation slice.
## Non-Goals
- Building a full code review system
- Solving subissue UX in this slice
- Implementing reusable shared workspace definitions across projects in this slice
- Reworking all current runtime service behavior before introducing execution workspaces
## Existing Baseline
The repo already has:
- `project_workspaces`
- `projects.execution_workspace_policy`
- `issues.execution_workspace_settings`
- runtime service persistence in `workspace_runtime_services`
- local git-worktree realization in `workspace-runtime.ts`
This implementation should build on that baseline rather than fork it.
## Terminology
- `Project workspace`: durable configured codebase/root for a project
- `Execution workspace`: actual runtime workspace used for one or more issues
- `Work product`: user-facing output such as PR, preview, branch, commit, artifact, document
- `Runtime service`: process or service owned or tracked for a workspace
- `Compatibility mode`: existing behavior preserved for upgraded installs with no explicit workspace opt-in
## Architecture Summary
The first slice should introduce three explicit layers:
1. `Project workspace`
- existing durable project-scoped codebase record
- extended to support local, git, non-git, and remote-managed shapes
2. `Execution workspace`
- new durable runtime record
- represents shared, isolated, operator-branch, or remote-managed execution context
3. `Issue work product`
- new durable output record
- stores PRs, previews, branches, commits, artifacts, and documents
The issue remains the planning and ownership unit.
The execution workspace remains the runtime unit.
The work product remains the deliverable/output unit.
## Configuration and Deployment Topology
## Important correction
This repo already uses `PAPERCLIP_DEPLOYMENT_MODE` for auth/deployment behavior (`local_trusted | authenticated`).
Do not overload that variable for workspace execution topology.
## New env var
Add a separate execution-host hint:
- `PAPERCLIP_EXECUTION_TOPOLOGY=local|cloud|hybrid`
Default:
- if unset, treat as `local`
Purpose:
- influences defaults and validation for workspace configuration
- does not change current auth/deployment semantics
- does not break existing installs
### Semantics
- `local`
- Paperclip may create host-local worktrees, processes, and paths
- `cloud`
- Paperclip should assume no durable host-local execution workspace management
- adapter-managed and cloud-sandbox flows should be treated as first-class
- `hybrid`
- both local and remote execution strategies may exist
This is a guardrail and defaulting aid, not a hard policy engine in the first slice.
## Instance Settings
Add a new `Experimental` section under `/instance/settings`.
### New setting
- `experimental.workspaces: boolean`
Rules:
- default `false`
- UI-only gate
- stored in instance config or instance settings API response
- backend routes and migrations remain available even when false
### UI behavior when off
- hide workspace-specific issue controls
- hide workspace-specific project configuration
- hide issue `Work Product` tab if it would otherwise be empty
- do not remove or invalidate any stored workspace data
## Data Model
## 1. Extend `project_workspaces`
Current table exists and should evolve in place.
### New columns
- `source_type text not null default 'local_path'`
- `local_path | git_repo | non_git_path | remote_managed`
- `default_ref text null`
- `visibility text not null default 'default'`
- `default | advanced`
- `setup_command text null`
- `cleanup_command text null`
- `remote_provider text null`
- examples: `github`, `openai`, `anthropic`, `custom`
- `remote_workspace_ref text null`
- `shared_workspace_key text null`
- reserved for future cross-project shared workspace definitions
### Backfill rules
- if existing row has `repo_url`, backfill `source_type='git_repo'`
- else if existing row has `cwd`, backfill `source_type='local_path'`
- else backfill `source_type='remote_managed'`
- copy existing `repo_ref` into `default_ref`
### Indexes
- retain current indexes
- add `(project_id, source_type)`
- add `(company_id, shared_workspace_key)` non-unique for future support
## 2. Add `execution_workspaces`
Create a new durable table.
### Columns
- `id uuid pk`
- `company_id uuid not null`
- `project_id uuid not null`
- `project_workspace_id uuid null`
- `source_issue_id uuid null`
- `mode text not null`
- `shared_workspace | isolated_workspace | operator_branch | adapter_managed | cloud_sandbox`
- `strategy_type text not null`
- `project_primary | git_worktree | adapter_managed | cloud_sandbox`
- `name text not null`
- `status text not null default 'active'`
- `active | idle | in_review | archived | cleanup_failed`
- `cwd text null`
- `repo_url text null`
- `base_ref text null`
- `branch_name text null`
- `provider_type text not null default 'local_fs'`
- `local_fs | git_worktree | adapter_managed | cloud_sandbox`
- `provider_ref text null`
- `derived_from_execution_workspace_id uuid null`
- `last_used_at timestamptz not null default now()`
- `opened_at timestamptz not null default now()`
- `closed_at timestamptz null`
- `cleanup_eligible_at timestamptz null`
- `cleanup_reason text null`
- `metadata jsonb null`
- `created_at timestamptz not null default now()`
- `updated_at timestamptz not null default now()`
### Foreign keys
- `company_id -> companies.id`
- `project_id -> projects.id`
- `project_workspace_id -> project_workspaces.id on delete set null`
- `source_issue_id -> issues.id on delete set null`
- `derived_from_execution_workspace_id -> execution_workspaces.id on delete set null`
### Indexes
- `(company_id, project_id, status)`
- `(company_id, project_workspace_id, status)`
- `(company_id, source_issue_id)`
- `(company_id, last_used_at desc)`
- `(company_id, branch_name)` non-unique
## 3. Extend `issues`
Add explicit workspace linkage.
### New columns
- `project_workspace_id uuid null`
- `execution_workspace_id uuid null`
- `execution_workspace_preference text null`
- `inherit | shared_workspace | isolated_workspace | operator_branch | reuse_existing`
### Foreign keys
- `project_workspace_id -> project_workspaces.id on delete set null`
- `execution_workspace_id -> execution_workspaces.id on delete set null`
### Backfill rules
- all existing issues get null values
- null should be interpreted as compatibility/inherit behavior
### Invariants
- if `project_workspace_id` is set, it must belong to the issue's project and company
- if `execution_workspace_id` is set, it must belong to the issue's company
- if `execution_workspace_id` is set, the referenced workspace's `project_id` must match the issue's `project_id`
## 4. Add `issue_work_products`
Create a new durable table for outputs.
### Columns
- `id uuid pk`
- `company_id uuid not null`
- `project_id uuid null`
- `issue_id uuid not null`
- `execution_workspace_id uuid null`
- `runtime_service_id uuid null`
- `type text not null`
- `preview_url | runtime_service | pull_request | branch | commit | artifact | document`
- `provider text not null`
- `paperclip | github | vercel | s3 | custom`
- `external_id text null`
- `title text not null`
- `url text null`
- `status text not null`
- `active | ready_for_review | approved | changes_requested | merged | closed | failed | archived`
- `review_state text not null default 'none'`
- `none | needs_board_review | approved | changes_requested`
- `is_primary boolean not null default false`
- `health_status text not null default 'unknown'`
- `unknown | healthy | unhealthy`
- `summary text null`
- `metadata jsonb null`
- `created_by_run_id uuid null`
- `created_at timestamptz not null default now()`
- `updated_at timestamptz not null default now()`
### Foreign keys
- `company_id -> companies.id`
- `project_id -> projects.id on delete set null`
- `issue_id -> issues.id on delete cascade`
- `execution_workspace_id -> execution_workspaces.id on delete set null`
- `runtime_service_id -> workspace_runtime_services.id on delete set null`
- `created_by_run_id -> heartbeat_runs.id on delete set null`
### Indexes
- `(company_id, issue_id, type)`
- `(company_id, execution_workspace_id, type)`
- `(company_id, provider, external_id)`
- `(company_id, updated_at desc)`
## 5. Extend `workspace_runtime_services`
This table already exists and should remain the system of record for owned/tracked services.
### New column
- `execution_workspace_id uuid null`
### Foreign key
- `execution_workspace_id -> execution_workspaces.id on delete set null`
### Behavior
- runtime services remain workspace-first
- issue UIs should surface them through linked execution workspaces and work products
## Shared Contracts
## 1. `packages/shared`
### Update project workspace types and validators
Add fields:
- `sourceType`
- `defaultRef`
- `visibility`
- `setupCommand`
- `cleanupCommand`
- `remoteProvider`
- `remoteWorkspaceRef`
- `sharedWorkspaceKey`
### Add execution workspace types and validators
New shared types:
- `ExecutionWorkspace`
- `ExecutionWorkspaceMode`
- `ExecutionWorkspaceStatus`
- `ExecutionWorkspaceProviderType`
### Add work product types and validators
New shared types:
- `IssueWorkProduct`
- `IssueWorkProductType`
- `IssueWorkProductStatus`
- `IssueWorkProductReviewState`
### Update issue types and validators
Add:
- `projectWorkspaceId`
- `executionWorkspaceId`
- `executionWorkspacePreference`
- `workProducts?: IssueWorkProduct[]`
### Extend project execution policy contract
Replace the current narrow policy with a more explicit shape:
- `enabled`
- `defaultMode`
- `shared_workspace | isolated_workspace | operator_branch | adapter_default`
- `allowIssueOverride`
- `defaultProjectWorkspaceId`
- `workspaceStrategy`
- `branchPolicy`
- `pullRequestPolicy`
- `runtimePolicy`
- `cleanupPolicy`
Do not try to encode every possible provider-specific field in V1. Keep provider-specific extensibility in nested JSON where needed.
## Service Layer Changes
## 1. Project service
Update project workspace CRUD to handle the extended schema.
### Required rules
- when setting a primary workspace, clear `is_primary` on siblings
- `source_type=remote_managed` may have null `cwd`
- local/git-backed workspaces should still require one of `cwd` or `repo_url`
- preserve current behavior for existing callers that only send `cwd/repoUrl/repoRef`
## 2. Issue service
Update create/update flows to handle explicit workspace binding.
### Create behavior
Resolve defaults in this order:
1. explicit `projectWorkspaceId` from request
2. `project.executionWorkspacePolicy.defaultProjectWorkspaceId`
3. project's primary workspace
4. null
Resolve `executionWorkspacePreference`:
1. explicit request field
2. project policy default
3. compatibility fallback to `inherit`
Do not create an execution workspace at issue creation time unless:
- `reuse_existing` is explicitly chosen and `executionWorkspaceId` is provided
Otherwise, workspace realization happens when execution starts.
### Update behavior
- allow changing `projectWorkspaceId` only if the workspace belongs to the same project
- allow setting `executionWorkspaceId` only if it belongs to the same company and project
- do not automatically destroy or relink historical work products when workspace linkage changes
## 3. Workspace realization service
Refactor `workspace-runtime.ts` so realization produces or reuses an `execution_workspaces` row.
### New flow
Input:
- issue
- project workspace
- project execution policy
- execution topology hint
- adapter/runtime configuration
Output:
- realized execution workspace record
- runtime cwd/provider metadata
### Required modes
- `shared_workspace`
- reuse a stable execution workspace representing the project primary/shared workspace
- `isolated_workspace`
- create or reuse a derived isolated execution workspace
- `operator_branch`
- create or reuse a long-lived branch workspace
- `adapter_managed`
- create an execution workspace with provider references and optional null `cwd`
- `cloud_sandbox`
- same as adapter-managed, but explicit remote sandbox semantics
### Reuse rules
When `reuse_existing` is requested:
- only list active or recently used execution workspaces
- only for the same project
- only for the same project workspace if one is specified
- exclude archived and cleanup-failed workspaces
### Shared workspace realization
For compatibility mode and shared-workspace projects:
- create a stable execution workspace per project workspace when first needed
- reuse it for subsequent runs
This avoids a special-case branch in later work product linkage.
## 4. Runtime service integration
When runtime services are started or reused:
- populate `execution_workspace_id`
- continue populating `project_workspace_id`, `project_id`, and `issue_id`
When a runtime service yields a URL:
- optionally create or update a linked `issue_work_products` row of type `runtime_service` or `preview_url`
## 5. PR and preview reporting
Add a service for creating/updating `issue_work_products`.
### Supported V1 product types
- `pull_request`
- `preview_url`
- `runtime_service`
- `branch`
- `commit`
- `artifact`
- `document`
### GitHub PR reporting
For V1, GitHub is the only provider with richer semantics.
Supported statuses:
- `draft`
- `ready_for_review`
- `approved`
- `changes_requested`
- `merged`
- `closed`
Represent these in `status` and `review_state` rather than inventing a separate PR table in V1.
## Routes and API
## 1. Project workspace routes
Extend existing routes:
- `GET /projects/:id/workspaces`
- `POST /projects/:id/workspaces`
- `PATCH /projects/:id/workspaces/:workspaceId`
- `DELETE /projects/:id/workspaces/:workspaceId`
### New accepted/returned fields
- `sourceType`
- `defaultRef`
- `visibility`
- `setupCommand`
- `cleanupCommand`
- `remoteProvider`
- `remoteWorkspaceRef`
## 2. Execution workspace routes
Add:
- `GET /companies/:companyId/execution-workspaces`
- filters:
- `projectId`
- `projectWorkspaceId`
- `status`
- `issueId`
- `reuseEligible=true`
- `GET /execution-workspaces/:id`
- `PATCH /execution-workspaces/:id`
- update status/metadata/cleanup fields only in V1
Do not add top-level navigation for these routes yet.
## 3. Work product routes
Add:
- `GET /issues/:id/work-products`
- `POST /issues/:id/work-products`
- `PATCH /work-products/:id`
- `DELETE /work-products/:id`
### V1 mutation permissions
- board can create/update/delete all
- agents can create/update for issues they are assigned or currently executing
- deletion should generally archive rather than hard-delete once linked to historical output
## 4. Issue routes
Extend existing create/update payloads to accept:
- `projectWorkspaceId`
- `executionWorkspacePreference`
- `executionWorkspaceId`
Extend `GET /issues/:id` to return:
- `projectWorkspaceId`
- `executionWorkspaceId`
- `executionWorkspacePreference`
- `currentExecutionWorkspace`
- `workProducts[]`
## 5. Instance settings routes
Add support for:
- reading/writing `experimental.workspaces`
This is a UI gate only.
If there is no generic instance settings storage yet, the first slice can store this in the existing config/instance settings mechanism used by `/instance/settings`.
## UI Changes
## 1. `/instance/settings`
Add section:
- `Experimental`
- `Enable Workspaces`
When off:
- hide new workspace-specific affordances
- do not alter existing project or issue behavior
## 2. Project properties
Do not create a separate `Code` tab yet.
Ship inside existing project properties first.
### Add or re-enable sections
- `Project Workspaces`
- `Execution Defaults`
- `Provisioning`
- `Pull Requests`
- `Previews and Runtime`
- `Cleanup`
### Display rules
- only show when `experimental.workspaces=true`
- keep wording generic enough for local and remote setups
- only show git-specific fields when `sourceType=git_repo`
- only show local-path-specific fields when not `remote_managed`
## 3. Issue create dialog
When the workspace experimental flag is on and the selected project has workspace automation or workspaces:
### Basic fields
- `Codebase`
- select from project workspaces
- default to policy default or primary workspace
- `Execution mode`
- `Project default`
- `Shared workspace`
- `Isolated workspace`
- `Operator branch`
### Advanced section
- `Reuse existing execution workspace`
This control should query only:
- same project
- same codebase if selected
- active/recent workspaces
- compact labels with branch or workspace name
Do not expose all execution workspaces in a noisy unfiltered list.
## 4. Issue detail
Add a `Work Product` tab when:
- the experimental flag is on, or
- the issue already has work products
### Show
- current execution workspace summary
- PR cards
- preview cards
- branch/commit rows
- artifacts/documents
Add compact header chips:
- codebase
- workspace
- PR count/status
- preview status
## 5. Execution workspace detail page
Add a detail route but no nav item.
Linked from:
- issue work product tab
- project workspace/execution panels
### Show
- identity and status
- project workspace origin
- source issue
- linked issues
- branch/ref/provider info
- runtime services
- work products
- cleanup state
## Runtime and Adapter Behavior
## 1. Local adapters
For local adapters:
- continue to use existing cwd/worktree realization paths
- persist the result as execution workspaces
- attach runtime services and work product to the execution workspace and issue
## 2. Remote or cloud adapters
For remote adapters:
- allow execution workspaces with null `cwd`
- require provider metadata sufficient to identify the remote workspace/session
- allow work product creation without any host-local process ownership
Examples:
- cloud coding agent opens a branch and PR on GitHub
- Vercel preview URL is reported back as a preview work product
- remote sandbox emits artifact URLs
## 3. Approval-aware PR workflow
V1 should support richer PR state tracking, but not a full review engine.
### Required actions
- `open_pr`
- `mark_ready`
### Required review states
- `draft`
- `ready_for_review`
- `approved`
- `changes_requested`
- `merged`
- `closed`
### Storage approach
- represent these as `issue_work_products` with `type='pull_request'`
- use `status` and `review_state`
- store provider-specific details in `metadata`
## Migration Plan
## 1. Existing installs
The migration posture is backward-compatible by default.
### Guarantees
- no existing project must be edited before it keeps working
- no existing issue flow should start requiring workspace input
- all new nullable columns must preserve current behavior when absent
## 2. Project workspace migration
Migrate `project_workspaces` in place.
### Backfill
- derive `source_type`
- copy `repo_ref` to `default_ref`
- leave new optional fields null
## 3. Issue migration
Do not backfill `project_workspace_id` or `execution_workspace_id` on all existing issues.
Reason:
- the safest migration is to preserve current runtime behavior and bind explicitly only when new workspace-aware flows are used
Interpret old issues as:
- `executionWorkspacePreference = inherit`
- compatibility/shared behavior
## 4. Runtime history migration
Do not attempt a perfect historical reconstruction of execution workspaces in the migration itself.
Instead:
- create execution workspace records forward from first new run
- optionally add a later backfill tool for recent runtime services if it proves valuable
## Rollout Order
## Phase 1: Schema and shared contracts
1. extend `project_workspaces`
2. add `execution_workspaces`
3. add `issue_work_products`
4. extend `issues`
5. extend `workspace_runtime_services`
6. update shared types and validators
## Phase 2: Service wiring
1. update project workspace CRUD
2. update issue create/update resolution
3. refactor workspace realization to persist execution workspaces
4. attach runtime services to execution workspaces
5. add work product service and persistence
## Phase 3: API and UI
1. add execution workspace routes
2. add work product routes
3. add instance experimental settings toggle
4. re-enable and revise project workspace UI behind the flag
5. add issue create/update controls behind the flag
6. add issue work product tab
7. add execution workspace detail page
## Phase 4: Provider integrations
1. GitHub PR reporting
2. preview URL reporting
3. runtime-service-to-work-product linking
4. remote/cloud provider references
## Acceptance Criteria
1. Existing installs continue to behave predictably with no required reconfiguration.
2. Projects can define local, git, non-git, and remote-managed project workspaces.
3. Issues can explicitly select a project workspace and execution preference.
4. Each issue can point to one current execution workspace.
5. Multiple issues can intentionally reuse the same execution workspace.
6. Execution workspaces are persisted for both local and remote execution flows.
7. Work products can be attached to issues with optional execution workspace linkage.
8. GitHub PRs can be represented with richer lifecycle states.
9. The main UI remains simple when the experimental flag is off.
10. No top-level workspace navigation is required for this first slice.
## Risks and Mitigations
## Risk: too many overlapping workspace concepts
Mitigation:
- keep issue UI to `Codebase` and `Execution mode`
- reserve execution workspace details for advanced pages
## Risk: breaking current projects on upgrade
Mitigation:
- nullable schema additions
- in-place `project_workspaces` migration
- compatibility defaults
## Risk: local-only assumptions leaking into cloud mode
Mitigation:
- make `cwd` optional for execution workspaces
- use `provider_type` and `provider_ref`
- use `PAPERCLIP_EXECUTION_TOPOLOGY` as a defaulting guardrail
## Risk: turning PRs into a bespoke subsystem too early
Mitigation:
- represent PRs as work products in V1
- keep provider-specific details in metadata
- defer a dedicated PR table unless usage proves it necessary
## Recommended First Engineering Slice
If we want the narrowest useful implementation:
1. extend `project_workspaces`
2. add `execution_workspaces`
3. extend `issues` with explicit workspace fields
4. persist execution workspaces from existing local workspace realization
5. add `issue_work_products`
6. show project workspace controls and issue workspace controls behind the experimental flag
7. add issue `Work Product` tab with PR/preview/runtime service display
This slice is enough to validate the model without yet building every provider integration or cleanup workflow.

View File

@@ -94,7 +94,7 @@ export async function prepareWorktreeCodexHome(
}
await onLog(
"stderr",
"stdout",
`[paperclip] Using worktree-isolated Codex home "${targetHome}" (seeded from "${sourceHome}").\n`,
);
return targetHome;

View File

@@ -123,7 +123,7 @@ export async function ensureCodexSkillsInjected(
);
for (const skillName of removedSkills) {
await onLog(
"stderr",
"stdout",
`[paperclip] Removed maintainer-only Codex skill "${skillName}" from ${skillsHome}\n`,
);
}
@@ -150,8 +150,8 @@ export async function ensureCodexSkillsInjected(
await fs.symlink(entry.source, target);
}
await onLog(
"stderr",
`[paperclip] Repaired Codex skill "${entry.key}" into ${skillsHome}\n`,
"stdout",
`[paperclip] Repaired Codex skill "${entry.runtimeName}" into ${skillsHome}\n`,
);
continue;
}
@@ -161,8 +161,8 @@ export async function ensureCodexSkillsInjected(
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.key}" into ${skillsHome}\n`,
"stdout",
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.runtimeName}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
@@ -379,7 +379,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`Resolve any relative file references from ${instructionsDir}.\n\n`;
instructionsChars = instructionsPrefix.length;
await onLog(
"stderr",
"stdout",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
);
} catch (err) {

View File

@@ -33,9 +33,9 @@
"seed": "tsx src/seed.ts"
},
"dependencies": {
"embedded-postgres": "^18.1.0-beta.16",
"@paperclipai/shared": "workspace:*",
"drizzle-orm": "^0.38.4",
"embedded-postgres": "^18.1.0-beta.16",
"postgres": "^3.4.5"
},
"devDependencies": {

View File

@@ -0,0 +1,157 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import {
applyPendingMigrations,
ensurePostgresDatabase,
inspectMigrations,
} from "./client.js";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
const tempPaths: string[] = [];
const runningInstances: EmbeddedPostgresInstance[] = [];
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
async function createTempDatabase(): Promise<string> {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-client-"));
tempPaths.push(dataDir);
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
await instance.initialise();
await instance.start();
runningInstances.push(instance);
const adminUrl = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminUrl, "paperclip");
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
}
async function migrationHash(migrationFile: string): Promise<string> {
const content = await fs.promises.readFile(
new URL(`./migrations/${migrationFile}`, import.meta.url),
"utf8",
);
return createHash("sha256").update(content).digest("hex");
}
afterEach(async () => {
while (runningInstances.length > 0) {
const instance = runningInstances.pop();
if (!instance) continue;
await instance.stop();
}
while (tempPaths.length > 0) {
const tempPath = tempPaths.pop();
if (!tempPath) continue;
fs.rmSync(tempPath, { recursive: true, force: true });
}
});
describe("applyPendingMigrations", () => {
it(
"applies an inserted earlier migration without replaying later legacy migrations",
async () => {
const connectionString = await createTempDatabase();
await applyPendingMigrations(connectionString);
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const richMagnetoHash = await migrationHash("0030_rich_magneto.sql");
await sql.unsafe(
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${richMagnetoHash}'`,
);
await sql.unsafe(`DROP TABLE "company_logos"`);
} finally {
await sql.end();
}
const pendingState = await inspectMigrations(connectionString);
expect(pendingState).toMatchObject({
status: "needsMigrations",
pendingMigrations: ["0030_rich_magneto.sql"],
reason: "pending-migrations",
});
await applyPendingMigrations(connectionString);
const finalState = await inspectMigrations(connectionString);
expect(finalState.status).toBe("upToDate");
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const rows = await verifySql.unsafe<{ table_name: string }[]>(
`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('company_logos', 'execution_workspaces')
ORDER BY table_name
`,
);
expect(rows.map((row) => row.table_name)).toEqual([
"company_logos",
"execution_workspaces",
]);
} finally {
await verifySql.end();
}
},
20_000,
);
});

View File

@@ -50,6 +50,21 @@ export function createDb(url: string) {
return drizzlePg(sql, { schema });
}
export async function getPostgresDataDirectory(url: string): Promise<string | null> {
const sql = createUtilitySql(url);
try {
const rows = await sql<{ data_directory: string | null }[]>`
SELECT current_setting('data_directory', true) AS data_directory
`;
const actual = rows[0]?.data_directory;
return typeof actual === "string" && actual.length > 0 ? actual : null;
} catch {
return null;
} finally {
await sql.end();
}
}
async function listMigrationFiles(): Promise<string[]> {
const entries = await readdir(MIGRATIONS_FOLDER, { withFileTypes: true });
return entries
@@ -646,13 +661,26 @@ export async function applyPendingMigrations(url: string): Promise<void> {
const initialState = await inspectMigrations(url);
if (initialState.status === "upToDate") return;
const sql = createUtilitySql(url);
if (initialState.reason === "no-migration-journal-empty-db") {
const sql = createUtilitySql(url);
try {
const db = drizzlePg(sql);
await migratePg(db, { migrationsFolder: MIGRATIONS_FOLDER });
} finally {
await sql.end();
}
try {
const db = drizzlePg(sql);
await migratePg(db, { migrationsFolder: MIGRATIONS_FOLDER });
} finally {
await sql.end();
const bootstrappedState = await inspectMigrations(url);
if (bootstrappedState.status === "upToDate") return;
throw new Error(
`Failed to bootstrap migrations: ${bootstrappedState.pendingMigrations.join(", ")}`,
);
}
if (initialState.reason === "no-migration-journal-non-empty-db") {
throw new Error(
"Database has tables but no migration journal; automatic migration is unsafe. Initialize migration history manually.",
);
}
let state = await inspectMigrations(url);
@@ -665,7 +693,7 @@ export async function applyPendingMigrations(url: string): Promise<void> {
}
if (state.status !== "needsMigrations" || state.reason !== "pending-migrations") {
throw new Error("Migrations are still pending after attempted apply; run inspectMigrations for details.");
throw new Error("Migrations are still pending after migration-history reconciliation; run inspectMigrations for details.");
}
await applyPendingMigrationsManually(url, state.pendingMigrations);

View File

@@ -1,5 +1,6 @@
export {
createDb,
getPostgresDataDirectory,
ensurePostgresDatabase,
inspectMigrations,
applyPendingMigrations,

View File

@@ -1,9 +1,7 @@
import { existsSync, readFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import { createServer } from "node:net";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { ensurePostgresDatabase } from "./client.js";
import { ensurePostgresDatabase, getPostgresDataDirectory } from "./client.js";
import { resolveDatabaseTarget } from "./runtime-config.js";
type EmbeddedPostgresInstance = {
@@ -90,17 +88,8 @@ async function findAvailablePort(startPort: number): Promise<number> {
}
async function loadEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const require = createRequire(import.meta.url);
const resolveCandidates = [
path.resolve(fileURLToPath(new URL("../..", import.meta.url))),
path.resolve(fileURLToPath(new URL("../../server", import.meta.url))),
path.resolve(fileURLToPath(new URL("../../cli", import.meta.url))),
process.cwd(),
];
try {
const resolvedModulePath = require.resolve("embedded-postgres", { paths: resolveCandidates });
const mod = await import(pathToFileURL(resolvedModulePath).href);
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
} catch {
throw new Error(
@@ -116,8 +105,33 @@ async function ensureEmbeddedPostgresConnection(
const EmbeddedPostgres = await loadEmbeddedPostgresCtor();
const selectedPort = await findAvailablePort(preferredPort);
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
const pgVersionFile = path.resolve(dataDir, "PG_VERSION");
const runningPid = readRunningPostmasterPid(postmasterPidFile);
const runningPort = readPidFilePort(postmasterPidFile);
const preferredAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/postgres`;
if (!runningPid && existsSync(pgVersionFile)) {
try {
const actualDataDir = await getPostgresDataDirectory(preferredAdminConnectionString);
const matchesDataDir =
typeof actualDataDir === "string" &&
path.resolve(actualDataDir) === path.resolve(dataDir);
if (!matchesDataDir) {
throw new Error("reachable postgres does not use the expected embedded data directory");
}
await ensurePostgresDatabase(preferredAdminConnectionString, "paperclip");
process.emitWarning(
`Adopting an existing PostgreSQL instance on port ${preferredPort} for embedded data dir ${dataDir} because postmaster.pid is missing.`,
);
return {
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/paperclip`,
source: `embedded-postgres@${preferredPort}`,
stop: async () => {},
};
} catch {
// Fall through and attempt to start the configured embedded cluster.
}
}
if (runningPid) {
const port = runningPort ?? preferredPort;

View File

@@ -0,0 +1,91 @@
CREATE TABLE "execution_workspaces" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"project_id" uuid NOT NULL,
"project_workspace_id" uuid,
"source_issue_id" uuid,
"mode" text NOT NULL,
"strategy_type" text NOT NULL,
"name" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"cwd" text,
"repo_url" text,
"base_ref" text,
"branch_name" text,
"provider_type" text DEFAULT 'local_fs' NOT NULL,
"provider_ref" text,
"derived_from_execution_workspace_id" uuid,
"last_used_at" timestamp with time zone DEFAULT now() NOT NULL,
"opened_at" timestamp with time zone DEFAULT now() NOT NULL,
"closed_at" timestamp with time zone,
"cleanup_eligible_at" timestamp with time zone,
"cleanup_reason" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "issue_work_products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"project_id" uuid,
"issue_id" uuid NOT NULL,
"execution_workspace_id" uuid,
"runtime_service_id" uuid,
"type" text NOT NULL,
"provider" text NOT NULL,
"external_id" text,
"title" text NOT NULL,
"url" text,
"status" text NOT NULL,
"review_state" text DEFAULT 'none' NOT NULL,
"is_primary" boolean DEFAULT false NOT NULL,
"health_status" text DEFAULT 'unknown' NOT NULL,
"summary" text,
"metadata" jsonb,
"created_by_run_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN "project_workspace_id" uuid;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN "execution_workspace_id" uuid;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN "execution_workspace_preference" text;--> statement-breakpoint
ALTER TABLE "project_workspaces" ADD COLUMN "source_type" text DEFAULT 'local_path' NOT NULL;--> statement-breakpoint
ALTER TABLE "project_workspaces" ADD COLUMN "default_ref" text;--> statement-breakpoint
ALTER TABLE "project_workspaces" ADD COLUMN "visibility" text DEFAULT 'default' NOT NULL;--> statement-breakpoint
ALTER TABLE "project_workspaces" ADD COLUMN "setup_command" text;--> statement-breakpoint
ALTER TABLE "project_workspaces" ADD COLUMN "cleanup_command" text;--> statement-breakpoint
ALTER TABLE "project_workspaces" ADD COLUMN "remote_provider" text;--> statement-breakpoint
ALTER TABLE "project_workspaces" ADD COLUMN "remote_workspace_ref" text;--> statement-breakpoint
ALTER TABLE "project_workspaces" ADD COLUMN "shared_workspace_key" text;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD COLUMN "execution_workspace_id" uuid;--> statement-breakpoint
ALTER TABLE "execution_workspaces" ADD CONSTRAINT "execution_workspaces_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 "execution_workspaces" ADD CONSTRAINT "execution_workspaces_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "execution_workspaces" ADD CONSTRAINT "execution_workspaces_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 "execution_workspaces" ADD CONSTRAINT "execution_workspaces_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "execution_workspaces" ADD CONSTRAINT "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk" FOREIGN KEY ("derived_from_execution_workspace_id") REFERENCES "public"."execution_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_work_products" ADD CONSTRAINT "issue_work_products_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 "issue_work_products" ADD CONSTRAINT "issue_work_products_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 "issue_work_products" ADD CONSTRAINT "issue_work_products_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_work_products" ADD CONSTRAINT "issue_work_products_execution_workspace_id_execution_workspaces_id_fk" FOREIGN KEY ("execution_workspace_id") REFERENCES "public"."execution_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_work_products" ADD CONSTRAINT "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk" FOREIGN KEY ("runtime_service_id") REFERENCES "public"."workspace_runtime_services"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_work_products" ADD CONSTRAINT "issue_work_products_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "execution_workspaces_company_project_status_idx" ON "execution_workspaces" USING btree ("company_id","project_id","status");--> statement-breakpoint
CREATE INDEX "execution_workspaces_company_project_workspace_status_idx" ON "execution_workspaces" USING btree ("company_id","project_workspace_id","status");--> statement-breakpoint
CREATE INDEX "execution_workspaces_company_source_issue_idx" ON "execution_workspaces" USING btree ("company_id","source_issue_id");--> statement-breakpoint
CREATE INDEX "execution_workspaces_company_last_used_idx" ON "execution_workspaces" USING btree ("company_id","last_used_at");--> statement-breakpoint
CREATE INDEX "execution_workspaces_company_branch_idx" ON "execution_workspaces" USING btree ("company_id","branch_name");--> statement-breakpoint
CREATE INDEX "issue_work_products_company_issue_type_idx" ON "issue_work_products" USING btree ("company_id","issue_id","type");--> statement-breakpoint
CREATE INDEX "issue_work_products_company_execution_workspace_type_idx" ON "issue_work_products" USING btree ("company_id","execution_workspace_id","type");--> statement-breakpoint
CREATE INDEX "issue_work_products_company_provider_external_id_idx" ON "issue_work_products" USING btree ("company_id","provider","external_id");--> statement-breakpoint
CREATE INDEX "issue_work_products_company_updated_idx" ON "issue_work_products" USING btree ("company_id","updated_at");--> statement-breakpoint
ALTER TABLE "issues" ADD CONSTRAINT "issues_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 "issues" ADD CONSTRAINT "issues_execution_workspace_id_execution_workspaces_id_fk" FOREIGN KEY ("execution_workspace_id") REFERENCES "public"."execution_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk" FOREIGN KEY ("execution_workspace_id") REFERENCES "public"."execution_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "issues_company_project_workspace_idx" ON "issues" USING btree ("company_id","project_workspace_id");--> statement-breakpoint
CREATE INDEX "issues_company_execution_workspace_idx" ON "issues" USING btree ("company_id","execution_workspace_id");--> statement-breakpoint
CREATE INDEX "project_workspaces_project_source_type_idx" ON "project_workspaces" USING btree ("project_id","source_type");--> statement-breakpoint
CREATE INDEX "project_workspaces_company_shared_key_idx" ON "project_workspaces" USING btree ("company_id","shared_workspace_key");--> statement-breakpoint
CREATE UNIQUE INDEX "project_workspaces_project_remote_ref_idx" ON "project_workspaces" USING btree ("project_id","remote_provider","remote_workspace_ref");--> statement-breakpoint
CREATE INDEX "workspace_runtime_services_company_execution_workspace_status_idx" ON "workspace_runtime_services" USING btree ("company_id","execution_workspace_id","status");

View File

@@ -0,0 +1,9 @@
CREATE TABLE "instance_settings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"singleton_key" text DEFAULT 'default' NOT NULL,
"experimental" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "instance_settings_singleton_key_idx" ON "instance_settings" USING btree ("singleton_key");

View File

@@ -0,0 +1,29 @@
CREATE TABLE "workspace_operations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"execution_workspace_id" uuid,
"heartbeat_run_id" uuid,
"phase" text NOT NULL,
"command" text,
"cwd" text,
"status" text DEFAULT 'running' NOT NULL,
"exit_code" integer,
"log_store" text,
"log_ref" text,
"log_bytes" bigint,
"log_sha256" text,
"log_compressed" boolean DEFAULT false NOT NULL,
"stdout_excerpt" text,
"stderr_excerpt" text,
"metadata" jsonb,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"finished_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_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_operations" ADD CONSTRAINT "workspace_operations_execution_workspace_id_execution_workspaces_id_fk" FOREIGN KEY ("execution_workspace_id") REFERENCES "public"."execution_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "workspace_operations_company_run_started_idx" ON "workspace_operations" USING btree ("company_id","heartbeat_run_id","started_at");--> statement-breakpoint
CREATE INDEX "workspace_operations_company_workspace_started_idx" ON "workspace_operations" USING btree ("company_id","execution_workspace_id","started_at");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -250,8 +250,22 @@
{
"idx": 35,
"version": "7",
"when": 1773703213570,
"tag": "0035_colorful_rhino",
"when": 1773698696169,
"tag": "0035_marvelous_satana",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1773756213455,
"tag": "0036_cheerful_nitro",
"breakpoints": true
},
{
"idx": 37,
"version": "7",
"when": 1773756922363,
"tag": "0037_friendly_eddie_brock",
"breakpoints": true
}
]

View File

@@ -0,0 +1,68 @@
import {
type AnyPgColumn,
index,
jsonb,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { projectWorkspaces } from "./project_workspaces.js";
import { projects } from "./projects.js";
export const executionWorkspaces = pgTable(
"execution_workspaces",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
sourceIssueId: uuid("source_issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }),
mode: text("mode").notNull(),
strategyType: text("strategy_type").notNull(),
name: text("name").notNull(),
status: text("status").notNull().default("active"),
cwd: text("cwd"),
repoUrl: text("repo_url"),
baseRef: text("base_ref"),
branchName: text("branch_name"),
providerType: text("provider_type").notNull().default("local_fs"),
providerRef: text("provider_ref"),
derivedFromExecutionWorkspaceId: uuid("derived_from_execution_workspace_id")
.references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull().defaultNow(),
openedAt: timestamp("opened_at", { withTimezone: true }).notNull().defaultNow(),
closedAt: timestamp("closed_at", { withTimezone: true }),
cleanupEligibleAt: timestamp("cleanup_eligible_at", { withTimezone: true }),
cleanupReason: text("cleanup_reason"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyProjectStatusIdx: index("execution_workspaces_company_project_status_idx").on(
table.companyId,
table.projectId,
table.status,
),
companyProjectWorkspaceStatusIdx: index("execution_workspaces_company_project_workspace_status_idx").on(
table.companyId,
table.projectWorkspaceId,
table.status,
),
companySourceIssueIdx: index("execution_workspaces_company_source_issue_idx").on(
table.companyId,
table.sourceIssueId,
),
companyLastUsedIdx: index("execution_workspaces_company_last_used_idx").on(
table.companyId,
table.lastUsedAt,
),
companyBranchIdx: index("execution_workspaces_company_branch_idx").on(
table.companyId,
table.branchName,
),
}),
);

View File

@@ -1,6 +1,7 @@
export { companies } from "./companies.js";
export { companyLogos } from "./company_logos.js";
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
export { instanceSettings } from "./instance_settings.js";
export { instanceUserRoles } from "./instance_user_roles.js";
export { agents } from "./agents.js";
export { companyMemberships } from "./company_memberships.js";
@@ -16,10 +17,13 @@ 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 { executionWorkspaces } from "./execution_workspaces.js";
export { workspaceOperations } from "./workspace_operations.js";
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
export { projectGoals } from "./project_goals.js";
export { goals } from "./goals.js";
export { issues } from "./issues.js";
export { issueWorkProducts } from "./issue_work_products.js";
export { labels } from "./labels.js";
export { issueLabels } from "./issue_labels.js";
export { issueApprovals } from "./issue_approvals.js";

View File

@@ -0,0 +1,15 @@
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
export const instanceSettings = pgTable(
"instance_settings",
{
id: uuid("id").primaryKey().defaultRandom(),
singletonKey: text("singleton_key").notNull().default("default"),
experimental: jsonb("experimental").$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
singletonKeyIdx: uniqueIndex("instance_settings_singleton_key_idx").on(table.singletonKey),
}),
);

View File

@@ -0,0 +1,64 @@
import {
boolean,
index,
jsonb,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { executionWorkspaces } from "./execution_workspaces.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issues } from "./issues.js";
import { projects } from "./projects.js";
import { workspaceRuntimeServices } from "./workspace_runtime_services.js";
export const issueWorkProducts = pgTable(
"issue_work_products",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
executionWorkspaceId: uuid("execution_workspace_id")
.references(() => executionWorkspaces.id, { onDelete: "set null" }),
runtimeServiceId: uuid("runtime_service_id")
.references(() => workspaceRuntimeServices.id, { onDelete: "set null" }),
type: text("type").notNull(),
provider: text("provider").notNull(),
externalId: text("external_id"),
title: text("title").notNull(),
url: text("url"),
status: text("status").notNull(),
reviewState: text("review_state").notNull().default("none"),
isPrimary: boolean("is_primary").notNull().default(false),
healthStatus: text("health_status").notNull().default("unknown"),
summary: text("summary"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIssueTypeIdx: index("issue_work_products_company_issue_type_idx").on(
table.companyId,
table.issueId,
table.type,
),
companyExecutionWorkspaceTypeIdx: index("issue_work_products_company_execution_workspace_type_idx").on(
table.companyId,
table.executionWorkspaceId,
table.type,
),
companyProviderExternalIdIdx: index("issue_work_products_company_provider_external_id_idx").on(
table.companyId,
table.provider,
table.externalId,
),
companyUpdatedIdx: index("issue_work_products_company_updated_idx").on(
table.companyId,
table.updatedAt,
),
}),
);

View File

@@ -14,6 +14,8 @@ import { projects } from "./projects.js";
import { goals } from "./goals.js";
import { companies } from "./companies.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { projectWorkspaces } from "./project_workspaces.js";
import { executionWorkspaces } from "./execution_workspaces.js";
export const issues = pgTable(
"issues",
@@ -21,6 +23,7 @@ export const issues = pgTable(
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
projectId: uuid("project_id").references(() => projects.id),
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
goalId: uuid("goal_id").references(() => goals.id),
parentId: uuid("parent_id").references((): AnyPgColumn => issues.id),
title: text("title").notNull(),
@@ -40,6 +43,9 @@ export const issues = pgTable(
requestDepth: integer("request_depth").notNull().default(0),
billingCode: text("billing_code"),
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
executionWorkspaceId: uuid("execution_workspace_id")
.references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }),
executionWorkspacePreference: text("execution_workspace_preference"),
executionWorkspaceSettings: jsonb("execution_workspace_settings").$type<Record<string, unknown>>(),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
@@ -62,6 +68,8 @@ export const issues = pgTable(
),
parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId),
projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId),
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
}),
);

View File

@@ -5,6 +5,7 @@ import {
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
@@ -17,9 +18,17 @@ export const projectWorkspaces = pgTable(
companyId: uuid("company_id").notNull().references(() => companies.id),
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
sourceType: text("source_type").notNull().default("local_path"),
cwd: text("cwd"),
repoUrl: text("repo_url"),
repoRef: text("repo_ref"),
defaultRef: text("default_ref"),
visibility: text("visibility").notNull().default("default"),
setupCommand: text("setup_command"),
cleanupCommand: text("cleanup_command"),
remoteProvider: text("remote_provider"),
remoteWorkspaceRef: text("remote_workspace_ref"),
sharedWorkspaceKey: text("shared_workspace_key"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
isPrimary: boolean("is_primary").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
@@ -28,5 +37,9 @@ export const projectWorkspaces = pgTable(
(table) => ({
companyProjectIdx: index("project_workspaces_company_project_idx").on(table.companyId, table.projectId),
projectPrimaryIdx: index("project_workspaces_project_primary_idx").on(table.projectId, table.isPrimary),
projectSourceTypeIdx: index("project_workspaces_project_source_type_idx").on(table.projectId, table.sourceType),
companySharedKeyIdx: index("project_workspaces_company_shared_key_idx").on(table.companyId, table.sharedWorkspaceKey),
projectRemoteRefIdx: uniqueIndex("project_workspaces_project_remote_ref_idx")
.on(table.projectId, table.remoteProvider, table.remoteWorkspaceRef),
}),
);

View File

@@ -0,0 +1,57 @@
import {
bigint,
boolean,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { executionWorkspaces } from "./execution_workspaces.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
export const workspaceOperations = pgTable(
"workspace_operations",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
executionWorkspaceId: uuid("execution_workspace_id").references(() => executionWorkspaces.id, {
onDelete: "set null",
}),
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, {
onDelete: "set null",
}),
phase: text("phase").notNull(),
command: text("command"),
cwd: text("cwd"),
status: text("status").notNull().default("running"),
exitCode: integer("exit_code"),
logStore: text("log_store"),
logRef: text("log_ref"),
logBytes: bigint("log_bytes", { mode: "number" }),
logSha256: text("log_sha256"),
logCompressed: boolean("log_compressed").notNull().default(false),
stdoutExcerpt: text("stdout_excerpt"),
stderrExcerpt: text("stderr_excerpt"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
finishedAt: timestamp("finished_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyRunStartedIdx: index("workspace_operations_company_run_started_idx").on(
table.companyId,
table.heartbeatRunId,
table.startedAt,
),
companyWorkspaceStartedIdx: index("workspace_operations_company_workspace_started_idx").on(
table.companyId,
table.executionWorkspaceId,
table.startedAt,
),
}),
);

View File

@@ -10,6 +10,7 @@ import {
import { companies } from "./companies.js";
import { projects } from "./projects.js";
import { projectWorkspaces } from "./project_workspaces.js";
import { executionWorkspaces } from "./execution_workspaces.js";
import { issues } from "./issues.js";
import { agents } from "./agents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
@@ -21,6 +22,7 @@ export const workspaceRuntimeServices = pgTable(
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" }),
executionWorkspaceId: uuid("execution_workspace_id").references(() => executionWorkspaces.id, { onDelete: "set null" }),
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
scopeType: text("scope_type").notNull(),
scopeId: text("scope_id"),
@@ -50,6 +52,11 @@ export const workspaceRuntimeServices = pgTable(
table.projectWorkspaceId,
table.status,
),
companyExecutionWorkspaceStatusIdx: index("workspace_runtime_services_company_execution_workspace_status_idx").on(
table.companyId,
table.executionWorkspaceId,
table.status,
),
companyProjectStatusIdx: index("workspace_runtime_services_company_project_status_idx").on(
table.companyId,
table.projectId,

View File

@@ -353,6 +353,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
id: randomUUID(),
companyId: input.companyId,
projectId: input.projectId ?? null,
projectWorkspaceId: null,
goalId: input.goalId ?? null,
parentId: input.parentId ?? null,
title: input.title,
@@ -372,6 +373,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,

View File

@@ -144,6 +144,8 @@ export type {
AgentSkillEntry,
AgentSkillSnapshot,
AgentSkillSyncRequest,
InstanceExperimentalSettings,
InstanceSettings,
Agent,
AgentPermissions,
AgentKeyCreated,
@@ -154,14 +156,28 @@ export type {
AdapterEnvironmentTestResult,
AssetImage,
Project,
ProjectCodebase,
ProjectCodebaseOrigin,
ProjectGoalRef,
ProjectWorkspace,
ExecutionWorkspace,
WorkspaceRuntimeService,
WorkspaceOperation,
WorkspaceOperationPhase,
WorkspaceOperationStatus,
ExecutionWorkspaceStrategyType,
ExecutionWorkspaceMode,
ExecutionWorkspaceProviderType,
ExecutionWorkspaceStatus,
ExecutionWorkspaceStrategy,
ProjectExecutionWorkspacePolicy,
ProjectExecutionWorkspaceDefaultMode,
IssueExecutionWorkspaceSettings,
IssueWorkProduct,
IssueWorkProductType,
IssueWorkProductProvider,
IssueWorkProductStatus,
IssueWorkProductReviewState,
Issue,
IssueAssigneeAdapterOverrides,
IssueComment,
@@ -258,6 +274,12 @@ export type {
ProviderQuotaResult,
} from "./types/index.js";
export {
instanceExperimentalSettingsSchema,
patchInstanceExperimentalSettingsSchema,
type PatchInstanceExperimentalSettings,
} from "./validators/index.js";
export {
createCompanySchema,
updateCompanySchema,
@@ -305,6 +327,13 @@ export {
addIssueCommentSchema,
linkIssueApprovalSchema,
createIssueAttachmentMetadataSchema,
createIssueWorkProductSchema,
updateIssueWorkProductSchema,
issueWorkProductTypeSchema,
issueWorkProductStatusSchema,
issueWorkProductReviewStateSchema,
updateExecutionWorkspaceSchema,
executionWorkspaceStatusSchema,
issueDocumentFormatSchema,
issueDocumentKeySchema,
upsertIssueDocumentSchema,
@@ -315,6 +344,9 @@ export {
type AddIssueComment,
type LinkIssueApproval,
type CreateIssueAttachmentMetadata,
type CreateIssueWorkProduct,
type UpdateIssueWorkProduct,
type UpdateExecutionWorkspace,
type IssueDocumentFormat,
type UpsertIssueDocument,
createGoalSchema,

View File

@@ -1,4 +1,5 @@
export type { Company } from "./company.js";
export type { InstanceExperimentalSettings, InstanceSettings } from "./instance.js";
export type {
CompanySkillSourceType,
CompanySkillTrustLevel,
@@ -38,15 +39,31 @@ export type {
AdapterEnvironmentTestResult,
} from "./agent.js";
export type { AssetImage } from "./asset.js";
export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.js";
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js";
export type {
ExecutionWorkspace,
WorkspaceRuntimeService,
ExecutionWorkspaceStrategyType,
ExecutionWorkspaceMode,
ExecutionWorkspaceProviderType,
ExecutionWorkspaceStatus,
ExecutionWorkspaceStrategy,
ProjectExecutionWorkspacePolicy,
ProjectExecutionWorkspaceDefaultMode,
IssueExecutionWorkspaceSettings,
} from "./workspace-runtime.js";
export type {
WorkspaceOperation,
WorkspaceOperationPhase,
WorkspaceOperationStatus,
} from "./workspace-operation.js";
export type {
IssueWorkProduct,
IssueWorkProductType,
IssueWorkProductProvider,
IssueWorkProductStatus,
IssueWorkProductReviewState,
} from "./work-product.js";
export type {
Issue,
IssueAssigneeAdapterOverrides,

View File

@@ -0,0 +1,10 @@
export interface InstanceExperimentalSettings {
enableIsolatedWorkspaces: boolean;
}
export interface InstanceSettings {
id: string;
experimental: InstanceExperimentalSettings;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -1,7 +1,8 @@
import type { IssuePriority, IssueStatus } from "../constants.js";
import type { Goal } from "./goal.js";
import type { Project, ProjectWorkspace } from "./project.js";
import type { IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
import type { IssueWorkProduct } from "./work-product.js";
export interface IssueAncestorProject {
id: string;
@@ -97,6 +98,7 @@ export interface Issue {
id: string;
companyId: string;
projectId: string | null;
projectWorkspaceId: string | null;
goalId: string | null;
parentId: string | null;
ancestors?: IssueAncestor[];
@@ -117,6 +119,8 @@ export interface Issue {
requestDepth: number;
billingCode: string | null;
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
executionWorkspaceId: string | null;
executionWorkspacePreference: string | null;
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
startedAt: Date | null;
completedAt: Date | null;
@@ -129,6 +133,8 @@ export interface Issue {
legacyPlanDocument?: LegacyPlanDocument | null;
project?: Project | null;
goal?: Goal | null;
currentExecutionWorkspace?: ExecutionWorkspace | null;
workProducts?: IssueWorkProduct[];
mentionedProjects?: Project[];
myLastTouchAt?: Date | null;
lastExternalCommentAt?: Date | null;

View File

@@ -1,6 +1,9 @@
import type { PauseReason, ProjectStatus } from "../constants.js";
import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js";
export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path";
export type ProjectWorkspaceVisibility = "default" | "advanced";
export interface ProjectGoalRef {
id: string;
title: string;
@@ -11,9 +14,17 @@ export interface ProjectWorkspace {
companyId: string;
projectId: string;
name: string;
sourceType: ProjectWorkspaceSourceType;
cwd: string | null;
repoUrl: string | null;
repoRef: string | null;
defaultRef: string | null;
visibility: ProjectWorkspaceVisibility;
setupCommand: string | null;
cleanupCommand: string | null;
remoteProvider: string | null;
remoteWorkspaceRef: string | null;
sharedWorkspaceKey: string | null;
metadata: Record<string, unknown> | null;
isPrimary: boolean;
runtimeServices?: WorkspaceRuntimeService[];
@@ -21,6 +32,20 @@ export interface ProjectWorkspace {
updatedAt: Date;
}
export type ProjectCodebaseOrigin = "local_folder" | "managed_checkout";
export interface ProjectCodebase {
workspaceId: string | null;
repoUrl: string | null;
repoRef: string | null;
defaultRef: string | null;
repoName: string | null;
localFolder: string | null;
managedFolder: string;
effectiveLocalFolder: string;
origin: ProjectCodebaseOrigin;
}
export interface Project {
id: string;
companyId: string;
@@ -38,6 +63,7 @@ export interface Project {
pauseReason: PauseReason | null;
pausedAt: Date | null;
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
codebase: ProjectCodebase;
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
archivedAt: Date | null;

View File

@@ -0,0 +1,55 @@
export type IssueWorkProductType =
| "preview_url"
| "runtime_service"
| "pull_request"
| "branch"
| "commit"
| "artifact"
| "document";
export type IssueWorkProductProvider =
| "paperclip"
| "github"
| "vercel"
| "s3"
| "custom";
export type IssueWorkProductStatus =
| "active"
| "ready_for_review"
| "approved"
| "changes_requested"
| "merged"
| "closed"
| "failed"
| "archived"
| "draft";
export type IssueWorkProductReviewState =
| "none"
| "needs_board_review"
| "approved"
| "changes_requested";
export interface IssueWorkProduct {
id: string;
companyId: string;
projectId: string | null;
issueId: string;
executionWorkspaceId: string | null;
runtimeServiceId: string | null;
type: IssueWorkProductType;
provider: IssueWorkProductProvider | string;
externalId: string | null;
title: string;
url: string | null;
status: IssueWorkProductStatus | string;
reviewState: IssueWorkProductReviewState;
isPrimary: boolean;
healthStatus: "unknown" | "healthy" | "unhealthy";
summary: string | null;
metadata: Record<string, unknown> | null;
createdByRunId: string | null;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -0,0 +1,31 @@
export type WorkspaceOperationPhase =
| "worktree_prepare"
| "workspace_provision"
| "workspace_teardown"
| "worktree_cleanup";
export type WorkspaceOperationStatus = "running" | "succeeded" | "failed" | "skipped";
export interface WorkspaceOperation {
id: string;
companyId: string;
executionWorkspaceId: string | null;
heartbeatRunId: string | null;
phase: WorkspaceOperationPhase;
command: string | null;
cwd: string | null;
status: WorkspaceOperationStatus;
exitCode: number | null;
logStore: string | null;
logRef: string | null;
logBytes: number | null;
logSha256: string | null;
logCompressed: boolean;
stdoutExcerpt: string | null;
stderrExcerpt: string | null;
metadata: Record<string, unknown> | null;
startedAt: Date;
finishedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -1,6 +1,35 @@
export type ExecutionWorkspaceStrategyType = "project_primary" | "git_worktree";
export type ExecutionWorkspaceStrategyType =
| "project_primary"
| "git_worktree"
| "adapter_managed"
| "cloud_sandbox";
export type ExecutionWorkspaceMode = "inherit" | "project_primary" | "isolated" | "agent_default";
export type ProjectExecutionWorkspaceDefaultMode =
| "shared_workspace"
| "isolated_workspace"
| "operator_branch"
| "adapter_default";
export type ExecutionWorkspaceMode =
| "inherit"
| "shared_workspace"
| "isolated_workspace"
| "operator_branch"
| "reuse_existing"
| "agent_default";
export type ExecutionWorkspaceProviderType =
| "local_fs"
| "git_worktree"
| "adapter_managed"
| "cloud_sandbox";
export type ExecutionWorkspaceStatus =
| "active"
| "idle"
| "in_review"
| "archived"
| "cleanup_failed";
export interface ExecutionWorkspaceStrategy {
type: ExecutionWorkspaceStrategyType;
@@ -13,12 +42,14 @@ export interface ExecutionWorkspaceStrategy {
export interface ProjectExecutionWorkspacePolicy {
enabled: boolean;
defaultMode?: "project_primary" | "isolated";
defaultMode?: ProjectExecutionWorkspaceDefaultMode;
allowIssueOverride?: boolean;
defaultProjectWorkspaceId?: string | null;
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
workspaceRuntime?: Record<string, unknown> | null;
branchPolicy?: Record<string, unknown> | null;
pullRequestPolicy?: Record<string, unknown> | null;
runtimePolicy?: Record<string, unknown> | null;
cleanupPolicy?: Record<string, unknown> | null;
}
@@ -28,11 +59,39 @@ export interface IssueExecutionWorkspaceSettings {
workspaceRuntime?: Record<string, unknown> | null;
}
export interface ExecutionWorkspace {
id: string;
companyId: string;
projectId: string;
projectWorkspaceId: string | null;
sourceIssueId: string | null;
mode: Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing" | "agent_default"> | "adapter_managed" | "cloud_sandbox";
strategyType: ExecutionWorkspaceStrategyType;
name: string;
status: ExecutionWorkspaceStatus;
cwd: string | null;
repoUrl: string | null;
baseRef: string | null;
branchName: string | null;
providerType: ExecutionWorkspaceProviderType;
providerRef: string | null;
derivedFromExecutionWorkspaceId: string | null;
lastUsedAt: Date;
openedAt: Date;
closedAt: Date | null;
cleanupEligibleAt: Date | null;
cleanupReason: string | null;
metadata: Record<string, unknown> | null;
createdAt: Date;
updatedAt: Date;
}
export interface WorkspaceRuntimeService {
id: string;
companyId: string;
projectId: string | null;
projectWorkspaceId: string | null;
executionWorkspaceId: string | null;
issueId: string | null;
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
scopeId: string | null;

View File

@@ -0,0 +1,18 @@
import { z } from "zod";
export const executionWorkspaceStatusSchema = z.enum([
"active",
"idle",
"in_review",
"archived",
"cleanup_failed",
]);
export const updateExecutionWorkspaceSchema = z.object({
status: executionWorkspaceStatusSchema.optional(),
cleanupEligibleAt: z.string().datetime().optional().nullable(),
cleanupReason: z.string().optional().nullable(),
metadata: z.record(z.unknown()).optional().nullable(),
}).strict();
export type UpdateExecutionWorkspace = z.infer<typeof updateExecutionWorkspaceSchema>;

View File

@@ -1,3 +1,10 @@
export {
instanceExperimentalSettingsSchema,
patchInstanceExperimentalSettingsSchema,
type InstanceExperimentalSettings,
type PatchInstanceExperimentalSettings,
} from "./instance.js";
export {
upsertBudgetPolicySchema,
resolveBudgetIncidentSchema,
@@ -121,6 +128,22 @@ export {
type UpsertIssueDocument,
} from "./issue.js";
export {
createIssueWorkProductSchema,
updateIssueWorkProductSchema,
issueWorkProductTypeSchema,
issueWorkProductStatusSchema,
issueWorkProductReviewStateSchema,
type CreateIssueWorkProduct,
type UpdateIssueWorkProduct,
} from "./work-product.js";
export {
updateExecutionWorkspaceSchema,
executionWorkspaceStatusSchema,
type UpdateExecutionWorkspace,
} from "./execution-workspace.js";
export {
createGoalSchema,
updateGoalSchema,

View File

@@ -0,0 +1,10 @@
import { z } from "zod";
export const instanceExperimentalSettingsSchema = z.object({
enableIsolatedWorkspaces: z.boolean().default(false),
}).strict();
export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema.partial();
export type InstanceExperimentalSettings = z.infer<typeof instanceExperimentalSettingsSchema>;
export type PatchInstanceExperimentalSettings = z.infer<typeof patchInstanceExperimentalSettingsSchema>;

View File

@@ -3,7 +3,7 @@ import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
const executionWorkspaceStrategySchema = z
.object({
type: z.enum(["project_primary", "git_worktree"]).optional(),
type: z.enum(["project_primary", "git_worktree", "adapter_managed", "cloud_sandbox"]).optional(),
baseRef: z.string().optional().nullable(),
branchTemplate: z.string().optional().nullable(),
worktreeParentDir: z.string().optional().nullable(),
@@ -14,7 +14,7 @@ const executionWorkspaceStrategySchema = z
export const issueExecutionWorkspaceSettingsSchema = z
.object({
mode: z.enum(["inherit", "project_primary", "isolated", "agent_default"]).optional(),
mode: z.enum(["inherit", "shared_workspace", "isolated_workspace", "operator_branch", "reuse_existing", "agent_default"]).optional(),
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
})
@@ -29,6 +29,7 @@ export const issueAssigneeAdapterOverridesSchema = z
export const createIssueSchema = z.object({
projectId: z.string().uuid().optional().nullable(),
projectWorkspaceId: z.string().uuid().optional().nullable(),
goalId: z.string().uuid().optional().nullable(),
parentId: z.string().uuid().optional().nullable(),
title: z.string().min(1),
@@ -40,6 +41,15 @@ export const createIssueSchema = z.object({
requestDepth: z.number().int().nonnegative().optional().default(0),
billingCode: z.string().optional().nullable(),
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
executionWorkspaceId: z.string().uuid().optional().nullable(),
executionWorkspacePreference: z.enum([
"inherit",
"shared_workspace",
"isolated_workspace",
"operator_branch",
"reuse_existing",
"agent_default",
]).optional().nullable(),
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
labelIds: z.array(z.string().uuid()).optional(),
});

View File

@@ -3,7 +3,7 @@ import { PROJECT_STATUSES } from "../constants.js";
const executionWorkspaceStrategySchema = z
.object({
type: z.enum(["project_primary", "git_worktree"]).optional(),
type: z.enum(["project_primary", "git_worktree", "adapter_managed", "cloud_sandbox"]).optional(),
baseRef: z.string().optional().nullable(),
branchTemplate: z.string().optional().nullable(),
worktreeParentDir: z.string().optional().nullable(),
@@ -15,30 +15,54 @@ const executionWorkspaceStrategySchema = z
export const projectExecutionWorkspacePolicySchema = z
.object({
enabled: z.boolean(),
defaultMode: z.enum(["project_primary", "isolated"]).optional(),
defaultMode: z.enum(["shared_workspace", "isolated_workspace", "operator_branch", "adapter_default"]).optional(),
allowIssueOverride: z.boolean().optional(),
defaultProjectWorkspaceId: z.string().uuid().optional().nullable(),
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
branchPolicy: z.record(z.unknown()).optional().nullable(),
pullRequestPolicy: z.record(z.unknown()).optional().nullable(),
runtimePolicy: z.record(z.unknown()).optional().nullable(),
cleanupPolicy: z.record(z.unknown()).optional().nullable(),
})
.strict();
const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]);
const projectWorkspaceVisibilitySchema = z.enum(["default", "advanced"]);
const projectWorkspaceFields = {
name: z.string().min(1).optional(),
sourceType: projectWorkspaceSourceTypeSchema.optional(),
cwd: z.string().min(1).optional().nullable(),
repoUrl: z.string().url().optional().nullable(),
repoRef: z.string().optional().nullable(),
defaultRef: z.string().optional().nullable(),
visibility: projectWorkspaceVisibilitySchema.optional(),
setupCommand: z.string().optional().nullable(),
cleanupCommand: z.string().optional().nullable(),
remoteProvider: z.string().optional().nullable(),
remoteWorkspaceRef: z.string().optional().nullable(),
sharedWorkspaceKey: z.string().optional().nullable(),
metadata: z.record(z.unknown()).optional().nullable(),
};
export const createProjectWorkspaceSchema = z.object({
...projectWorkspaceFields,
isPrimary: z.boolean().optional().default(false),
}).superRefine((value, ctx) => {
function validateProjectWorkspace(value: Record<string, unknown>, ctx: z.RefinementCtx) {
const sourceType = value.sourceType ?? "local_path";
const hasCwd = typeof value.cwd === "string" && value.cwd.trim().length > 0;
const hasRepo = typeof value.repoUrl === "string" && value.repoUrl.trim().length > 0;
const hasRemoteRef = typeof value.remoteWorkspaceRef === "string" && value.remoteWorkspaceRef.trim().length > 0;
if (sourceType === "remote_managed") {
if (!hasRemoteRef && !hasRepo) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Remote-managed workspace requires remoteWorkspaceRef or repoUrl.",
path: ["remoteWorkspaceRef"],
});
}
return;
}
if (!hasCwd && !hasRepo) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -46,7 +70,12 @@ export const createProjectWorkspaceSchema = z.object({
path: ["cwd"],
});
}
});
}
export const createProjectWorkspaceSchema = z.object({
...projectWorkspaceFields,
isPrimary: z.boolean().optional().default(false),
}).superRefine(validateProjectWorkspace);
export type CreateProjectWorkspace = z.infer<typeof createProjectWorkspaceSchema>;

View File

@@ -0,0 +1,54 @@
import { z } from "zod";
export const issueWorkProductTypeSchema = z.enum([
"preview_url",
"runtime_service",
"pull_request",
"branch",
"commit",
"artifact",
"document",
]);
export const issueWorkProductStatusSchema = z.enum([
"active",
"ready_for_review",
"approved",
"changes_requested",
"merged",
"closed",
"failed",
"archived",
"draft",
]);
export const issueWorkProductReviewStateSchema = z.enum([
"none",
"needs_board_review",
"approved",
"changes_requested",
]);
export const createIssueWorkProductSchema = z.object({
projectId: z.string().uuid().optional().nullable(),
executionWorkspaceId: z.string().uuid().optional().nullable(),
runtimeServiceId: z.string().uuid().optional().nullable(),
type: issueWorkProductTypeSchema,
provider: z.string().min(1),
externalId: z.string().optional().nullable(),
title: z.string().min(1),
url: z.string().url().optional().nullable(),
status: issueWorkProductStatusSchema.default("active"),
reviewState: issueWorkProductReviewStateSchema.optional().default("none"),
isPrimary: z.boolean().optional().default(false),
healthStatus: z.enum(["unknown", "healthy", "unhealthy"]).optional().default("unknown"),
summary: z.string().optional().nullable(),
metadata: z.record(z.unknown()).optional().nullable(),
createdByRunId: z.string().uuid().optional().nullable(),
});
export type CreateIssueWorkProduct = z.infer<typeof createIssueWorkProductSchema>;
export const updateIssueWorkProductSchema = createIssueWorkProductSchema.partial();
export type UpdateIssueWorkProduct = z.infer<typeof updateIssueWorkProductSchema>;

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { resolveViteHmrPort } from "../app.ts";
describe("resolveViteHmrPort", () => {
it("uses serverPort + 10000 when the result stays in range", () => {
expect(resolveViteHmrPort(3100)).toBe(13_100);
expect(resolveViteHmrPort(55_535)).toBe(65_535);
});
it("falls back below the server port when adding 10000 would overflow", () => {
expect(resolveViteHmrPort(55_536)).toBe(45_536);
expect(resolveViteHmrPort(63_000)).toBe(53_000);
});
it("never returns a privileged or invalid port", () => {
expect(resolveViteHmrPort(65_535)).toBe(55_535);
expect(resolveViteHmrPort(9_000)).toBe(19_000);
});
});

View File

@@ -35,6 +35,11 @@ type CapturePayload = {
paperclipEnvKeys: string[];
};
type LogEntry = {
stream: "stdout" | "stderr";
chunk: string;
};
describe("codex execute", () => {
it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
@@ -62,6 +67,7 @@ describe("codex execute", () => {
process.env.CODEX_HOME = sharedCodexHome;
try {
const logs: LogEntry[] = [];
const result = await execute({
runId: "run-1",
agent: {
@@ -87,7 +93,9 @@ describe("codex execute", () => {
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
onLog: async (stream, chunk) => {
logs.push({ stream, chunk });
},
});
expect(result.exitCode).toBe(0);
@@ -116,6 +124,18 @@ describe("codex execute", () => {
expect((await fs.lstat(isolatedConfig)).isFile()).toBe(true);
expect(await fs.readFile(isolatedConfig, "utf8")).toBe('model = "codex-mini-latest"\n');
expect((await fs.lstat(isolatedSkill)).isSymbolicLink()).toBe(true);
expect(logs).toContainEqual(
expect.objectContaining({
stream: "stdout",
chunk: expect.stringContaining("Using worktree-isolated Codex home"),
}),
);
expect(logs).toContainEqual(
expect.objectContaining({
stream: "stdout",
chunk: expect.stringContaining('Injected Codex skill "paperclip"'),
}),
);
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;

View File

@@ -51,10 +51,10 @@ describe("codex local adapter skill injection", () => {
await createPaperclipRepoSkill(oldRepo, "paperclip");
await fs.symlink(path.join(oldRepo, "skills", "paperclip"), path.join(skillsHome, "paperclip"));
const logs: string[] = [];
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
await ensureCodexSkillsInjected(
async (_stream, chunk) => {
logs.push(chunk);
async (stream, chunk) => {
logs.push({ stream, chunk });
},
{
skillsHome,
@@ -69,7 +69,12 @@ describe("codex local adapter skill injection", () => {
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
await fs.realpath(path.join(currentRepo, "skills", "paperclip")),
);
expect(logs.some((line) => line.includes("Repaired Codex skill"))).toBe(true);
expect(logs).toContainEqual(
expect.objectContaining({
stream: "stdout",
chunk: expect.stringContaining('Repaired Codex skill "paperclip"'),
}),
);
});
it("preserves a custom Codex skill symlink outside Paperclip repo checkouts", async () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildExecutionWorkspaceAdapterConfig,
defaultIssueExecutionWorkspaceSettingsForProject,
gateProjectExecutionWorkspacePolicy,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
@@ -12,36 +13,36 @@ describe("execution workspace policy helpers", () => {
expect(
defaultIssueExecutionWorkspaceSettingsForProject({
enabled: true,
defaultMode: "isolated",
defaultMode: "isolated_workspace",
}),
).toEqual({ mode: "isolated" });
).toEqual({ mode: "isolated_workspace" });
expect(
defaultIssueExecutionWorkspaceSettingsForProject({
enabled: true,
defaultMode: "project_primary",
defaultMode: "shared_workspace",
}),
).toEqual({ mode: "project_primary" });
).toEqual({ mode: "shared_workspace" });
expect(defaultIssueExecutionWorkspaceSettingsForProject(null)).toBeNull();
});
it("prefers explicit issue mode over project policy and legacy overrides", () => {
expect(
resolveExecutionWorkspaceMode({
projectPolicy: { enabled: true, defaultMode: "project_primary" },
issueSettings: { mode: "isolated" },
projectPolicy: { enabled: true, defaultMode: "shared_workspace" },
issueSettings: { mode: "isolated_workspace" },
legacyUseProjectWorkspace: false,
}),
).toBe("isolated");
).toBe("isolated_workspace");
});
it("falls back to project policy before legacy project-workspace compatibility flag", () => {
expect(
resolveExecutionWorkspaceMode({
projectPolicy: { enabled: true, defaultMode: "isolated" },
projectPolicy: { enabled: true, defaultMode: "isolated_workspace" },
issueSettings: null,
legacyUseProjectWorkspace: false,
}),
).toBe("isolated");
).toBe("isolated_workspace");
expect(
resolveExecutionWorkspaceMode({
projectPolicy: null,
@@ -58,7 +59,7 @@ describe("execution workspace policy helpers", () => {
},
projectPolicy: {
enabled: true,
defaultMode: "isolated",
defaultMode: "isolated_workspace",
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/main",
@@ -69,7 +70,7 @@ describe("execution workspace policy helpers", () => {
},
},
issueSettings: null,
mode: "isolated",
mode: "isolated_workspace",
legacyUseProjectWorkspace: null,
});
@@ -92,9 +93,9 @@ describe("execution workspace policy helpers", () => {
expect(
buildExecutionWorkspaceAdapterConfig({
agentConfig: baseConfig,
projectPolicy: { enabled: true, defaultMode: "isolated" },
issueSettings: { mode: "project_primary" },
mode: "project_primary",
projectPolicy: { enabled: true, defaultMode: "isolated_workspace" },
issueSettings: { mode: "shared_workspace" },
mode: "shared_workspace",
legacyUseProjectWorkspace: null,
}).workspaceStrategy,
).toBeUndefined();
@@ -124,7 +125,7 @@ describe("execution workspace policy helpers", () => {
}),
).toEqual({
enabled: true,
defaultMode: "isolated",
defaultMode: "isolated_workspace",
workspaceStrategy: {
type: "git_worktree",
worktreeParentDir: ".paperclip/worktrees",
@@ -137,7 +138,22 @@ describe("execution workspace policy helpers", () => {
mode: "project_primary",
}),
).toEqual({
mode: "project_primary",
mode: "shared_workspace",
});
});
it("disables project execution workspace policy when the instance flag is off", () => {
expect(
gateProjectExecutionWorkspacePolicy(
{ enabled: true, defaultMode: "isolated_workspace" },
false,
),
).toBeNull();
expect(
gateProjectExecutionWorkspacePolicy(
{ enabled: true, defaultMode: "isolated_workspace" },
true,
),
).toEqual({ enabled: true, defaultMode: "isolated_workspace" });
});
});

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { agents } from "@paperclipai/db";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import {
prioritizeProjectWorkspaceCandidatesForRun,
parseSessionCompactionPolicy,
resolveRuntimeSessionParamsForWorkspace,
shouldResetTaskSessionForWake,
@@ -180,6 +181,42 @@ describe("shouldResetTaskSessionForWake", () => {
});
});
describe("prioritizeProjectWorkspaceCandidatesForRun", () => {
it("moves the explicitly selected workspace to the front", () => {
const rows = [
{ id: "workspace-1", cwd: "/tmp/one" },
{ id: "workspace-2", cwd: "/tmp/two" },
{ id: "workspace-3", cwd: "/tmp/three" },
];
expect(
prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-2").map((row) => row.id),
).toEqual(["workspace-2", "workspace-1", "workspace-3"]);
});
it("keeps the original order when no preferred workspace is selected", () => {
const rows = [
{ id: "workspace-1" },
{ id: "workspace-2" },
];
expect(
prioritizeProjectWorkspaceCandidatesForRun(rows, null).map((row) => row.id),
).toEqual(["workspace-1", "workspace-2"]);
});
it("keeps the original order when the selected workspace is missing", () => {
const rows = [
{ id: "workspace-1" },
{ id: "workspace-2" },
];
expect(
prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-9").map((row) => row.id),
).toEqual(["workspace-1", "workspace-2"]);
});
});
describe("parseSessionCompactionPolicy", () => {
it("disables Paperclip-managed rotation by default for codex and claude local", () => {
expect(parseSessionCompactionPolicy(buildAgent("codex_local"))).toEqual({

View File

@@ -0,0 +1,99 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { instanceSettingsRoutes } from "../routes/instance-settings.js";
const mockInstanceSettingsService = vi.hoisted(() => ({
getExperimental: vi.fn(),
updateExperimental: vi.fn(),
listCompanyIds: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
logActivity: mockLogActivity,
}));
function createApp(actor: any) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", instanceSettingsRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("instance settings routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
});
mockInstanceSettingsService.updateExperimental.mockResolvedValue({
id: "instance-settings-1",
experimental: {
enableIsolatedWorkspaces: true,
},
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]);
});
it("allows local board users to read and update experimental settings", async () => {
const app = createApp({
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
});
const getRes = await request(app).get("/api/instance/settings/experimental");
expect(getRes.status).toBe(200);
expect(getRes.body).toEqual({ enableIsolatedWorkspaces: false });
const patchRes = await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableIsolatedWorkspaces: true });
expect(patchRes.status).toBe(200);
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
enableIsolatedWorkspaces: true,
});
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
it("rejects non-admin board users", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app).get("/api/instance/settings/experimental");
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.getExperimental).not.toHaveBeenCalled();
});
it("rejects agent callers", async () => {
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
});
const res = await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableIsolatedWorkspaces: true });
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from "vitest";
import { workProductService } from "../services/work-products.ts";
function createWorkProductRow(overrides: Partial<Record<string, unknown>> = {}) {
const now = new Date("2026-03-17T00:00:00.000Z");
return {
id: "work-product-1",
companyId: "company-1",
projectId: "project-1",
issueId: "issue-1",
executionWorkspaceId: null,
runtimeServiceId: null,
type: "pull_request",
provider: "github",
externalId: null,
title: "PR 1",
url: "https://example.com/pr/1",
status: "open",
reviewState: "draft",
isPrimary: true,
healthStatus: "unknown",
summary: null,
metadata: null,
createdByRunId: null,
createdAt: now,
updatedAt: now,
...overrides,
};
}
describe("workProductService", () => {
it("uses a transaction when creating a new primary work product", async () => {
const updatedWhere = vi.fn(async () => undefined);
const updateSet = vi.fn(() => ({ where: updatedWhere }));
const txUpdate = vi.fn(() => ({ set: updateSet }));
const insertedRow = createWorkProductRow();
const insertReturning = vi.fn(async () => [insertedRow]);
const insertValues = vi.fn(() => ({ returning: insertReturning }));
const txInsert = vi.fn(() => ({ values: insertValues }));
const tx = {
update: txUpdate,
insert: txInsert,
};
const transaction = vi.fn(async (callback: (input: typeof tx) => Promise<unknown>) => await callback(tx));
const svc = workProductService({ transaction } as any);
const result = await svc.createForIssue("issue-1", "company-1", {
type: "pull_request",
provider: "github",
title: "PR 1",
status: "open",
reviewState: "draft",
isPrimary: true,
});
expect(transaction).toHaveBeenCalledTimes(1);
expect(txUpdate).toHaveBeenCalledTimes(1);
expect(txInsert).toHaveBeenCalledTimes(1);
expect(result?.id).toBe("work-product-1");
});
it("uses a transaction when promoting an existing work product to primary", async () => {
const existingRow = createWorkProductRow({ isPrimary: false });
const selectWhere = vi.fn(async () => [existingRow]);
const selectFrom = vi.fn(() => ({ where: selectWhere }));
const txSelect = vi.fn(() => ({ from: selectFrom }));
const updateReturning = vi
.fn()
.mockResolvedValue([createWorkProductRow({ reviewState: "ready_for_review" })]);
const updateWhere = vi.fn(() => ({ returning: updateReturning }));
const updateSet = vi.fn(() => ({ where: updateWhere }));
const txUpdate = vi.fn(() => ({ set: updateSet }));
const tx = {
select: txSelect,
update: txUpdate,
};
const transaction = vi.fn(async (callback: (input: typeof tx) => Promise<unknown>) => await callback(tx));
const svc = workProductService({ transaction } as any);
const result = await svc.update("work-product-1", {
isPrimary: true,
reviewState: "ready_for_review",
});
expect(transaction).toHaveBeenCalledTimes(1);
expect(txSelect).toHaveBeenCalledTimes(1);
expect(txUpdate).toHaveBeenCalledTimes(2);
expect(result?.reviewState).toBe("ready_for_review");
});
});

View File

@@ -5,12 +5,16 @@ import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import {
cleanupExecutionWorkspaceArtifacts,
ensureRuntimeServicesForRun,
normalizeAdapterManagedRuntimeServices,
realizeExecutionWorkspace,
releaseRuntimeServicesForRun,
stopRuntimeServicesForExecutionWorkspace,
type RealizedExecutionWorkspace,
} from "../services/workspace-runtime.ts";
import type { WorkspaceOperation } from "@paperclipai/shared";
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
const execFileAsync = promisify(execFile);
const leasedRunIds = new Set<string>();
@@ -48,6 +52,68 @@ function buildWorkspace(cwd: string): RealizedExecutionWorkspace {
};
}
function createWorkspaceOperationRecorderDouble() {
const operations: Array<{
phase: string;
command: string | null;
cwd: string | null;
metadata: Record<string, unknown> | null;
result: {
status?: string;
exitCode?: number | null;
stdout?: string | null;
stderr?: string | null;
system?: string | null;
metadata?: Record<string, unknown> | null;
};
}> = [];
let executionWorkspaceId: string | null = null;
const recorder: WorkspaceOperationRecorder = {
attachExecutionWorkspaceId: async (nextExecutionWorkspaceId) => {
executionWorkspaceId = nextExecutionWorkspaceId;
},
recordOperation: async (input) => {
const result = await input.run();
operations.push({
phase: input.phase,
command: input.command ?? null,
cwd: input.cwd ?? null,
metadata: {
...(input.metadata ?? {}),
...(executionWorkspaceId ? { executionWorkspaceId } : {}),
},
result,
});
return {
id: `op-${operations.length}`,
companyId: "company-1",
executionWorkspaceId,
heartbeatRunId: "run-1",
phase: input.phase,
command: input.command ?? null,
cwd: input.cwd ?? null,
status: (result.status ?? "succeeded") as WorkspaceOperation["status"],
exitCode: result.exitCode ?? null,
logStore: "local_file",
logRef: `op-${operations.length}.ndjson`,
logBytes: 0,
logSha256: null,
logCompressed: false,
stdoutExcerpt: result.stdout ?? null,
stderrExcerpt: result.stderr ?? null,
metadata: input.metadata ?? null,
startedAt: new Date(),
finishedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
},
};
return { recorder, operations };
}
afterEach(async () => {
await Promise.all(
Array.from(leasedRunIds).map(async (runId) => {
@@ -55,6 +121,10 @@ afterEach(async () => {
leasedRunIds.delete(runId);
}),
);
delete process.env.PAPERCLIP_CONFIG;
delete process.env.PAPERCLIP_HOME;
delete process.env.PAPERCLIP_INSTANCE_ID;
delete process.env.DATABASE_URL;
});
describe("realizeExecutionWorkspace", () => {
@@ -211,6 +281,304 @@ describe("realizeExecutionWorkspace", () => {
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
});
it("records worktree setup and provision operations when a recorder is provided", async () => {
const repoRoot = await createTempRepo();
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
await fs.writeFile(
path.join(repoRoot, "scripts", "provision.sh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
"printf 'provisioned\\n'",
].join("\n"),
"utf8",
);
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
await runGit(repoRoot, ["commit", "-m", "Add recorder provision script"]);
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}}",
provisionCommand: "bash ./scripts/provision.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-540",
title: "Record workspace operations",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
recorder,
});
expect(operations.map((operation) => operation.phase)).toEqual([
"worktree_prepare",
"workspace_provision",
]);
expect(operations[0]?.command).toContain("git worktree add");
expect(operations[0]?.metadata).toMatchObject({
branchName: "PAP-540-record-workspace-operations",
created: true,
});
expect(operations[1]?.command).toBe("bash ./scripts/provision.sh");
});
it("reuses an existing branch without resetting it when recreating a missing worktree", async () => {
const repoRoot = await createTempRepo();
const branchName = "PAP-450-recreate-missing-worktree";
await runGit(repoRoot, ["checkout", "-b", branchName]);
await fs.writeFile(path.join(repoRoot, "feature.txt"), "preserve me\n", "utf8");
await runGit(repoRoot, ["add", "feature.txt"]);
await runGit(repoRoot, ["commit", "-m", "Add preserved feature"]);
const expectedHead = (await execFileAsync("git", ["rev-parse", branchName], { cwd: repoRoot })).stdout.trim();
await runGit(repoRoot, ["checkout", "main"]);
const workspace = 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-450",
title: "Recreate missing worktree",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
expect(workspace.branchName).toBe(branchName);
await expect(fs.readFile(path.join(workspace.cwd, "feature.txt"), "utf8")).resolves.toBe("preserve me\n");
const actualHead = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspace.cwd })).stdout.trim();
expect(actualHead).toBe(expectedHead);
});
it("removes a created git worktree and branch during cleanup", async () => {
const repoRoot = await createTempRepo();
const workspace = 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-449",
title: "Cleanup workspace",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
const cleanup = await cleanupExecutionWorkspaceArtifacts({
workspace: {
id: "execution-workspace-1",
cwd: workspace.cwd,
providerType: "git_worktree",
providerRef: workspace.worktreePath,
branchName: workspace.branchName,
repoUrl: workspace.repoUrl,
baseRef: workspace.repoRef,
projectId: workspace.projectId,
projectWorkspaceId: workspace.workspaceId,
sourceIssueId: "issue-1",
metadata: {
createdByRuntime: true,
},
},
projectWorkspace: {
cwd: repoRoot,
cleanupCommand: null,
},
});
expect(cleanup.cleaned).toBe(true);
expect(cleanup.warnings).toEqual([]);
await expect(fs.stat(workspace.cwd)).rejects.toThrow();
await expect(
execFileAsync("git", ["branch", "--list", workspace.branchName!], { cwd: repoRoot }),
).resolves.toMatchObject({
stdout: "",
});
});
it("keeps an unmerged runtime-created branch and warns instead of force deleting it", async () => {
const repoRoot = await createTempRepo();
const workspace = 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-451",
title: "Keep unmerged branch",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
await fs.writeFile(path.join(workspace.cwd, "unmerged.txt"), "still here\n", "utf8");
await runGit(workspace.cwd, ["add", "unmerged.txt"]);
await runGit(workspace.cwd, ["commit", "-m", "Keep unmerged work"]);
const cleanup = await cleanupExecutionWorkspaceArtifacts({
workspace: {
id: "execution-workspace-1",
cwd: workspace.cwd,
providerType: "git_worktree",
providerRef: workspace.worktreePath,
branchName: workspace.branchName,
repoUrl: workspace.repoUrl,
baseRef: workspace.repoRef,
projectId: workspace.projectId,
projectWorkspaceId: workspace.workspaceId,
sourceIssueId: "issue-1",
metadata: {
createdByRuntime: true,
},
},
projectWorkspace: {
cwd: repoRoot,
cleanupCommand: null,
},
});
expect(cleanup.cleaned).toBe(true);
expect(cleanup.warnings).toHaveLength(1);
expect(cleanup.warnings[0]).toContain(`Skipped deleting branch "${workspace.branchName}"`);
await expect(
execFileAsync("git", ["branch", "--list", workspace.branchName!], { cwd: repoRoot }),
).resolves.toMatchObject({
stdout: expect.stringContaining(workspace.branchName!),
});
});
it("records teardown and cleanup operations when a recorder is provided", async () => {
const repoRoot = await createTempRepo();
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
const workspace = 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-541",
title: "Cleanup recorder",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
await cleanupExecutionWorkspaceArtifacts({
workspace: {
id: "execution-workspace-1",
cwd: workspace.cwd,
providerType: "git_worktree",
providerRef: workspace.worktreePath,
branchName: workspace.branchName,
repoUrl: workspace.repoUrl,
baseRef: workspace.repoRef,
projectId: workspace.projectId,
projectWorkspaceId: workspace.workspaceId,
sourceIssueId: "issue-1",
metadata: {
createdByRuntime: true,
},
},
projectWorkspace: {
cwd: repoRoot,
cleanupCommand: "printf 'cleanup ok\\n'",
},
recorder,
});
expect(operations.map((operation) => operation.phase)).toEqual([
"workspace_teardown",
"worktree_cleanup",
"worktree_cleanup",
]);
expect(operations[0]?.command).toBe("printf 'cleanup ok\\n'");
expect(operations[1]?.metadata).toMatchObject({
cleanupAction: "worktree_remove",
});
expect(operations[2]?.metadata).toMatchObject({
cleanupAction: "branch_delete",
});
});
});
describe("ensureRuntimeServicesForRun", () => {
@@ -312,6 +680,199 @@ describe("ensureRuntimeServicesForRun", () => {
expect(third[0]?.reused).toBe(false);
expect(third[0]?.id).not.toBe(first[0]?.id);
});
it("does not leak parent Paperclip instance env into runtime service commands", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-env-"));
const workspace = buildWorkspace(workspaceRoot);
const envCapturePath = path.join(workspaceRoot, "captured-env.json");
const serviceCommand = [
"node -e",
JSON.stringify(
[
"const fs = require('node:fs');",
`fs.writeFileSync(${JSON.stringify(envCapturePath)}, JSON.stringify({`,
"paperclipConfig: process.env.PAPERCLIP_CONFIG ?? null,",
"paperclipHome: process.env.PAPERCLIP_HOME ?? null,",
"paperclipInstanceId: process.env.PAPERCLIP_INSTANCE_ID ?? null,",
"databaseUrl: process.env.DATABASE_URL ?? null,",
"customEnv: process.env.RUNTIME_CUSTOM_ENV ?? null,",
"port: process.env.PORT ?? null,",
"}));",
"require('node:http').createServer((req, res) => res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1');",
].join(" "),
),
].join(" ");
process.env.PAPERCLIP_CONFIG = "/tmp/base-paperclip-config.json";
process.env.PAPERCLIP_HOME = "/tmp/base-paperclip-home";
process.env.PAPERCLIP_INSTANCE_ID = "base-instance";
process.env.DATABASE_URL = "postgres://shared-db.example.com/paperclip";
const runId = "run-env";
leasedRunIds.add(runId);
const services = await ensureRuntimeServicesForRun({
runId,
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace,
executionWorkspaceId: "execution-workspace-1",
config: {
workspaceRuntime: {
services: [
{
name: "web",
command: serviceCommand,
port: { type: "auto" },
readiness: {
type: "http",
urlTemplate: "http://127.0.0.1:{{port}}",
timeoutSec: 10,
intervalMs: 100,
},
lifecycle: "shared",
reuseScope: "execution_workspace",
stopPolicy: {
type: "on_run_finish",
},
},
],
},
},
adapterEnv: {
RUNTIME_CUSTOM_ENV: "from-adapter",
},
});
expect(services).toHaveLength(1);
const captured = JSON.parse(await fs.readFile(envCapturePath, "utf8")) as Record<string, string | null>;
expect(captured.paperclipConfig).toBeNull();
expect(captured.paperclipHome).toBeNull();
expect(captured.paperclipInstanceId).toBeNull();
expect(captured.databaseUrl).toBeNull();
expect(captured.customEnv).toBe("from-adapter");
expect(captured.port).toMatch(/^\d+$/);
expect(services[0]?.executionWorkspaceId).toBe("execution-workspace-1");
expect(services[0]?.scopeType).toBe("execution_workspace");
expect(services[0]?.scopeId).toBe("execution-workspace-1");
});
it("stops execution workspace runtime services by executionWorkspaceId", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-"));
const workspace = buildWorkspace(workspaceRoot);
const runId = "run-stop";
leasedRunIds.add(runId);
const services = await ensureRuntimeServicesForRun({
runId,
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace,
executionWorkspaceId: "execution-workspace-stop",
config: {
workspaceRuntime: {
services: [
{
name: "web",
command:
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"",
port: { type: "auto" },
readiness: {
type: "http",
urlTemplate: "http://127.0.0.1:{{port}}",
timeoutSec: 10,
intervalMs: 100,
},
lifecycle: "shared",
reuseScope: "execution_workspace",
stopPolicy: {
type: "manual",
},
},
],
},
},
adapterEnv: {},
});
expect(services[0]?.url).toBeTruthy();
await stopRuntimeServicesForExecutionWorkspace({
executionWorkspaceId: "execution-workspace-stop",
workspaceCwd: workspace.cwd,
});
await releaseRuntimeServicesForRun(runId);
leasedRunIds.delete(runId);
await new Promise((resolve) => setTimeout(resolve, 250));
await expect(fetch(services[0]!.url!)).rejects.toThrow();
});
it("does not stop services in sibling directories when matching by workspace cwd", async () => {
const workspaceParent = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-sibling-"));
const targetWorkspaceRoot = path.join(workspaceParent, "project");
const siblingWorkspaceRoot = path.join(workspaceParent, "project-extended", "service");
await fs.mkdir(targetWorkspaceRoot, { recursive: true });
await fs.mkdir(siblingWorkspaceRoot, { recursive: true });
const siblingWorkspace = buildWorkspace(siblingWorkspaceRoot);
const runId = "run-sibling";
leasedRunIds.add(runId);
const services = await ensureRuntimeServicesForRun({
runId,
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace: siblingWorkspace,
executionWorkspaceId: "execution-workspace-sibling",
config: {
workspaceRuntime: {
services: [
{
name: "web",
command:
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"",
port: { type: "auto" },
readiness: {
type: "http",
urlTemplate: "http://127.0.0.1:{{port}}",
timeoutSec: 10,
intervalMs: 100,
},
lifecycle: "shared",
reuseScope: "execution_workspace",
stopPolicy: {
type: "manual",
},
},
],
},
},
adapterEnv: {},
});
await stopRuntimeServicesForExecutionWorkspace({
executionWorkspaceId: "execution-workspace-target",
workspaceCwd: targetWorkspaceRoot,
});
const response = await fetch(services[0]!.url!);
expect(await response.text()).toBe("ok");
await releaseRuntimeServicesForRun(runId);
leasedRunIds.delete(runId);
});
});
describe("normalizeAdapterManagedRuntimeServices", () => {
@@ -374,6 +935,7 @@ describe("normalizeAdapterManagedRuntimeServices", () => {
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
executionWorkspaceId: null,
issueId: "issue-1",
serviceName: "preview",
provider: "adapter_managed",
@@ -383,4 +945,33 @@ describe("normalizeAdapterManagedRuntimeServices", () => {
});
expect(first[0]?.id).toBe(second[0]?.id);
});
it("prefers execution workspace ids over cwd for execution-scoped adapter services", () => {
const workspace = buildWorkspace("/tmp/project");
const refs = normalizeAdapterManagedRuntimeServices({
adapterType: "openclaw_gateway",
runId: "run-1",
agent: {
id: "agent-1",
name: "Gateway Agent",
companyId: "company-1",
},
issue: null,
workspace,
executionWorkspaceId: "execution-workspace-1",
reports: [
{
serviceName: "preview",
scopeType: "execution_workspace",
},
],
});
expect(refs[0]).toMatchObject({
scopeType: "execution_workspace",
scopeId: "execution-workspace-1",
executionWorkspaceId: "execution-workspace-1",
});
});
});

View File

@@ -15,6 +15,7 @@ import { companySkillRoutes } from "./routes/company-skills.js";
import { agentRoutes } from "./routes/agents.js";
import { projectRoutes } from "./routes/projects.js";
import { issueRoutes } from "./routes/issues.js";
import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js";
import { goalRoutes } from "./routes/goals.js";
import { approvalRoutes } from "./routes/approvals.js";
import { secretRoutes } from "./routes/secrets.js";
@@ -22,6 +23,7 @@ import { costRoutes } from "./routes/costs.js";
import { activityRoutes } from "./routes/activity.js";
import { dashboardRoutes } from "./routes/dashboard.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import { llmRoutes } from "./routes/llms.js";
import { assetRoutes } from "./routes/assets.js";
import { accessRoutes } from "./routes/access.js";
@@ -47,6 +49,13 @@ import type { BetterAuthSessionResult } from "./auth/better-auth.js";
type UiMode = "none" | "static" | "vite-dev";
export function resolveViteHmrPort(serverPort: number): number {
if (serverPort <= 55_535) {
return serverPort + 10_000;
}
return Math.max(1_024, serverPort - 10_000);
}
export async function createApp(
db: Db,
opts: {
@@ -133,6 +142,7 @@ export async function createApp(
api.use(assetRoutes(db, opts.storageService));
api.use(projectRoutes(db));
api.use(issueRoutes(db, opts.storageService));
api.use(executionWorkspaceRoutes(db));
api.use(goalRoutes(db));
api.use(approvalRoutes(db));
api.use(secretRoutes(db));
@@ -140,6 +150,7 @@ export async function createApp(
api.use(activityRoutes(db));
api.use(dashboardRoutes(db));
api.use(sidebarBadgeRoutes(db));
api.use(instanceSettingsRoutes(db));
const hostServicesDisposers = new Map<string, () => void>();
const workerManager = createPluginWorkerManager();
const pluginRegistry = pluginRegistryService(db);
@@ -240,7 +251,7 @@ export async function createApp(
if (opts.uiMode === "vite-dev") {
const uiRoot = path.resolve(__dirname, "../../ui");
const hmrPort = opts.serverPort + 10000;
const hmrPort = resolveViteHmrPort(opts.serverPort);
const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({
root: uiRoot,

View File

@@ -4,6 +4,7 @@ import path from "node:path";
const DEFAULT_INSTANCE_ID = "default";
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
const FRIENDLY_PATH_SEGMENT_RE = /[^a-zA-Z0-9._-]+/g;
function expandHomePrefix(value: string): string {
if (value === "~") return os.homedir();
@@ -61,6 +62,34 @@ export function resolveDefaultAgentWorkspaceDir(agentId: string): string {
return path.resolve(resolvePaperclipInstanceRoot(), "workspaces", trimmed);
}
function sanitizeFriendlyPathSegment(value: string | null | undefined, fallback = "_default"): string {
const trimmed = value?.trim() ?? "";
if (!trimmed) return fallback;
const sanitized = trimmed
.replace(FRIENDLY_PATH_SEGMENT_RE, "-")
.replace(/^-+|-+$/g, "");
return sanitized || fallback;
}
export function resolveManagedProjectWorkspaceDir(input: {
companyId: string;
projectId: string;
repoName?: string | null;
}): string {
const companyId = input.companyId.trim();
const projectId = input.projectId.trim();
if (!companyId || !projectId) {
throw new Error("Managed project workspace path requires companyId and projectId.");
}
return path.resolve(
resolvePaperclipInstanceRoot(),
"projects",
sanitizeFriendlyPathSegment(companyId, "company"),
sanitizeFriendlyPathSegment(projectId, "project"),
sanitizeFriendlyPathSegment(input.repoName, "_default"),
);
}
export function resolveHomeAwarePath(value: string): string {
return path.resolve(expandHomePrefix(value));
}

View File

@@ -10,6 +10,7 @@ import { and, eq } from "drizzle-orm";
import {
createDb,
ensurePostgresDatabase,
getPostgresDataDirectory,
inspectMigrations,
applyPendingMigrations,
reconcilePendingMigrationHistory,
@@ -320,45 +321,60 @@ export async function startServer(): Promise<StartedServer> {
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,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: appendEmbeddedPostgresLog,
onError: appendEmbeddedPostgresLog,
});
const configuredAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${configuredPort}/postgres`;
try {
const actualDataDir = await getPostgresDataDirectory(configuredAdminConnectionString);
if (
typeof actualDataDir !== "string" ||
resolve(actualDataDir) !== resolve(dataDir)
) {
throw new Error("reachable postgres does not use the expected embedded data directory");
}
await ensurePostgresDatabase(configuredAdminConnectionString, "paperclip");
logger.warn(
`Embedded PostgreSQL appears to already be reachable without a pid file; reusing existing server on configured port ${configuredPort}`,
);
} catch {
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,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: appendEmbeddedPostgresLog,
onError: appendEmbeddedPostgresLog,
});
if (!clusterAlreadyInitialized) {
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.initialise();
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 });
}
try {
await embeddedPostgres.start();
} catch (err) {
logEmbeddedPostgresFailure("start", err);
throw err;
}
embeddedPostgresStartedByThisProcess = true;
}
const embeddedAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;

View File

@@ -36,6 +36,7 @@ import {
issueService,
logActivity,
secretService,
workspaceOperationService,
} from "../services/index.js";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
@@ -70,6 +71,7 @@ export function agentRoutes(db: Db) {
const issueApprovalsSvc = issueApprovalService(db);
const secretsSvc = secretService(db);
const companySkills = companySkillService(db);
const workspaceOperations = workspaceOperationService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
function canCreateAgents(agent: { role: string; permissions: Record<string, unknown> | null | undefined }) {
@@ -1713,6 +1715,40 @@ export function agentRoutes(db: Db) {
res.json(result);
});
router.get("/heartbeat-runs/:runId/workspace-operations", async (req, res) => {
const runId = req.params.runId as string;
const run = await heartbeat.getRun(runId);
if (!run) {
res.status(404).json({ error: "Heartbeat run not found" });
return;
}
assertCompanyAccess(req, run.companyId);
const context = asRecord(run.contextSnapshot);
const executionWorkspaceId = asNonEmptyString(context?.executionWorkspaceId);
const operations = await workspaceOperations.listForRun(runId, executionWorkspaceId);
res.json(redactCurrentUserValue(operations));
});
router.get("/workspace-operations/:operationId/log", async (req, res) => {
const operationId = req.params.operationId as string;
const operation = await workspaceOperations.getById(operationId);
if (!operation) {
res.status(404).json({ error: "Workspace operation not found" });
return;
}
assertCompanyAccess(req, operation.companyId);
const offset = Number(req.query.offset ?? 0);
const limitBytes = Number(req.query.limitBytes ?? 256000);
const result = await workspaceOperations.readLog(operationId, {
offset: Number.isFinite(offset) ? offset : 0,
limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000,
});
res.json(result);
});
router.get("/issues/:issueId/live-runs", async (req, res) => {
const rawId = req.params.issueId as string;
const issueSvc = issueService(db);

View File

@@ -0,0 +1,181 @@
import { and, eq } from "drizzle-orm";
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { issues, projects, projectWorkspaces } from "@paperclipai/db";
import { updateExecutionWorkspaceSchema } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
import {
cleanupExecutionWorkspaceArtifacts,
stopRuntimeServicesForExecutionWorkspace,
} from "../services/workspace-runtime.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
export function executionWorkspaceRoutes(db: Db) {
const router = Router();
const svc = executionWorkspaceService(db);
const workspaceOperationsSvc = workspaceOperationService(db);
router.get("/companies/:companyId/execution-workspaces", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const workspaces = await svc.list(companyId, {
projectId: req.query.projectId as string | undefined,
projectWorkspaceId: req.query.projectWorkspaceId as string | undefined,
issueId: req.query.issueId as string | undefined,
status: req.query.status as string | undefined,
reuseEligible: req.query.reuseEligible === "true",
});
res.json(workspaces);
});
router.get("/execution-workspaces/:id", async (req, res) => {
const id = req.params.id as string;
const workspace = await svc.getById(id);
if (!workspace) {
res.status(404).json({ error: "Execution workspace not found" });
return;
}
assertCompanyAccess(req, workspace.companyId);
res.json(workspace);
});
router.patch("/execution-workspaces/:id", validate(updateExecutionWorkspaceSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Execution workspace not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const patch: Record<string, unknown> = {
...req.body,
...(req.body.cleanupEligibleAt ? { cleanupEligibleAt: new Date(req.body.cleanupEligibleAt) } : {}),
};
let workspace = existing;
let cleanupWarnings: string[] = [];
if (req.body.status === "archived" && existing.status !== "archived") {
const linkedIssues = await db
.select({
id: issues.id,
status: issues.status,
})
.from(issues)
.where(and(eq(issues.companyId, existing.companyId), eq(issues.executionWorkspaceId, existing.id)));
const activeLinkedIssues = linkedIssues.filter((issue) => !TERMINAL_ISSUE_STATUSES.has(issue.status));
if (activeLinkedIssues.length > 0) {
res.status(409).json({
error: `Cannot archive execution workspace while ${activeLinkedIssues.length} linked issue(s) are still open`,
});
return;
}
const closedAt = new Date();
const archivedWorkspace = await svc.update(id, {
...patch,
status: "archived",
closedAt,
cleanupReason: null,
});
if (!archivedWorkspace) {
res.status(404).json({ error: "Execution workspace not found" });
return;
}
workspace = archivedWorkspace;
try {
await stopRuntimeServicesForExecutionWorkspace({
db,
executionWorkspaceId: existing.id,
workspaceCwd: existing.cwd,
});
const projectWorkspace = existing.projectWorkspaceId
? await db
.select({
cwd: projectWorkspaces.cwd,
cleanupCommand: projectWorkspaces.cleanupCommand,
})
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.id, existing.projectWorkspaceId),
eq(projectWorkspaces.companyId, existing.companyId),
),
)
.then((rows) => rows[0] ?? null)
: null;
const projectPolicy = existing.projectId
? await db
.select({
executionWorkspacePolicy: projects.executionWorkspacePolicy,
})
.from(projects)
.where(and(eq(projects.id, existing.projectId), eq(projects.companyId, existing.companyId)))
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy))
: null;
const cleanupResult = await cleanupExecutionWorkspaceArtifacts({
workspace: existing,
projectWorkspace,
teardownCommand: projectPolicy?.workspaceStrategy?.teardownCommand ?? null,
recorder: workspaceOperationsSvc.createRecorder({
companyId: existing.companyId,
executionWorkspaceId: existing.id,
}),
});
cleanupWarnings = cleanupResult.warnings;
const cleanupPatch: Record<string, unknown> = {
closedAt,
cleanupReason: cleanupWarnings.length > 0 ? cleanupWarnings.join(" | ") : null,
};
if (!cleanupResult.cleaned) {
cleanupPatch.status = "cleanup_failed";
}
if (cleanupResult.warnings.length > 0 || !cleanupResult.cleaned) {
workspace = (await svc.update(id, cleanupPatch)) ?? workspace;
}
} catch (error) {
const failureReason = error instanceof Error ? error.message : String(error);
workspace =
(await svc.update(id, {
status: "cleanup_failed",
closedAt,
cleanupReason: failureReason,
})) ?? workspace;
res.status(500).json({
error: `Failed to archive execution workspace: ${failureReason}`,
});
return;
}
} else {
const updatedWorkspace = await svc.update(id, patch);
if (!updatedWorkspace) {
res.status(404).json({ error: "Execution workspace not found" });
return;
}
workspace = updatedWorkspace;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "execution_workspace.updated",
entityType: "execution_workspace",
entityId: workspace.id,
details: {
changedKeys: Object.keys(req.body).sort(),
...(cleanupWarnings.length > 0 ? { cleanupWarnings } : {}),
},
});
res.json(workspace);
});
return router;
}

View File

@@ -13,3 +13,4 @@ export { dashboardRoutes } from "./dashboard.js";
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
export { llmRoutes } from "./llms.js";
export { accessRoutes } from "./access.js";
export { instanceSettingsRoutes } from "./instance-settings.js";

View File

@@ -0,0 +1,59 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import { patchInstanceExperimentalSettingsSchema } from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js";
import { instanceSettingsService, logActivity } from "../services/index.js";
import { getActorInfo } from "./authz.js";
function assertCanManageInstanceSettings(req: Request) {
if (req.actor.type !== "board") {
throw forbidden("Board access required");
}
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
return;
}
throw forbidden("Instance admin access required");
}
export function instanceSettingsRoutes(db: Db) {
const router = Router();
const svc = instanceSettingsService(db);
router.get("/instance/settings/experimental", async (req, res) => {
assertCanManageInstanceSettings(req);
res.json(await svc.getExperimental());
});
router.patch(
"/instance/settings/experimental",
validate(patchInstanceExperimentalSettingsSchema),
async (req, res) => {
assertCanManageInstanceSettings(req);
const updated = await svc.updateExperimental(req.body);
const actor = getActorInfo(req);
const companyIds = await svc.listCompanyIds();
await Promise.all(
companyIds.map((companyId) =>
logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "instance.settings.experimental_updated",
entityType: "instance_settings",
entityId: updated.id,
details: {
experimental: updated.experimental,
changedKeys: Object.keys(req.body).sort(),
},
}),
),
);
res.json(updated.experimental);
},
);
return router;
}

View File

@@ -4,11 +4,13 @@ import type { Db } from "@paperclipai/db";
import {
addIssueCommentSchema,
createIssueAttachmentMetadataSchema,
createIssueWorkProductSchema,
createIssueLabelSchema,
checkoutIssueSchema,
createIssueSchema,
linkIssueApprovalSchema,
issueDocumentKeySchema,
updateIssueWorkProductSchema,
upsertIssueDocumentSchema,
updateIssueSchema,
} from "@paperclipai/shared";
@@ -17,6 +19,7 @@ import { validate } from "../middleware/validate.js";
import {
accessService,
agentService,
executionWorkspaceService,
goalService,
heartbeatService,
issueApprovalService,
@@ -24,6 +27,7 @@ import {
documentService,
logActivity,
projectService,
workProductService,
} from "../services/index.js";
import { logger } from "../middleware/logger.js";
import { forbidden, HttpError, unauthorized } from "../errors.js";
@@ -42,6 +46,8 @@ export function issueRoutes(db: Db, storage: StorageService) {
const projectsSvc = projectService(db);
const goalsSvc = goalService(db);
const issueApprovalsSvc = issueApprovalService(db);
const executionWorkspacesSvc = executionWorkspaceService(db);
const workProductsSvc = workProductService(db);
const documentsSvc = documentService(db);
const upload = multer({
storage: multer.memoryStorage(),
@@ -311,6 +317,10 @@ export function issueRoutes(db: Db, storage: StorageService) {
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
: [];
const currentExecutionWorkspace = issue.executionWorkspaceId
? await executionWorkspacesSvc.getById(issue.executionWorkspaceId)
: null;
const workProducts = await workProductsSvc.listForIssue(issue.id);
res.json({
...issue,
goalId: goal?.id ?? issue.goalId,
@@ -319,6 +329,8 @@ export function issueRoutes(db: Db, storage: StorageService) {
project: project ?? null,
goal: goal ?? null,
mentionedProjects,
currentExecutionWorkspace,
workProducts,
});
});
@@ -395,6 +407,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
});
});
router.get("/issues/:id/work-products", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const workProducts = await workProductsSvc.listForIssue(issue.id);
res.json(workProducts);
});
router.get("/issues/:id/documents", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
@@ -535,6 +559,93 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.json({ ok: true });
});
router.post("/issues/:id/work-products", validate(createIssueWorkProductSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, {
...req.body,
projectId: req.body.projectId ?? issue.projectId ?? null,
});
if (!product) {
res.status(422).json({ error: "Invalid work product payload" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.work_product_created",
entityType: "issue",
entityId: issue.id,
details: { workProductId: product.id, type: product.type, provider: product.provider },
});
res.status(201).json(product);
});
router.patch("/work-products/:id", validate(updateIssueWorkProductSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await workProductsSvc.getById(id);
if (!existing) {
res.status(404).json({ error: "Work product not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const product = await workProductsSvc.update(id, req.body);
if (!product) {
res.status(404).json({ error: "Work product not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.work_product_updated",
entityType: "issue",
entityId: existing.issueId,
details: { workProductId: product.id, changedKeys: Object.keys(req.body).sort() },
});
res.json(product);
});
router.delete("/work-products/:id", async (req, res) => {
const id = req.params.id as string;
const existing = await workProductsSvc.getById(id);
if (!existing) {
res.status(404).json({ error: "Work product not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const removed = await workProductsSvc.remove(id);
if (!removed) {
res.status(404).json({ error: "Work product not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.work_product_deleted",
entityType: "issue",
entityId: existing.issueId,
details: { workProductId: removed.id, type: removed.type },
});
res.json(removed);
});
router.post("/issues/:id/read", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);

View File

@@ -2,11 +2,12 @@ import type {
ExecutionWorkspaceMode,
ExecutionWorkspaceStrategy,
IssueExecutionWorkspaceSettings,
ProjectExecutionWorkspaceDefaultMode,
ProjectExecutionWorkspacePolicy,
} from "@paperclipai/shared";
import { asString, parseObject } from "../adapters/utils.js";
type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit">;
type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing">;
function cloneRecord(value: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
if (!value) return null;
@@ -16,7 +17,7 @@ function cloneRecord(value: Record<string, unknown> | null | undefined): Record<
function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrategy | null {
const parsed = parseObject(raw);
const type = asString(parsed.type, "");
if (type !== "project_primary" && type !== "git_worktree") {
if (type !== "project_primary" && type !== "git_worktree" && type !== "adapter_managed" && type !== "cloud_sandbox") {
return null;
}
return {
@@ -33,16 +34,31 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
const parsed = parseObject(raw);
if (Object.keys(parsed).length === 0) return null;
const enabled = typeof parsed.enabled === "boolean" ? parsed.enabled : false;
const workspaceStrategy = parseExecutionWorkspaceStrategy(parsed.workspaceStrategy);
const defaultMode = asString(parsed.defaultMode, "");
const defaultProjectWorkspaceId =
typeof parsed.defaultProjectWorkspaceId === "string" ? parsed.defaultProjectWorkspaceId : undefined;
const allowIssueOverride =
typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined;
const normalizedDefaultMode = (() => {
if (
defaultMode === "shared_workspace" ||
defaultMode === "isolated_workspace" ||
defaultMode === "operator_branch" ||
defaultMode === "adapter_default"
) {
return defaultMode as ProjectExecutionWorkspaceDefaultMode;
}
if (defaultMode === "project_primary") return "shared_workspace";
if (defaultMode === "isolated") return "isolated_workspace";
return undefined;
})();
return {
enabled,
...(defaultMode === "project_primary" || defaultMode === "isolated" ? { defaultMode } : {}),
...(normalizedDefaultMode ? { defaultMode: normalizedDefaultMode } : {}),
...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}),
...(parseExecutionWorkspaceStrategy(parsed.workspaceStrategy)
? { workspaceStrategy: parseExecutionWorkspaceStrategy(parsed.workspaceStrategy) }
: {}),
...(defaultProjectWorkspaceId ? { defaultProjectWorkspaceId } : {}),
...(workspaceStrategy ? { workspaceStrategy } : {}),
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
: {}),
@@ -52,23 +68,48 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
...(parsed.pullRequestPolicy && typeof parsed.pullRequestPolicy === "object" && !Array.isArray(parsed.pullRequestPolicy)
? { pullRequestPolicy: { ...(parsed.pullRequestPolicy as Record<string, unknown>) } }
: {}),
...(parsed.runtimePolicy && typeof parsed.runtimePolicy === "object" && !Array.isArray(parsed.runtimePolicy)
? { runtimePolicy: { ...(parsed.runtimePolicy as Record<string, unknown>) } }
: {}),
...(parsed.cleanupPolicy && typeof parsed.cleanupPolicy === "object" && !Array.isArray(parsed.cleanupPolicy)
? { cleanupPolicy: { ...(parsed.cleanupPolicy as Record<string, unknown>) } }
: {}),
};
}
export function gateProjectExecutionWorkspacePolicy(
projectPolicy: ProjectExecutionWorkspacePolicy | null,
isolatedWorkspacesEnabled: boolean,
): ProjectExecutionWorkspacePolicy | null {
if (!isolatedWorkspacesEnabled) return null;
return projectPolicy;
}
export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecutionWorkspaceSettings | null {
const parsed = parseObject(raw);
if (Object.keys(parsed).length === 0) return null;
const workspaceStrategy = parseExecutionWorkspaceStrategy(parsed.workspaceStrategy);
const mode = asString(parsed.mode, "");
const normalizedMode = (() => {
if (
mode === "inherit" ||
mode === "shared_workspace" ||
mode === "isolated_workspace" ||
mode === "operator_branch" ||
mode === "reuse_existing" ||
mode === "agent_default"
) {
return mode;
}
if (mode === "project_primary") return "shared_workspace";
if (mode === "isolated") return "isolated_workspace";
return "";
})();
return {
...(mode === "inherit" || mode === "project_primary" || mode === "isolated" || mode === "agent_default"
? { mode }
: {}),
...(parseExecutionWorkspaceStrategy(parsed.workspaceStrategy)
? { workspaceStrategy: parseExecutionWorkspaceStrategy(parsed.workspaceStrategy) }
...(normalizedMode
? { mode: normalizedMode as IssueExecutionWorkspaceSettings["mode"] }
: {}),
...(workspaceStrategy ? { workspaceStrategy } : {}),
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
: {}),
@@ -80,7 +121,14 @@ export function defaultIssueExecutionWorkspaceSettingsForProject(
): IssueExecutionWorkspaceSettings | null {
if (!projectPolicy?.enabled) return null;
return {
mode: projectPolicy.defaultMode === "isolated" ? "isolated" : "project_primary",
mode:
projectPolicy.defaultMode === "isolated_workspace"
? "isolated_workspace"
: projectPolicy.defaultMode === "operator_branch"
? "operator_branch"
: projectPolicy.defaultMode === "adapter_default"
? "agent_default"
: "shared_workspace",
};
}
@@ -90,16 +138,19 @@ export function resolveExecutionWorkspaceMode(input: {
legacyUseProjectWorkspace: boolean | null;
}): ParsedExecutionWorkspaceMode {
const issueMode = input.issueSettings?.mode;
if (issueMode && issueMode !== "inherit") {
if (issueMode && issueMode !== "inherit" && issueMode !== "reuse_existing") {
return issueMode;
}
if (input.projectPolicy?.enabled) {
return input.projectPolicy.defaultMode === "isolated" ? "isolated" : "project_primary";
if (input.projectPolicy.defaultMode === "isolated_workspace") return "isolated_workspace";
if (input.projectPolicy.defaultMode === "operator_branch") return "operator_branch";
if (input.projectPolicy.defaultMode === "adapter_default") return "agent_default";
return "shared_workspace";
}
if (input.legacyUseProjectWorkspace === false) {
return "agent_default";
}
return "project_primary";
return "shared_workspace";
}
export function buildExecutionWorkspaceAdapterConfig(input: {
@@ -119,7 +170,7 @@ export function buildExecutionWorkspaceAdapterConfig(input: {
const hasWorkspaceControl = projectHasPolicy || issueHasWorkspaceOverrides || input.legacyUseProjectWorkspace === false;
if (hasWorkspaceControl) {
if (input.mode === "isolated") {
if (input.mode === "isolated_workspace") {
const strategy =
input.issueSettings?.workspaceStrategy ??
input.projectPolicy?.workspaceStrategy ??

View File

@@ -0,0 +1,99 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { executionWorkspaces } from "@paperclipai/db";
import type { ExecutionWorkspace } from "@paperclipai/shared";
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace {
return {
id: row.id,
companyId: row.companyId,
projectId: row.projectId,
projectWorkspaceId: row.projectWorkspaceId ?? null,
sourceIssueId: row.sourceIssueId ?? null,
mode: row.mode as ExecutionWorkspace["mode"],
strategyType: row.strategyType as ExecutionWorkspace["strategyType"],
name: row.name,
status: row.status as ExecutionWorkspace["status"],
cwd: row.cwd ?? null,
repoUrl: row.repoUrl ?? null,
baseRef: row.baseRef ?? null,
branchName: row.branchName ?? null,
providerType: row.providerType as ExecutionWorkspace["providerType"],
providerRef: row.providerRef ?? null,
derivedFromExecutionWorkspaceId: row.derivedFromExecutionWorkspaceId ?? null,
lastUsedAt: row.lastUsedAt,
openedAt: row.openedAt,
closedAt: row.closedAt ?? null,
cleanupEligibleAt: row.cleanupEligibleAt ?? null,
cleanupReason: row.cleanupReason ?? null,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export function executionWorkspaceService(db: Db) {
return {
list: async (companyId: string, filters?: {
projectId?: string;
projectWorkspaceId?: string;
issueId?: string;
status?: string;
reuseEligible?: boolean;
}) => {
const conditions = [eq(executionWorkspaces.companyId, companyId)];
if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId));
if (filters?.projectWorkspaceId) {
conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId));
}
if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId));
if (filters?.status) {
const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean);
if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!));
else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses));
}
if (filters?.reuseEligible) {
conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"]));
}
const rows = await db
.select()
.from(executionWorkspaces)
.where(and(...conditions))
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
return rows.map(toExecutionWorkspace);
},
getById: async (id: string) => {
const row = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, id))
.then((rows) => rows[0] ?? null);
return row ? toExecutionWorkspace(row) : null;
},
create: async (data: typeof executionWorkspaces.$inferInsert) => {
const row = await db
.insert(executionWorkspaces)
.values(data)
.returning()
.then((rows) => rows[0] ?? null);
return row ? toExecutionWorkspace(row) : null;
},
update: async (id: string, patch: Partial<typeof executionWorkspaces.$inferInsert>) => {
const row = await db
.update(executionWorkspaces)
.set({ ...patch, updatedAt: new Date() })
.where(eq(executionWorkspaces.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return row ? toExecutionWorkspace(row) : null;
},
};
}
export { toExecutionWorkspace };

View File

@@ -1,5 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import type { BillingType } from "@paperclipai/shared";
@@ -26,22 +28,28 @@ import { costService } from "./costs.js";
import { companySkillService } from "./company-skills.js";
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
import { secretService } from "./secrets.js";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js";
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
import {
buildWorkspaceReadyComment,
cleanupExecutionWorkspaceArtifacts,
ensureRuntimeServicesForRun,
persistAdapterManagedRuntimeServices,
realizeExecutionWorkspace,
releaseRuntimeServicesForRun,
sanitizeRuntimeServiceBaseEnv,
} from "./workspace-runtime.js";
import { issueService } from "./issues.js";
import { executionWorkspaceService } from "./execution-workspaces.js";
import { workspaceOperationService } from "./workspace-operations.js";
import {
buildExecutionWorkspaceAdapterConfig,
gateProjectExecutionWorkspacePolicy,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
} from "./execution-workspace-policy.js";
import { instanceSettingsService } from "./instance-settings.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
import {
hasSessionCompactionThresholds,
@@ -55,6 +63,80 @@ const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
const startLocksByAgent = new Map<string, Promise<void>>();
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000;
const execFile = promisify(execFileCallback);
const SESSIONED_LOCAL_ADAPTERS = new Set([
"claude_local",
"codex_local",
"cursor",
"gemini_local",
"opencode_local",
"pi_local",
]);
function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null {
const trimmed = repoUrl?.trim() ?? "";
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
const cleanedPath = parsed.pathname.replace(/\/+$/, "");
const repoName = cleanedPath.split("/").filter(Boolean).pop()?.replace(/\.git$/i, "") ?? "";
return repoName || null;
} catch {
return null;
}
}
async function ensureManagedProjectWorkspace(input: {
companyId: string;
projectId: string;
repoUrl: string | null;
}): Promise<{ cwd: string; warning: string | null }> {
const cwd = resolveManagedProjectWorkspaceDir({
companyId: input.companyId,
projectId: input.projectId,
repoName: deriveRepoNameFromRepoUrl(input.repoUrl),
});
await fs.mkdir(path.dirname(cwd), { recursive: true });
const stats = await fs.stat(cwd).catch(() => null);
if (!input.repoUrl) {
if (!stats) {
await fs.mkdir(cwd, { recursive: true });
}
return { cwd, warning: null };
}
const gitDirExists = await fs
.stat(path.resolve(cwd, ".git"))
.then((entry) => entry.isDirectory())
.catch(() => false);
if (gitDirExists) {
return { cwd, warning: null };
}
if (stats) {
const entries = await fs.readdir(cwd).catch(() => []);
if (entries.length > 0) {
return {
cwd,
warning: `Managed workspace path "${cwd}" already exists but is not a git checkout. Using it as-is.`,
};
}
await fs.rm(cwd, { recursive: true, force: true });
}
try {
await execFile("git", ["clone", input.repoUrl, cwd], {
env: sanitizeRuntimeServiceBaseEnv(process.env),
timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS,
});
return { cwd, warning: null };
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to prepare managed checkout for "${input.repoUrl}" at "${cwd}": ${reason}`);
}
}
const heartbeatRunListColumns = {
id: heartbeatRuns.id,
@@ -159,6 +241,20 @@ export type ResolvedWorkspaceForRun = {
warnings: string[];
};
type ProjectWorkspaceCandidate = {
id: string;
};
export function prioritizeProjectWorkspaceCandidatesForRun<T extends ProjectWorkspaceCandidate>(
rows: T[],
preferredWorkspaceId: string | null | undefined,
): T[] {
if (!preferredWorkspaceId) return rows;
const preferredIndex = rows.findIndex((row) => row.id === preferredWorkspaceId);
if (preferredIndex <= 0) return rows;
return [rows[preferredIndex]!, ...rows.slice(0, preferredIndex), ...rows.slice(preferredIndex + 1)];
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
@@ -592,10 +688,14 @@ function resolveNextSessionState(input: {
}
export function heartbeatService(db: Db) {
const instanceSettings = instanceSettingsService(db);
const runLogStore = getRunLogStore();
const secretsSvc = secretService(db);
const companySkills = companySkillService(db);
const issuesSvc = issueService(db);
const executionWorkspacesSvc = executionWorkspaceService(db);
const workspaceOperationsSvc = workspaceOperationService(db);
const activeRunExecutions = new Set<string>();
const budgetHooks = {
cancelWorkForScope: cancelBudgetScopeWork,
@@ -850,18 +950,25 @@ export function heartbeatService(db: Db) {
): Promise<ResolvedWorkspaceForRun> {
const issueId = readNonEmptyString(context.issueId);
const contextProjectId = readNonEmptyString(context.projectId);
const issueProjectId = issueId
const contextProjectWorkspaceId = readNonEmptyString(context.projectWorkspaceId);
const issueProjectRef = issueId
? await db
.select({ projectId: issues.projectId })
.select({
projectId: issues.projectId,
projectWorkspaceId: issues.projectWorkspaceId,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
.then((rows) => rows[0]?.projectId ?? null)
.then((rows) => rows[0] ?? null)
: null;
const issueProjectId = issueProjectRef?.projectId ?? null;
const preferredProjectWorkspaceId =
issueProjectRef?.projectWorkspaceId ?? contextProjectWorkspaceId ?? null;
const resolvedProjectId = issueProjectId ?? contextProjectId;
const useProjectWorkspace = opts?.useProjectWorkspace !== false;
const workspaceProjectId = useProjectWorkspace ? resolvedProjectId : null;
const projectWorkspaceRows = workspaceProjectId
const unorderedProjectWorkspaceRows = workspaceProjectId
? await db
.select()
.from(projectWorkspaces)
@@ -873,6 +980,10 @@ export function heartbeatService(db: Db) {
)
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
: [];
const projectWorkspaceRows = prioritizeProjectWorkspaceCandidatesForRun(
unorderedProjectWorkspaceRows,
preferredProjectWorkspaceId,
);
const workspaceHints = projectWorkspaceRows.map((workspace) => ({
workspaceId: workspace.id,
@@ -882,12 +993,34 @@ export function heartbeatService(db: Db) {
}));
if (projectWorkspaceRows.length > 0) {
const preferredWorkspace = preferredProjectWorkspaceId
? projectWorkspaceRows.find((workspace) => workspace.id === preferredProjectWorkspaceId) ?? null
: null;
const missingProjectCwds: string[] = [];
let hasConfiguredProjectCwd = false;
let preferredWorkspaceWarning: string | null = null;
if (preferredProjectWorkspaceId && !preferredWorkspace) {
preferredWorkspaceWarning =
`Selected project workspace "${preferredProjectWorkspaceId}" is not available on this project.`;
}
for (const workspace of projectWorkspaceRows) {
const projectCwd = readNonEmptyString(workspace.cwd);
let projectCwd = readNonEmptyString(workspace.cwd);
let managedWorkspaceWarning: string | null = null;
if (!projectCwd || projectCwd === REPO_ONLY_CWD_SENTINEL) {
continue;
try {
const managedWorkspace = await ensureManagedProjectWorkspace({
companyId: agent.companyId,
projectId: workspaceProjectId ?? resolvedProjectId ?? workspace.projectId,
repoUrl: readNonEmptyString(workspace.repoUrl),
});
projectCwd = managedWorkspace.cwd;
managedWorkspaceWarning = managedWorkspace.warning;
} catch (error) {
if (preferredWorkspace?.id === workspace.id) {
preferredWorkspaceWarning = error instanceof Error ? error.message : String(error);
}
continue;
}
}
hasConfiguredProjectCwd = true;
const projectCwdExists = await fs
@@ -903,15 +1036,24 @@ export function heartbeatService(db: Db) {
repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef,
workspaceHints,
warnings: [],
warnings: [preferredWorkspaceWarning, managedWorkspaceWarning].filter(
(value): value is string => Boolean(value),
),
};
}
if (preferredWorkspace?.id === workspace.id) {
preferredWorkspaceWarning =
`Selected project workspace path "${projectCwd}" is not available yet.`;
}
missingProjectCwds.push(projectCwd);
}
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agent.id);
await fs.mkdir(fallbackCwd, { recursive: true });
const warnings: string[] = [];
if (preferredWorkspaceWarning) {
warnings.push(preferredWorkspaceWarning);
}
if (missingProjectCwds.length > 0) {
const firstMissing = missingProjectCwds[0];
const extraMissingCount = Math.max(0, missingProjectCwds.length - 1);
@@ -937,6 +1079,24 @@ export function heartbeatService(db: Db) {
};
}
if (workspaceProjectId) {
const managedWorkspace = await ensureManagedProjectWorkspace({
companyId: agent.companyId,
projectId: workspaceProjectId,
repoUrl: null,
});
return {
cwd: managedWorkspace.cwd,
source: "project_primary" as const,
projectId: resolvedProjectId,
workspaceId: null,
repoUrl: null,
repoRef: null,
workspaceHints,
warnings: managedWorkspace.warning ? [managedWorkspace.warning] : [],
};
}
const sessionCwd = readNonEmptyString(previousSessionParams?.cwd);
if (sessionCwd) {
const sessionCwdExists = await fs
@@ -1475,10 +1635,16 @@ export function heartbeatService(db: Db) {
const taskKey = deriveTaskKey(context, null);
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
const issueId = readNonEmptyString(context.issueId);
const issueAssigneeConfig = issueId
const issueContext = issueId
? await db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
projectId: issues.projectId,
projectWorkspaceId: issues.projectWorkspaceId,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
assigneeAgentId: issues.assigneeAgentId,
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
@@ -1488,22 +1654,27 @@ export function heartbeatService(db: Db) {
.then((rows) => rows[0] ?? null)
: null;
const issueAssigneeOverrides =
issueAssigneeConfig && issueAssigneeConfig.assigneeAgentId === agent.id
issueContext && issueContext.assigneeAgentId === agent.id
? parseIssueAssigneeAdapterOverrides(
issueAssigneeConfig.assigneeAdapterOverrides,
issueContext.assigneeAdapterOverrides,
)
: null;
const issueExecutionWorkspaceSettings = parseIssueExecutionWorkspaceSettings(
issueAssigneeConfig?.executionWorkspaceSettings,
);
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
const issueExecutionWorkspaceSettings = isolatedWorkspacesEnabled
? parseIssueExecutionWorkspaceSettings(issueContext?.executionWorkspaceSettings)
: null;
const contextProjectId = readNonEmptyString(context.projectId);
const executionProjectId = issueAssigneeConfig?.projectId ?? contextProjectId;
const executionProjectId = issueContext?.projectId ?? contextProjectId;
const projectExecutionWorkspacePolicy = executionProjectId
? await db
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.from(projects)
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy))
.then((rows) =>
gateProjectExecutionWorkspacePolicy(
parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy),
isolatedWorkspacesEnabled,
))
: null;
const taskSession = taskKey
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
@@ -1545,17 +1716,24 @@ export function heartbeatService(db: Db) {
...resolvedConfig,
paperclipRuntimeSkills: runtimeSkillEntries,
};
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)
const issueRef = issueContext
? {
id: issueContext.id,
identifier: issueContext.identifier,
title: issueContext.title,
projectId: issueContext.projectId,
projectWorkspaceId: issueContext.projectWorkspaceId,
executionWorkspaceId: issueContext.executionWorkspaceId,
executionWorkspacePreference: issueContext.executionWorkspacePreference,
}
: null;
const existingExecutionWorkspace =
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({
companyId: agent.companyId,
heartbeatRunId: run.id,
executionWorkspaceId: existingExecutionWorkspace?.id ?? null,
});
const executionWorkspace = await realizeExecutionWorkspace({
base: {
baseCwd: resolvedWorkspace.cwd,
@@ -1565,14 +1743,139 @@ export function heartbeatService(db: Db) {
repoUrl: resolvedWorkspace.repoUrl,
repoRef: resolvedWorkspace.repoRef,
},
config: resolvedConfig,
config: runtimeConfig,
issue: issueRef,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
recorder: workspaceOperationRecorder,
});
const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null;
const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
const shouldReuseExisting =
issueRef?.executionWorkspacePreference === "reuse_existing" &&
existingExecutionWorkspace &&
existingExecutionWorkspace.status !== "archived";
let persistedExecutionWorkspace = null;
try {
persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, {
cwd: executionWorkspace.cwd,
repoUrl: executionWorkspace.repoUrl,
baseRef: executionWorkspace.repoRef,
branchName: executionWorkspace.branchName,
providerType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "local_fs",
providerRef: executionWorkspace.worktreePath,
status: "active",
lastUsedAt: new Date(),
metadata: {
...(existingExecutionWorkspace.metadata ?? {}),
source: executionWorkspace.source,
createdByRuntime: executionWorkspace.created,
},
})
: resolvedProjectId
? await executionWorkspacesSvc.create({
companyId: agent.companyId,
projectId: resolvedProjectId,
projectWorkspaceId: resolvedProjectWorkspaceId,
sourceIssueId: issueRef?.id ?? null,
mode:
executionWorkspaceMode === "isolated_workspace"
? "isolated_workspace"
: executionWorkspaceMode === "operator_branch"
? "operator_branch"
: executionWorkspaceMode === "agent_default"
? "adapter_managed"
: "shared_workspace",
strategyType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "project_primary",
name: executionWorkspace.branchName ?? issueRef?.identifier ?? `workspace-${agent.id.slice(0, 8)}`,
status: "active",
cwd: executionWorkspace.cwd,
repoUrl: executionWorkspace.repoUrl,
baseRef: executionWorkspace.repoRef,
branchName: executionWorkspace.branchName,
providerType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "local_fs",
providerRef: executionWorkspace.worktreePath,
lastUsedAt: new Date(),
openedAt: new Date(),
metadata: {
source: executionWorkspace.source,
createdByRuntime: executionWorkspace.created,
},
})
: null;
} catch (error) {
if (executionWorkspace.created) {
try {
await cleanupExecutionWorkspaceArtifacts({
workspace: {
id: existingExecutionWorkspace?.id ?? `transient-${run.id}`,
cwd: executionWorkspace.cwd,
providerType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "local_fs",
providerRef: executionWorkspace.worktreePath,
branchName: executionWorkspace.branchName,
repoUrl: executionWorkspace.repoUrl,
baseRef: executionWorkspace.repoRef,
projectId: resolvedProjectId,
projectWorkspaceId: resolvedProjectWorkspaceId,
sourceIssueId: issueRef?.id ?? null,
metadata: {
createdByRuntime: true,
source: executionWorkspace.source,
},
},
projectWorkspace: {
cwd: resolvedWorkspace.cwd,
cleanupCommand: null,
},
teardownCommand: projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null,
recorder: workspaceOperationRecorder,
});
} catch (cleanupError) {
logger.warn(
{
runId: run.id,
issueId,
executionWorkspaceCwd: executionWorkspace.cwd,
cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
},
"Failed to cleanup realized execution workspace after persistence failure",
);
}
}
throw error;
}
await workspaceOperationRecorder.attachExecutionWorkspaceId(persistedExecutionWorkspace?.id ?? null);
if (
existingExecutionWorkspace &&
persistedExecutionWorkspace &&
existingExecutionWorkspace.id !== persistedExecutionWorkspace.id &&
existingExecutionWorkspace.status === "active"
) {
await executionWorkspacesSvc.update(existingExecutionWorkspace.id, {
status: "idle",
cleanupReason: null,
});
}
if (issueId && persistedExecutionWorkspace && issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
await issuesSvc.update(issueId, {
executionWorkspaceId: persistedExecutionWorkspace.id,
...(resolvedProjectWorkspaceId ? { projectWorkspaceId: resolvedProjectWorkspaceId } : {}),
});
}
if (persistedExecutionWorkspace) {
context.executionWorkspaceId = persistedExecutionWorkspace.id;
await db
.update(heartbeatRuns)
.set({
contextSnapshot: context,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, run.id));
}
const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({
agentId: agent.id,
previousSessionParams,
@@ -1776,6 +2079,7 @@ export function heartbeatService(db: Db) {
},
issue: issueRef,
workspace: executionWorkspace,
executionWorkspaceId: persistedExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null,
config: resolvedConfig,
adapterEnv,
onLog,

View File

@@ -17,7 +17,11 @@ export { heartbeatService } from "./heartbeat.js";
export { dashboardService } from "./dashboard.js";
export { sidebarBadgeService } from "./sidebar-badges.js";
export { accessService } from "./access.js";
export { instanceSettingsService } from "./instance-settings.js";
export { companyPortabilityService } from "./company-portability.js";
export { executionWorkspaceService } from "./execution-workspaces.js";
export { workspaceOperationService } from "./workspace-operations.js";
export { workProductService } from "./work-products.js";
export { logActivity, type LogActivityInput } from "./activity-log.js";
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";

View File

@@ -0,0 +1,95 @@
import type { Db } from "@paperclipai/db";
import { companies, instanceSettings } from "@paperclipai/db";
import {
instanceExperimentalSettingsSchema,
type InstanceExperimentalSettings,
type InstanceSettings,
type PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
import { eq } from "drizzle-orm";
const DEFAULT_SINGLETON_KEY = "default";
function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
};
}
return {
enableIsolatedWorkspaces: false,
};
}
function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings {
return {
id: row.id,
experimental: normalizeExperimentalSettings(row.experimental),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export function instanceSettingsService(db: Db) {
async function getOrCreateRow() {
const existing = await db
.select()
.from(instanceSettings)
.where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY))
.then((rows) => rows[0] ?? null);
if (existing) return existing;
const now = new Date();
const [created] = await db
.insert(instanceSettings)
.values({
singletonKey: DEFAULT_SINGLETON_KEY,
experimental: {},
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [instanceSettings.singletonKey],
set: {
updatedAt: now,
},
})
.returning();
return created;
}
return {
get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
getExperimental: async (): Promise<InstanceExperimentalSettings> => {
const row = await getOrCreateRow();
return normalizeExperimentalSettings(row.experimental);
},
updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow();
const nextExperimental = normalizeExperimentalSettings({
...normalizeExperimentalSettings(current.experimental),
...patch,
});
const now = new Date();
const [updated] = await db
.update(instanceSettings)
.set({
experimental: { ...nextExperimental },
updatedAt: now,
})
.where(eq(instanceSettings.id, current.id))
.returning();
return toInstanceSettings(updated ?? current);
},
listCompanyIds: async (): Promise<string[]> =>
db
.select({ id: companies.id })
.from(companies)
.then((rows) => rows.map((row) => row.id)),
};
}

View File

@@ -8,6 +8,7 @@ import {
documents,
goals,
heartbeatRuns,
executionWorkspaces,
issueAttachments,
issueLabels,
issueComments,
@@ -22,8 +23,10 @@ import { extractProjectMentionIds } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import {
defaultIssueExecutionWorkspaceSettingsForProject,
gateProjectExecutionWorkspacePolicy,
parseProjectExecutionWorkspacePolicy,
} from "./execution-workspace-policy.js";
import { instanceSettingsService } from "./instance-settings.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
import { getDefaultCompanyGoal } from "./goals.js";
@@ -315,6 +318,8 @@ function withActiveRuns(
}
export function issueService(db: Db) {
const instanceSettings = instanceSettingsService(db);
async function assertAssignableAgent(companyId: string, agentId: string) {
const assignee = await db
.select({
@@ -356,6 +361,40 @@ export function issueService(db: Db) {
}
}
async function assertValidProjectWorkspace(companyId: string, projectId: string | null | undefined, projectWorkspaceId: string) {
const workspace = await db
.select({
id: projectWorkspaces.id,
companyId: projectWorkspaces.companyId,
projectId: projectWorkspaces.projectId,
})
.from(projectWorkspaces)
.where(eq(projectWorkspaces.id, projectWorkspaceId))
.then((rows) => rows[0] ?? null);
if (!workspace) throw notFound("Project workspace not found");
if (workspace.companyId !== companyId) throw unprocessable("Project workspace must belong to same company");
if (projectId && workspace.projectId !== projectId) {
throw unprocessable("Project workspace must belong to the selected project");
}
}
async function assertValidExecutionWorkspace(companyId: string, projectId: string | null | undefined, executionWorkspaceId: string) {
const workspace = await db
.select({
id: executionWorkspaces.id,
companyId: executionWorkspaces.companyId,
projectId: executionWorkspaces.projectId,
})
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId))
.then((rows) => rows[0] ?? null);
if (!workspace) throw notFound("Execution workspace not found");
if (workspace.companyId !== companyId) throw unprocessable("Execution workspace must belong to same company");
if (projectId && workspace.projectId !== projectId) {
throw unprocessable("Execution workspace must belong to the selected project");
}
}
async function assertValidLabelIds(companyId: string, labelIds: string[], dbOrTx: any = db) {
if (labelIds.length === 0) return;
const existing = await dbOrTx
@@ -641,6 +680,12 @@ export function issueService(db: Db) {
data: Omit<typeof issues.$inferInsert, "companyId"> & { labelIds?: string[] },
) => {
const { labelIds: inputLabelIds, ...issueData } = data;
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
if (!isolatedWorkspacesEnabled) {
delete issueData.executionWorkspaceId;
delete issueData.executionWorkspacePreference;
delete issueData.executionWorkspaceSettings;
}
if (data.assigneeAgentId && data.assigneeUserId) {
throw unprocessable("Issue can only have one assignee");
}
@@ -650,6 +695,12 @@ export function issueService(db: Db) {
if (data.assigneeUserId) {
await assertAssignableUser(companyId, data.assigneeUserId);
}
if (data.projectWorkspaceId) {
await assertValidProjectWorkspace(companyId, data.projectId, data.projectWorkspaceId);
}
if (data.executionWorkspaceId) {
await assertValidExecutionWorkspace(companyId, data.projectId, data.executionWorkspaceId);
}
if (data.status === "in_progress" && !data.assigneeAgentId && !data.assigneeUserId) {
throw unprocessable("in_progress issues require an assignee");
}
@@ -665,9 +716,32 @@ export function issueService(db: Db) {
.then((rows) => rows[0] ?? null);
executionWorkspaceSettings =
defaultIssueExecutionWorkspaceSettingsForProject(
parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
gateProjectExecutionWorkspacePolicy(
parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
isolatedWorkspacesEnabled,
),
) as Record<string, unknown> | null;
}
let projectWorkspaceId = issueData.projectWorkspaceId ?? null;
if (!projectWorkspaceId && issueData.projectId) {
const project = await tx
.select({
executionWorkspacePolicy: projects.executionWorkspacePolicy,
})
.from(projects)
.where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
.then((rows) => rows[0] ?? null);
const projectPolicy = parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy);
projectWorkspaceId = projectPolicy?.defaultProjectWorkspaceId ?? null;
if (!projectWorkspaceId) {
projectWorkspaceId = await tx
.select({ id: projectWorkspaces.id })
.from(projectWorkspaces)
.where(and(eq(projectWorkspaces.projectId, issueData.projectId), eq(projectWorkspaces.companyId, companyId)))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
.then((rows) => rows[0]?.id ?? null);
}
}
const [company] = await tx
.update(companies)
.set({ issueCounter: sql`${companies.issueCounter} + 1` })
@@ -684,6 +758,7 @@ export function issueService(db: Db) {
goalId: issueData.goalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
}),
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
companyId,
issueNumber,
@@ -717,6 +792,12 @@ export function issueService(db: Db) {
if (!existing) return null;
const { labelIds: nextLabelIds, ...issueData } = data;
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
if (!isolatedWorkspacesEnabled) {
delete issueData.executionWorkspaceId;
delete issueData.executionWorkspacePreference;
delete issueData.executionWorkspaceSettings;
}
if (issueData.status) {
assertTransition(existing.status, issueData.status);
@@ -744,6 +825,17 @@ export function issueService(db: Db) {
if (issueData.assigneeUserId) {
await assertAssignableUser(existing.companyId, issueData.assigneeUserId);
}
const nextProjectId = issueData.projectId !== undefined ? issueData.projectId : existing.projectId;
const nextProjectWorkspaceId =
issueData.projectWorkspaceId !== undefined ? issueData.projectWorkspaceId : existing.projectWorkspaceId;
const nextExecutionWorkspaceId =
issueData.executionWorkspaceId !== undefined ? issueData.executionWorkspaceId : existing.executionWorkspaceId;
if (nextProjectWorkspaceId) {
await assertValidProjectWorkspace(existing.companyId, nextProjectId, nextProjectWorkspaceId);
}
if (nextExecutionWorkspaceId) {
await assertValidExecutionWorkspace(existing.companyId, nextProjectId, nextExecutionWorkspaceId);
}
applyStatusSideEffects(issueData.status, patch);
if (issueData.status && issueData.status !== "done") {

View File

@@ -718,17 +718,16 @@ export function buildHostServices(
const project = await projects.getById(params.projectId);
if (!inCompany(project, companyId)) return null;
const row = project.primaryWorkspace;
if (!row) return null;
const path = sanitizeWorkspacePath(row.cwd);
const name = sanitizeWorkspaceName(row.name, path);
const path = sanitizeWorkspacePath(project.codebase.effectiveLocalFolder);
const name = sanitizeWorkspaceName(row?.name ?? project.name, path);
return {
id: row.id,
projectId: row.projectId,
id: row?.id ?? `${project.id}:managed`,
projectId: project.id,
name,
path,
isPrimary: row.isPrimary,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
isPrimary: true,
createdAt: (row?.createdAt ?? project.createdAt).toISOString(),
updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(),
};
},
@@ -742,17 +741,16 @@ export function buildHostServices(
const project = await projects.getById(projectId);
if (!inCompany(project, companyId)) return null;
const row = project.primaryWorkspace;
if (!row) return null;
const path = sanitizeWorkspacePath(row.cwd);
const name = sanitizeWorkspaceName(row.name, path);
const path = sanitizeWorkspacePath(project.codebase.effectiveLocalFolder);
const name = sanitizeWorkspaceName(row?.name ?? project.name, path);
return {
id: row.id,
projectId: row.projectId,
id: row?.id ?? `${project.id}:managed`,
projectId: project.id,
name,
path,
isPrimary: row.isPrimary,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
isPrimary: true,
createdAt: (row?.createdAt ?? project.createdAt).toISOString(),
updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(),
};
},
},

View File

@@ -6,6 +6,7 @@ import {
deriveProjectUrlKey,
isUuidLike,
normalizeProjectUrlKey,
type ProjectCodebase,
type ProjectExecutionWorkspacePolicy,
type ProjectGoalRef,
type ProjectWorkspace,
@@ -13,6 +14,7 @@ import {
} from "@paperclipai/shared";
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
type ProjectRow = typeof projects.$inferSelect;
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
@@ -20,9 +22,17 @@ type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
type CreateWorkspaceInput = {
name?: string | null;
sourceType?: string | null;
cwd?: string | null;
repoUrl?: string | null;
repoRef?: string | null;
defaultRef?: string | null;
visibility?: string | null;
setupCommand?: string | null;
cleanupCommand?: string | null;
remoteProvider?: string | null;
remoteWorkspaceRef?: string | null;
sharedWorkspaceKey?: string | null;
metadata?: Record<string, unknown> | null;
isPrimary?: boolean;
};
@@ -33,6 +43,7 @@ interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy">
goalIds: string[];
goals: ProjectGoalRef[];
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
codebase: ProjectCodebase;
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
}
@@ -91,6 +102,7 @@ function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeServ
companyId: row.companyId,
projectId: row.projectId ?? null,
projectWorkspaceId: row.projectWorkspaceId ?? null,
executionWorkspaceId: row.executionWorkspaceId ?? null,
issueId: row.issueId ?? null,
scopeType: row.scopeType as WorkspaceRuntimeService["scopeType"],
scopeId: row.scopeId ?? null,
@@ -125,9 +137,17 @@ function toWorkspace(
companyId: row.companyId,
projectId: row.projectId,
name: row.name,
cwd: row.cwd,
sourceType: row.sourceType as ProjectWorkspace["sourceType"],
cwd: normalizeWorkspaceCwd(row.cwd),
repoUrl: row.repoUrl ?? null,
repoRef: row.repoRef ?? null,
defaultRef: row.defaultRef ?? row.repoRef ?? null,
visibility: row.visibility as ProjectWorkspace["visibility"],
setupCommand: row.setupCommand ?? null,
cleanupCommand: row.cleanupCommand ?? null,
remoteProvider: row.remoteProvider ?? null,
remoteWorkspaceRef: row.remoteWorkspaceRef ?? null,
sharedWorkspaceKey: row.sharedWorkspaceKey ?? null,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
isPrimary: row.isPrimary,
runtimeServices,
@@ -136,6 +156,48 @@ function toWorkspace(
};
}
function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null {
const raw = readNonEmptyString(repoUrl);
if (!raw) return null;
try {
const parsed = new URL(raw);
const cleanedPath = parsed.pathname.replace(/\/+$/, "");
const repoName = cleanedPath.split("/").filter(Boolean).pop()?.replace(/\.git$/i, "") ?? "";
return repoName || null;
} catch {
return null;
}
}
function deriveProjectCodebase(input: {
companyId: string;
projectId: string;
primaryWorkspace: ProjectWorkspace | null;
fallbackWorkspaces: ProjectWorkspace[];
}): ProjectCodebase {
const primaryWorkspace = input.primaryWorkspace ?? input.fallbackWorkspaces[0] ?? null;
const repoUrl = primaryWorkspace?.repoUrl ?? null;
const repoName = deriveRepoNameFromRepoUrl(repoUrl);
const localFolder = primaryWorkspace?.cwd ?? null;
const managedFolder = resolveManagedProjectWorkspaceDir({
companyId: input.companyId,
projectId: input.projectId,
repoName,
});
return {
workspaceId: primaryWorkspace?.id ?? null,
repoUrl,
repoRef: primaryWorkspace?.repoRef ?? null,
defaultRef: primaryWorkspace?.defaultRef ?? null,
repoName,
localFolder,
managedFolder,
effectiveLocalFolder: localFolder ?? managedFolder,
origin: localFolder ? "local_folder" : "managed_checkout",
};
}
function pickPrimaryWorkspace(
rows: ProjectWorkspaceRow[],
runtimeServicesByWorkspaceId?: Map<string, WorkspaceRuntimeService[]>,
@@ -186,10 +248,17 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
sharedRuntimeServicesByWorkspaceId.get(workspace.id) ?? [],
),
);
const primaryWorkspace = pickPrimaryWorkspace(projectWorkspaceRows, sharedRuntimeServicesByWorkspaceId);
return {
...row,
codebase: deriveProjectCodebase({
companyId: row.companyId,
projectId: row.id,
primaryWorkspace,
fallbackWorkspaces: workspaces,
}),
workspaces,
primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows, sharedRuntimeServicesByWorkspaceId),
primaryWorkspace,
};
});
}
@@ -491,7 +560,13 @@ export function projectService(db: Db) {
const cwd = normalizeWorkspaceCwd(data.cwd);
const repoUrl = readNonEmptyString(data.repoUrl);
if (!cwd && !repoUrl) return null;
const sourceType = readNonEmptyString(data.sourceType) ?? (repoUrl ? "git_repo" : cwd ? "local_path" : "remote_managed");
const remoteWorkspaceRef = readNonEmptyString(data.remoteWorkspaceRef);
if (sourceType === "remote_managed") {
if (!remoteWorkspaceRef && !repoUrl) return null;
} else if (!cwd && !repoUrl) {
return null;
}
const name = deriveWorkspaceName({
name: data.name,
cwd,
@@ -525,9 +600,17 @@ export function projectService(db: Db) {
companyId: project.companyId,
projectId,
name,
sourceType,
cwd: cwd ?? null,
repoUrl: repoUrl ?? null,
repoRef: readNonEmptyString(data.repoRef),
defaultRef: readNonEmptyString(data.defaultRef) ?? readNonEmptyString(data.repoRef),
visibility: readNonEmptyString(data.visibility) ?? "default",
setupCommand: readNonEmptyString(data.setupCommand),
cleanupCommand: readNonEmptyString(data.cleanupCommand),
remoteProvider: readNonEmptyString(data.remoteProvider),
remoteWorkspaceRef,
sharedWorkspaceKey: readNonEmptyString(data.sharedWorkspaceKey),
metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
isPrimary: shouldBePrimary,
})
@@ -564,7 +647,19 @@ export function projectService(db: Db) {
data.repoUrl !== undefined
? readNonEmptyString(data.repoUrl)
: readNonEmptyString(existing.repoUrl);
if (!nextCwd && !nextRepoUrl) return null;
const nextSourceType =
data.sourceType !== undefined
? readNonEmptyString(data.sourceType)
: readNonEmptyString(existing.sourceType);
const nextRemoteWorkspaceRef =
data.remoteWorkspaceRef !== undefined
? readNonEmptyString(data.remoteWorkspaceRef)
: readNonEmptyString(existing.remoteWorkspaceRef);
if (nextSourceType === "remote_managed") {
if (!nextRemoteWorkspaceRef && !nextRepoUrl) return null;
} else if (!nextCwd && !nextRepoUrl) {
return null;
}
const patch: Partial<typeof projectWorkspaces.$inferInsert> = {
updatedAt: new Date(),
@@ -576,6 +671,16 @@ export function projectService(db: Db) {
if (data.cwd !== undefined) patch.cwd = nextCwd ?? null;
if (data.repoUrl !== undefined) patch.repoUrl = nextRepoUrl ?? null;
if (data.repoRef !== undefined) patch.repoRef = readNonEmptyString(data.repoRef);
if (data.sourceType !== undefined && nextSourceType) patch.sourceType = nextSourceType;
if (data.defaultRef !== undefined) patch.defaultRef = readNonEmptyString(data.defaultRef);
if (data.visibility !== undefined && readNonEmptyString(data.visibility)) {
patch.visibility = readNonEmptyString(data.visibility)!;
}
if (data.setupCommand !== undefined) patch.setupCommand = readNonEmptyString(data.setupCommand);
if (data.cleanupCommand !== undefined) patch.cleanupCommand = readNonEmptyString(data.cleanupCommand);
if (data.remoteProvider !== undefined) patch.remoteProvider = readNonEmptyString(data.remoteProvider);
if (data.remoteWorkspaceRef !== undefined) patch.remoteWorkspaceRef = nextRemoteWorkspaceRef;
if (data.sharedWorkspaceKey !== undefined) patch.sharedWorkspaceKey = readNonEmptyString(data.sharedWorkspaceKey);
if (data.metadata !== undefined) patch.metadata = data.metadata;
const updated = await db.transaction(async (tx) => {

View File

@@ -0,0 +1,123 @@
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { issueWorkProducts } from "@paperclipai/db";
import type { IssueWorkProduct } from "@paperclipai/shared";
type IssueWorkProductRow = typeof issueWorkProducts.$inferSelect;
function toIssueWorkProduct(row: IssueWorkProductRow): IssueWorkProduct {
return {
id: row.id,
companyId: row.companyId,
projectId: row.projectId ?? null,
issueId: row.issueId,
executionWorkspaceId: row.executionWorkspaceId ?? null,
runtimeServiceId: row.runtimeServiceId ?? null,
type: row.type as IssueWorkProduct["type"],
provider: row.provider,
externalId: row.externalId ?? null,
title: row.title,
url: row.url ?? null,
status: row.status,
reviewState: row.reviewState as IssueWorkProduct["reviewState"],
isPrimary: row.isPrimary,
healthStatus: row.healthStatus as IssueWorkProduct["healthStatus"],
summary: row.summary ?? null,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
createdByRunId: row.createdByRunId ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export function workProductService(db: Db) {
return {
listForIssue: async (issueId: string) => {
const rows = await db
.select()
.from(issueWorkProducts)
.where(eq(issueWorkProducts.issueId, issueId))
.orderBy(desc(issueWorkProducts.isPrimary), desc(issueWorkProducts.updatedAt));
return rows.map(toIssueWorkProduct);
},
getById: async (id: string) => {
const row = await db
.select()
.from(issueWorkProducts)
.where(eq(issueWorkProducts.id, id))
.then((rows) => rows[0] ?? null);
return row ? toIssueWorkProduct(row) : null;
},
createForIssue: async (issueId: string, companyId: string, data: Omit<typeof issueWorkProducts.$inferInsert, "issueId" | "companyId">) => {
const row = await db.transaction(async (tx) => {
if (data.isPrimary) {
await tx
.update(issueWorkProducts)
.set({ isPrimary: false, updatedAt: new Date() })
.where(
and(
eq(issueWorkProducts.companyId, companyId),
eq(issueWorkProducts.issueId, issueId),
eq(issueWorkProducts.type, data.type),
),
);
}
return await tx
.insert(issueWorkProducts)
.values({
...data,
companyId,
issueId,
})
.returning()
.then((rows) => rows[0] ?? null);
});
return row ? toIssueWorkProduct(row) : null;
},
update: async (id: string, patch: Partial<typeof issueWorkProducts.$inferInsert>) => {
const row = await db.transaction(async (tx) => {
const existing = await tx
.select()
.from(issueWorkProducts)
.where(eq(issueWorkProducts.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
if (patch.isPrimary === true) {
await tx
.update(issueWorkProducts)
.set({ isPrimary: false, updatedAt: new Date() })
.where(
and(
eq(issueWorkProducts.companyId, existing.companyId),
eq(issueWorkProducts.issueId, existing.issueId),
eq(issueWorkProducts.type, existing.type),
),
);
}
return await tx
.update(issueWorkProducts)
.set({ ...patch, updatedAt: new Date() })
.where(eq(issueWorkProducts.id, id))
.returning()
.then((rows) => rows[0] ?? null);
});
return row ? toIssueWorkProduct(row) : null;
},
remove: async (id: string) => {
const row = await db
.delete(issueWorkProducts)
.where(eq(issueWorkProducts.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return row ? toIssueWorkProduct(row) : null;
},
};
}
export { toIssueWorkProduct };

View File

@@ -0,0 +1,156 @@
import { createReadStream, promises as fs } from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { notFound } from "../errors.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
export type WorkspaceOperationLogStoreType = "local_file";
export interface WorkspaceOperationLogHandle {
store: WorkspaceOperationLogStoreType;
logRef: string;
}
export interface WorkspaceOperationLogReadOptions {
offset?: number;
limitBytes?: number;
}
export interface WorkspaceOperationLogReadResult {
content: string;
nextOffset?: number;
}
export interface WorkspaceOperationLogFinalizeSummary {
bytes: number;
sha256?: string;
compressed: boolean;
}
export interface WorkspaceOperationLogStore {
begin(input: { companyId: string; operationId: string }): Promise<WorkspaceOperationLogHandle>;
append(
handle: WorkspaceOperationLogHandle,
event: { stream: "stdout" | "stderr" | "system"; chunk: string; ts: string },
): Promise<void>;
finalize(handle: WorkspaceOperationLogHandle): Promise<WorkspaceOperationLogFinalizeSummary>;
read(handle: WorkspaceOperationLogHandle, opts?: WorkspaceOperationLogReadOptions): Promise<WorkspaceOperationLogReadResult>;
}
function safeSegments(...segments: string[]) {
return segments.map((segment) => segment.replace(/[^a-zA-Z0-9._-]/g, "_"));
}
function resolveWithin(basePath: string, relativePath: string) {
const resolved = path.resolve(basePath, relativePath);
const base = path.resolve(basePath) + path.sep;
if (!resolved.startsWith(base) && resolved !== path.resolve(basePath)) {
throw new Error("Invalid log path");
}
return resolved;
}
function createLocalFileWorkspaceOperationLogStore(basePath: string): WorkspaceOperationLogStore {
async function ensureDir(relativeDir: string) {
const dir = resolveWithin(basePath, relativeDir);
await fs.mkdir(dir, { recursive: true });
}
async function readFileRange(filePath: string, offset: number, limitBytes: number): Promise<WorkspaceOperationLogReadResult> {
const stat = await fs.stat(filePath).catch(() => null);
if (!stat) throw notFound("Workspace operation log not found");
const start = Math.max(0, Math.min(offset, stat.size));
const end = Math.max(start, Math.min(start + limitBytes - 1, stat.size - 1));
if (start > end) {
return { content: "", nextOffset: start };
}
const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => {
const stream = createReadStream(filePath, { start, end });
stream.on("data", (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on("error", reject);
stream.on("end", () => resolve());
});
const content = Buffer.concat(chunks).toString("utf8");
const nextOffset = end + 1 < stat.size ? end + 1 : undefined;
return { content, nextOffset };
}
async function sha256File(filePath: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
const hash = createHash("sha256");
const stream = createReadStream(filePath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex")));
});
}
return {
async begin(input) {
const [companyId] = safeSegments(input.companyId);
const operationId = safeSegments(input.operationId)[0]!;
const relDir = companyId;
const relPath = path.join(relDir, `${operationId}.ndjson`);
await ensureDir(relDir);
const absPath = resolveWithin(basePath, relPath);
await fs.writeFile(absPath, "", "utf8");
return { store: "local_file", logRef: relPath };
},
async append(handle, event) {
if (handle.store !== "local_file") return;
const absPath = resolveWithin(basePath, handle.logRef);
const line = JSON.stringify({
ts: event.ts,
stream: event.stream,
chunk: event.chunk,
});
await fs.appendFile(absPath, `${line}\n`, "utf8");
},
async finalize(handle) {
if (handle.store !== "local_file") {
return { bytes: 0, compressed: false };
}
const absPath = resolveWithin(basePath, handle.logRef);
const stat = await fs.stat(absPath).catch(() => null);
if (!stat) throw notFound("Workspace operation log not found");
const hash = await sha256File(absPath);
return {
bytes: stat.size,
sha256: hash,
compressed: false,
};
},
async read(handle, opts) {
if (handle.store !== "local_file") {
throw notFound("Workspace operation log not found");
}
const absPath = resolveWithin(basePath, handle.logRef);
const offset = opts?.offset ?? 0;
const limitBytes = opts?.limitBytes ?? 256_000;
return readFileRange(absPath, offset, limitBytes);
},
};
}
let cachedStore: WorkspaceOperationLogStore | null = null;
export function getWorkspaceOperationLogStore() {
if (cachedStore) return cachedStore;
const basePath = process.env.WORKSPACE_OPERATION_LOG_BASE_PATH
?? path.resolve(resolvePaperclipInstanceRoot(), "data", "workspace-operation-logs");
cachedStore = createLocalFileWorkspaceOperationLogStore(basePath);
return cachedStore;
}

View File

@@ -0,0 +1,250 @@
import { randomUUID } from "node:crypto";
import type { Db } from "@paperclipai/db";
import { workspaceOperations } from "@paperclipai/db";
import type { WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationStatus } from "@paperclipai/shared";
import { asc, desc, eq, inArray, isNull, or, and } from "drizzle-orm";
import { notFound } from "../errors.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
import { getWorkspaceOperationLogStore } from "./workspace-operation-log-store.js";
type WorkspaceOperationRow = typeof workspaceOperations.$inferSelect;
function toWorkspaceOperation(row: WorkspaceOperationRow): WorkspaceOperation {
return {
id: row.id,
companyId: row.companyId,
executionWorkspaceId: row.executionWorkspaceId ?? null,
heartbeatRunId: row.heartbeatRunId ?? null,
phase: row.phase as WorkspaceOperationPhase,
command: row.command ?? null,
cwd: row.cwd ?? null,
status: row.status as WorkspaceOperationStatus,
exitCode: row.exitCode ?? null,
logStore: row.logStore ?? null,
logRef: row.logRef ?? null,
logBytes: row.logBytes ?? null,
logSha256: row.logSha256 ?? null,
logCompressed: row.logCompressed,
stdoutExcerpt: row.stdoutExcerpt ?? null,
stderrExcerpt: row.stderrExcerpt ?? null,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
startedAt: row.startedAt,
finishedAt: row.finishedAt ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function appendExcerpt(current: string, chunk: string) {
return `${current}${chunk}`.slice(-4096);
}
function combineMetadata(
base: Record<string, unknown> | null | undefined,
patch: Record<string, unknown> | null | undefined,
) {
if (!base && !patch) return null;
return {
...(base ?? {}),
...(patch ?? {}),
};
}
export interface WorkspaceOperationRecorder {
attachExecutionWorkspaceId(executionWorkspaceId: string | null): Promise<void>;
recordOperation(input: {
phase: WorkspaceOperationPhase;
command?: string | null;
cwd?: string | null;
metadata?: Record<string, unknown> | null;
run: () => Promise<{
status?: WorkspaceOperationStatus;
exitCode?: number | null;
stdout?: string | null;
stderr?: string | null;
system?: string | null;
metadata?: Record<string, unknown> | null;
}>;
}): Promise<WorkspaceOperation>;
}
export function workspaceOperationService(db: Db) {
const logStore = getWorkspaceOperationLogStore();
async function getById(id: string) {
const row = await db
.select()
.from(workspaceOperations)
.where(eq(workspaceOperations.id, id))
.then((rows) => rows[0] ?? null);
return row ? toWorkspaceOperation(row) : null;
}
return {
getById,
createRecorder(input: {
companyId: string;
heartbeatRunId?: string | null;
executionWorkspaceId?: string | null;
}): WorkspaceOperationRecorder {
let executionWorkspaceId = input.executionWorkspaceId ?? null;
const createdIds: string[] = [];
return {
async attachExecutionWorkspaceId(nextExecutionWorkspaceId) {
executionWorkspaceId = nextExecutionWorkspaceId ?? null;
if (!executionWorkspaceId || createdIds.length === 0) return;
await db
.update(workspaceOperations)
.set({
executionWorkspaceId,
updatedAt: new Date(),
})
.where(inArray(workspaceOperations.id, createdIds));
},
async recordOperation(recordInput) {
const startedAt = new Date();
const id = randomUUID();
const handle = await logStore.begin({
companyId: input.companyId,
operationId: id,
});
let stdoutExcerpt = "";
let stderrExcerpt = "";
const append = async (stream: "stdout" | "stderr" | "system", chunk: string | null | undefined) => {
if (!chunk) return;
const sanitizedChunk = redactCurrentUserText(chunk);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
await logStore.append(handle, {
stream,
chunk: sanitizedChunk,
ts: new Date().toISOString(),
});
};
await db.insert(workspaceOperations).values({
id,
companyId: input.companyId,
executionWorkspaceId,
heartbeatRunId: input.heartbeatRunId ?? null,
phase: recordInput.phase,
command: recordInput.command ?? null,
cwd: recordInput.cwd ?? null,
status: "running",
logStore: handle.store,
logRef: handle.logRef,
metadata: redactCurrentUserValue(recordInput.metadata ?? null) as Record<string, unknown> | null,
startedAt,
});
createdIds.push(id);
try {
const result = await recordInput.run();
await append("system", result.system ?? null);
await append("stdout", result.stdout ?? null);
await append("stderr", result.stderr ?? null);
const finalized = await logStore.finalize(handle);
const finishedAt = new Date();
const row = await db
.update(workspaceOperations)
.set({
executionWorkspaceId,
status: result.status ?? "succeeded",
exitCode: result.exitCode ?? null,
stdoutExcerpt: stdoutExcerpt || null,
stderrExcerpt: stderrExcerpt || null,
logBytes: finalized.bytes,
logSha256: finalized.sha256,
logCompressed: finalized.compressed,
metadata: redactCurrentUserValue(
combineMetadata(recordInput.metadata, result.metadata),
) as Record<string, unknown> | null,
finishedAt,
updatedAt: finishedAt,
})
.where(eq(workspaceOperations.id, id))
.returning()
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Workspace operation not found");
return toWorkspaceOperation(row);
} catch (error) {
await append("stderr", error instanceof Error ? error.message : String(error));
const finalized = await logStore.finalize(handle).catch(() => null);
const finishedAt = new Date();
await db
.update(workspaceOperations)
.set({
executionWorkspaceId,
status: "failed",
stdoutExcerpt: stdoutExcerpt || null,
stderrExcerpt: stderrExcerpt || null,
logBytes: finalized?.bytes ?? null,
logSha256: finalized?.sha256 ?? null,
logCompressed: finalized?.compressed ?? false,
finishedAt,
updatedAt: finishedAt,
})
.where(eq(workspaceOperations.id, id));
throw error;
}
},
};
},
listForRun: async (runId: string, executionWorkspaceId?: string | null) => {
const conditions = [eq(workspaceOperations.heartbeatRunId, runId)];
if (executionWorkspaceId) {
const cleanupCondition = and(
eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId)!,
isNull(workspaceOperations.heartbeatRunId),
)!;
if (cleanupCondition) conditions.push(cleanupCondition);
}
const rows = await db
.select()
.from(workspaceOperations)
.where(conditions.length === 1 ? conditions[0]! : or(...conditions)!)
.orderBy(asc(workspaceOperations.startedAt), asc(workspaceOperations.createdAt), asc(workspaceOperations.id));
return rows.map(toWorkspaceOperation);
},
listForExecutionWorkspace: async (executionWorkspaceId: string) => {
const rows = await db
.select()
.from(workspaceOperations)
.where(eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId))
.orderBy(desc(workspaceOperations.startedAt), desc(workspaceOperations.createdAt));
return rows.map(toWorkspaceOperation);
},
readLog: async (operationId: string, opts?: { offset?: number; limitBytes?: number }) => {
const operation = await getById(operationId);
if (!operation) throw notFound("Workspace operation not found");
if (!operation.logStore || !operation.logRef) throw notFound("Workspace operation log not found");
const result = await logStore.read(
{
store: operation.logStore as "local_file",
logRef: operation.logRef,
},
opts,
);
return {
operationId,
store: operation.logStore,
logRef: operation.logRef,
...result,
content: redactCurrentUserText(result.content),
};
},
};
}
export { toWorkspaceOperation };

View File

@@ -10,6 +10,7 @@ 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";
import type { WorkspaceOperationRecorder } from "./workspace-operations.js";
export interface ExecutionWorkspaceInput {
baseCwd: string;
@@ -46,6 +47,7 @@ export interface RuntimeServiceRef {
companyId: string;
projectId: string | null;
projectWorkspaceId: string | null;
executionWorkspaceId: string | null;
issueId: string | null;
serviceName: string;
status: "starting" | "running" | "stopped" | "failed";
@@ -92,6 +94,17 @@ function stableStringify(value: unknown): string {
return JSON.stringify(value);
}
export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...baseEnv };
for (const key of Object.keys(env)) {
if (key.startsWith("PAPERCLIP_")) {
delete env[key];
}
}
delete env.DATABASE_URL;
return env;
}
function stableRuntimeServiceId(input: {
adapterType: string;
runId: string;
@@ -126,6 +139,7 @@ function toRuntimeServiceRef(record: RuntimeServiceRecord, overrides?: Partial<R
companyId: record.companyId,
projectId: record.projectId,
projectWorkspaceId: record.projectWorkspaceId,
executionWorkspaceId: record.executionWorkspaceId,
issueId: record.issueId,
serviceName: record.serviceName,
status: record.status,
@@ -208,12 +222,23 @@ function resolveConfiguredPath(value: string, baseDir: string): string {
return path.resolve(baseDir, value);
}
async function runGit(args: string[], cwd: string): Promise<string> {
function formatCommandForDisplay(command: string, args: string[]) {
return [command, ...args]
.map((part) => (/^[A-Za-z0-9_./:-]+$/.test(part) ? part : JSON.stringify(part)))
.join(" ");
}
async function executeProcess(input: {
command: string;
args: string[];
cwd: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ stdout: string; stderr: string; code: number | null }> {
const proc = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => {
const child = spawn("git", args, {
cwd,
const child = spawn(input.command, input.args, {
cwd: input.cwd,
stdio: ["ignore", "pipe", "pipe"],
env: process.env,
env: input.env ?? process.env,
});
let stdout = "";
let stderr = "";
@@ -226,16 +251,45 @@ async function runGit(args: string[], cwd: string): Promise<string> {
child.on("error", reject);
child.on("close", (code) => resolve({ stdout, stderr, code }));
});
return proc;
}
async function runGit(args: string[], cwd: string): Promise<string> {
const proc = await executeProcess({
command: "git",
args,
cwd,
});
if (proc.code !== 0) {
throw new Error(proc.stderr.trim() || proc.stdout.trim() || `git ${args.join(" ")} failed`);
}
return proc.stdout.trim();
}
function gitErrorIncludes(error: unknown, needle: string) {
const message = error instanceof Error ? error.message : String(error);
return message.toLowerCase().includes(needle.toLowerCase());
}
async function directoryExists(value: string) {
return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false);
}
function terminateChildProcess(child: ChildProcess) {
if (!child.pid) return;
if (process.platform !== "win32") {
try {
process.kill(-child.pid, "SIGTERM");
return;
} catch {
// Fall through to the direct child kill.
}
}
if (!child.killed) {
child.kill("SIGTERM");
}
}
function buildWorkspaceCommandEnv(input: {
base: ExecutionWorkspaceInput;
repoRoot: string;
@@ -274,22 +328,11 @@ async function runWorkspaceCommand(input: {
label: string;
}) {
const shell = process.env.SHELL?.trim() || "/bin/sh";
const proc = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => {
const child = spawn(shell, ["-c", input.command], {
cwd: input.cwd,
env: input.env,
stdio: ["ignore", "pipe", "pipe"],
});
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 }));
const proc = await executeProcess({
command: shell,
args: ["-c", input.command],
cwd: input.cwd,
env: input.env,
});
if (proc.code === 0) return;
@@ -301,6 +344,115 @@ async function runWorkspaceCommand(input: {
);
}
async function recordGitOperation(
recorder: WorkspaceOperationRecorder | null | undefined,
input: {
phase: "worktree_prepare" | "worktree_cleanup";
args: string[];
cwd: string;
metadata?: Record<string, unknown> | null;
successMessage?: string | null;
failureLabel?: string | null;
},
): Promise<string> {
if (!recorder) {
return runGit(input.args, input.cwd);
}
let stdout = "";
let stderr = "";
let code: number | null = null;
await recorder.recordOperation({
phase: input.phase,
command: formatCommandForDisplay("git", input.args),
cwd: input.cwd,
metadata: input.metadata ?? null,
run: async () => {
const result = await executeProcess({
command: "git",
args: input.args,
cwd: input.cwd,
});
stdout = result.stdout;
stderr = result.stderr;
code = result.code;
return {
status: result.code === 0 ? "succeeded" : "failed",
exitCode: result.code,
stdout: result.stdout,
stderr: result.stderr,
system: result.code === 0 ? input.successMessage ?? null : null,
};
},
});
if (code !== 0) {
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
throw new Error(
details.length > 0
? `${input.failureLabel ?? `git ${input.args.join(" ")}`} failed: ${details}`
: `${input.failureLabel ?? `git ${input.args.join(" ")}`} failed with exit code ${code ?? -1}`,
);
}
return stdout.trim();
}
async function recordWorkspaceCommandOperation(
recorder: WorkspaceOperationRecorder | null | undefined,
input: {
phase: "workspace_provision" | "workspace_teardown";
command: string;
cwd: string;
env: NodeJS.ProcessEnv;
label: string;
metadata?: Record<string, unknown> | null;
successMessage?: string | null;
},
) {
if (!recorder) {
await runWorkspaceCommand(input);
return;
}
let stdout = "";
let stderr = "";
let code: number | null = null;
await recorder.recordOperation({
phase: input.phase,
command: input.command,
cwd: input.cwd,
metadata: input.metadata ?? null,
run: async () => {
const shell = process.env.SHELL?.trim() || "/bin/sh";
const result = await executeProcess({
command: shell,
args: ["-c", input.command],
cwd: input.cwd,
env: input.env,
});
stdout = result.stdout;
stderr = result.stderr;
code = result.code;
return {
status: result.code === 0 ? "succeeded" : "failed",
exitCode: result.code,
stdout: result.stdout,
stderr: result.stderr,
system: result.code === 0 ? input.successMessage ?? null : null,
};
},
});
if (code === 0) return;
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
throw new Error(
details.length > 0
? `${input.label} failed: ${details}`
: `${input.label} failed with exit code ${code ?? -1}`,
);
}
async function provisionExecutionWorktree(input: {
strategy: Record<string, unknown>;
base: ExecutionWorkspaceInput;
@@ -310,11 +462,13 @@ async function provisionExecutionWorktree(input: {
issue: ExecutionWorkspaceIssueRef | null;
agent: ExecutionWorkspaceAgentRef;
created: boolean;
recorder?: WorkspaceOperationRecorder | null;
}) {
const provisionCommand = asString(input.strategy.provisionCommand, "").trim();
if (!provisionCommand) return;
await runWorkspaceCommand({
await recordWorkspaceCommandOperation(input.recorder, {
phase: "workspace_provision",
command: provisionCommand,
cwd: input.worktreePath,
env: buildWorkspaceCommandEnv({
@@ -327,14 +481,71 @@ async function provisionExecutionWorktree(input: {
created: input.created,
}),
label: `Execution workspace provision command "${provisionCommand}"`,
metadata: {
repoRoot: input.repoRoot,
worktreePath: input.worktreePath,
branchName: input.branchName,
created: input.created,
},
successMessage: `Provisioned workspace at ${input.worktreePath}\n`,
});
}
function buildExecutionWorkspaceCleanupEnv(input: {
workspace: {
cwd: string | null;
providerRef: string | null;
branchName: string | null;
repoUrl: string | null;
baseRef: string | null;
projectId: string | null;
projectWorkspaceId: string | null;
sourceIssueId: string | null;
};
projectWorkspaceCwd?: string | null;
}) {
const env: NodeJS.ProcessEnv = sanitizeRuntimeServiceBaseEnv(process.env);
env.PAPERCLIP_WORKSPACE_CWD = input.workspace.cwd ?? "";
env.PAPERCLIP_WORKSPACE_PATH = input.workspace.cwd ?? "";
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH =
input.workspace.providerRef ?? input.workspace.cwd ?? "";
env.PAPERCLIP_WORKSPACE_BRANCH = input.workspace.branchName ?? "";
env.PAPERCLIP_WORKSPACE_BASE_CWD = input.projectWorkspaceCwd ?? "";
env.PAPERCLIP_WORKSPACE_REPO_ROOT = input.projectWorkspaceCwd ?? "";
env.PAPERCLIP_WORKSPACE_REPO_URL = input.workspace.repoUrl ?? "";
env.PAPERCLIP_WORKSPACE_REPO_REF = input.workspace.baseRef ?? "";
env.PAPERCLIP_PROJECT_ID = input.workspace.projectId ?? "";
env.PAPERCLIP_PROJECT_WORKSPACE_ID = input.workspace.projectWorkspaceId ?? "";
env.PAPERCLIP_ISSUE_ID = input.workspace.sourceIssueId ?? "";
return env;
}
async function resolveGitRepoRootForWorkspaceCleanup(
worktreePath: string,
projectWorkspaceCwd: string | null,
): Promise<string | null> {
if (projectWorkspaceCwd) {
const resolvedProjectWorkspaceCwd = path.resolve(projectWorkspaceCwd);
const gitDir = await runGit(["rev-parse", "--git-common-dir"], resolvedProjectWorkspaceCwd)
.catch(() => null);
if (gitDir) {
const resolvedGitDir = path.resolve(resolvedProjectWorkspaceCwd, gitDir);
return path.dirname(resolvedGitDir);
}
}
const gitDir = await runGit(["rev-parse", "--git-common-dir"], worktreePath).catch(() => null);
if (!gitDir) return null;
const resolvedGitDir = path.resolve(worktreePath, gitDir);
return path.dirname(resolvedGitDir);
}
export async function realizeExecutionWorkspace(input: {
base: ExecutionWorkspaceInput;
config: Record<string, unknown>;
issue: ExecutionWorkspaceIssueRef | null;
agent: ExecutionWorkspaceAgentRef;
recorder?: WorkspaceOperationRecorder | null;
}): Promise<RealizedExecutionWorkspace> {
const rawStrategy = parseObject(input.config.workspaceStrategy);
const strategyType = asString(rawStrategy.type, "project_primary");
@@ -372,6 +583,25 @@ export async function realizeExecutionWorkspace(input: {
if (existingWorktree) {
const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null);
if (existingGitDir) {
if (input.recorder) {
await input.recorder.recordOperation({
phase: "worktree_prepare",
cwd: repoRoot,
metadata: {
repoRoot,
worktreePath,
branchName,
baseRef,
created: false,
reused: true,
},
run: async () => ({
status: "succeeded",
exitCode: 0,
system: `Reused existing git worktree at ${worktreePath}\n`,
}),
});
}
await provisionExecutionWorktree({
strategy: rawStrategy,
base: input.base,
@@ -381,6 +611,7 @@ export async function realizeExecutionWorkspace(input: {
issue: input.issue,
agent: input.agent,
created: false,
recorder: input.recorder ?? null,
});
return {
...input.base,
@@ -395,7 +626,41 @@ export async function realizeExecutionWorkspace(input: {
throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a git worktree.`);
}
await runGit(["worktree", "add", "-B", branchName, worktreePath, baseRef], repoRoot);
try {
await recordGitOperation(input.recorder, {
phase: "worktree_prepare",
args: ["worktree", "add", "-b", branchName, worktreePath, baseRef],
cwd: repoRoot,
metadata: {
repoRoot,
worktreePath,
branchName,
baseRef,
created: true,
},
successMessage: `Created git worktree at ${worktreePath}\n`,
failureLabel: `git worktree add ${worktreePath}`,
});
} catch (error) {
if (!gitErrorIncludes(error, "already exists")) {
throw error;
}
await recordGitOperation(input.recorder, {
phase: "worktree_prepare",
args: ["worktree", "add", worktreePath, branchName],
cwd: repoRoot,
metadata: {
repoRoot,
worktreePath,
branchName,
baseRef,
created: false,
reusedExistingBranch: true,
},
successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`,
failureLabel: `git worktree add ${worktreePath}`,
});
}
await provisionExecutionWorktree({
strategy: rawStrategy,
base: input.base,
@@ -405,6 +670,7 @@ export async function realizeExecutionWorkspace(input: {
issue: input.issue,
agent: input.agent,
created: true,
recorder: input.recorder ?? null,
});
return {
@@ -418,6 +684,158 @@ export async function realizeExecutionWorkspace(input: {
};
}
export async function cleanupExecutionWorkspaceArtifacts(input: {
workspace: {
id: string;
cwd: string | null;
providerType: string;
providerRef: string | null;
branchName: string | null;
repoUrl: string | null;
baseRef: string | null;
projectId: string | null;
projectWorkspaceId: string | null;
sourceIssueId: string | null;
metadata?: Record<string, unknown> | null;
};
projectWorkspace?: {
cwd: string | null;
cleanupCommand: string | null;
} | null;
teardownCommand?: string | null;
recorder?: WorkspaceOperationRecorder | null;
}) {
const warnings: string[] = [];
const workspacePath = input.workspace.providerRef ?? input.workspace.cwd;
const cleanupEnv = buildExecutionWorkspaceCleanupEnv({
workspace: input.workspace,
projectWorkspaceCwd: input.projectWorkspace?.cwd ?? null,
});
const createdByRuntime = input.workspace.metadata?.createdByRuntime === true;
const cleanupCommands = [
input.projectWorkspace?.cleanupCommand ?? null,
input.teardownCommand ?? null,
]
.map((value) => asString(value, "").trim())
.filter(Boolean);
for (const command of cleanupCommands) {
try {
await recordWorkspaceCommandOperation(input.recorder, {
phase: "workspace_teardown",
command,
cwd: workspacePath ?? input.projectWorkspace?.cwd ?? process.cwd(),
env: cleanupEnv,
label: `Execution workspace cleanup command "${command}"`,
metadata: {
workspaceId: input.workspace.id,
workspacePath,
branchName: input.workspace.branchName,
providerType: input.workspace.providerType,
},
successMessage: `Completed cleanup command "${command}"\n`,
});
} catch (err) {
warnings.push(err instanceof Error ? err.message : String(err));
}
}
if (input.workspace.providerType === "git_worktree" && workspacePath) {
const repoRoot = await resolveGitRepoRootForWorkspaceCleanup(
workspacePath,
input.projectWorkspace?.cwd ?? null,
);
const worktreeExists = await directoryExists(workspacePath);
if (worktreeExists) {
if (!repoRoot) {
warnings.push(`Could not resolve git repo root for "${workspacePath}".`);
} else {
try {
await recordGitOperation(input.recorder, {
phase: "worktree_cleanup",
args: ["worktree", "remove", "--force", workspacePath],
cwd: repoRoot,
metadata: {
workspaceId: input.workspace.id,
workspacePath,
branchName: input.workspace.branchName,
cleanupAction: "worktree_remove",
},
successMessage: `Removed git worktree ${workspacePath}\n`,
failureLabel: `git worktree remove ${workspacePath}`,
});
} catch (err) {
warnings.push(err instanceof Error ? err.message : String(err));
}
}
}
if (createdByRuntime && input.workspace.branchName) {
if (!repoRoot) {
warnings.push(`Could not resolve git repo root to delete branch "${input.workspace.branchName}".`);
} else {
try {
await recordGitOperation(input.recorder, {
phase: "worktree_cleanup",
args: ["branch", "-d", input.workspace.branchName],
cwd: repoRoot,
metadata: {
workspaceId: input.workspace.id,
workspacePath,
branchName: input.workspace.branchName,
cleanupAction: "branch_delete",
},
successMessage: `Deleted branch ${input.workspace.branchName}\n`,
failureLabel: `git branch -d ${input.workspace.branchName}`,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
warnings.push(`Skipped deleting branch "${input.workspace.branchName}": ${message}`);
}
}
}
} else if (input.workspace.providerType === "local_fs" && createdByRuntime && workspacePath) {
const projectWorkspaceCwd = input.projectWorkspace?.cwd ? path.resolve(input.projectWorkspace.cwd) : null;
const resolvedWorkspacePath = path.resolve(workspacePath);
const containsProjectWorkspace = projectWorkspaceCwd
? (
resolvedWorkspacePath === projectWorkspaceCwd ||
projectWorkspaceCwd.startsWith(`${resolvedWorkspacePath}${path.sep}`)
)
: false;
if (containsProjectWorkspace) {
warnings.push(`Refusing to remove path "${workspacePath}" because it contains the project workspace.`);
} else {
await fs.rm(resolvedWorkspacePath, { recursive: true, force: true });
if (input.recorder) {
await input.recorder.recordOperation({
phase: "workspace_teardown",
cwd: projectWorkspaceCwd ?? process.cwd(),
metadata: {
workspaceId: input.workspace.id,
workspacePath: resolvedWorkspacePath,
cleanupAction: "remove_local_fs",
},
run: async () => ({
status: "succeeded",
exitCode: 0,
system: `Removed local workspace directory ${resolvedWorkspacePath}\n`,
}),
});
}
}
}
const cleaned =
!workspacePath ||
!(await directoryExists(workspacePath));
return {
cleanedPath: workspacePath,
cleaned,
warnings,
};
}
async function allocatePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
@@ -471,6 +889,7 @@ function buildTemplateData(input: {
function resolveServiceScopeId(input: {
service: Record<string, unknown>;
workspace: RealizedExecutionWorkspace;
executionWorkspaceId?: string | null;
issue: ExecutionWorkspaceIssueRef | null;
runId: string;
agent: ExecutionWorkspaceAgentRef;
@@ -486,7 +905,9 @@ function resolveServiceScopeId(input: {
? 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 === "execution_workspace") {
return { scopeType, scopeId: input.executionWorkspaceId ?? input.workspace.cwd };
}
if (scopeType === "agent") return { scopeType, scopeId: input.agent.id };
return { scopeType: "run" as const, scopeId: input.runId };
}
@@ -521,6 +942,7 @@ function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeo
companyId: record.companyId,
projectId: record.projectId,
projectWorkspaceId: record.projectWorkspaceId,
executionWorkspaceId: record.executionWorkspaceId,
issueId: record.issueId,
scopeType: record.scopeType,
scopeId: record.scopeId,
@@ -556,6 +978,7 @@ async function persistRuntimeServiceRecord(db: Db | undefined, record: RuntimeSe
set: {
projectId: values.projectId,
projectWorkspaceId: values.projectWorkspaceId,
executionWorkspaceId: values.executionWorkspaceId,
issueId: values.issueId,
scopeType: values.scopeType,
scopeId: values.scopeId,
@@ -593,6 +1016,7 @@ export function normalizeAdapterManagedRuntimeServices(input: {
agent: ExecutionWorkspaceAgentRef;
issue: ExecutionWorkspaceIssueRef | null;
workspace: RealizedExecutionWorkspace;
executionWorkspaceId?: string | null;
reports: AdapterRuntimeServiceReport[];
now?: Date;
}): RuntimeServiceRef[] {
@@ -604,7 +1028,7 @@ export function normalizeAdapterManagedRuntimeServices(input: {
(scopeType === "project_workspace"
? input.workspace.workspaceId
: scopeType === "execution_workspace"
? input.workspace.cwd
? input.executionWorkspaceId ?? input.workspace.cwd
: scopeType === "agent"
? input.agent.id
: input.runId) ??
@@ -629,6 +1053,7 @@ export function normalizeAdapterManagedRuntimeServices(input: {
companyId: input.agent.companyId,
projectId: report.projectId ?? input.workspace.projectId,
projectWorkspaceId: report.projectWorkspaceId ?? input.workspace.workspaceId,
executionWorkspaceId: input.executionWorkspaceId ?? null,
issueId: report.issueId ?? input.issue?.id ?? null,
serviceName,
status,
@@ -660,6 +1085,7 @@ async function startLocalRuntimeService(input: {
agent: ExecutionWorkspaceAgentRef;
issue: ExecutionWorkspaceIssueRef | null;
workspace: RealizedExecutionWorkspace;
executionWorkspaceId?: string | null;
adapterEnv: Record<string, string>;
service: Record<string, unknown>;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
@@ -683,7 +1109,10 @@ async function startLocalRuntimeService(input: {
port,
});
const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd);
const env: Record<string, string> = { ...process.env, ...input.adapterEnv } as Record<string, string>;
const env: Record<string, string> = {
...sanitizeRuntimeServiceBaseEnv(process.env),
...input.adapterEnv,
} as Record<string, string>;
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") {
env[key] = renderTemplate(value, templateData);
@@ -697,7 +1126,7 @@ async function startLocalRuntimeService(input: {
const child = spawn(shell, ["-lc", command], {
cwd: serviceCwd,
env,
detached: false,
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
});
let stderrExcerpt = "";
@@ -723,7 +1152,7 @@ async function startLocalRuntimeService(input: {
try {
await waitForReadiness({ service: input.service, url });
} catch (err) {
child.kill("SIGTERM");
terminateChildProcess(child);
throw new Error(
`Failed to start runtime service "${serviceName}": ${err instanceof Error ? err.message : String(err)}${stderrExcerpt ? ` | stderr: ${stderrExcerpt.trim()}` : ""}`,
);
@@ -735,6 +1164,7 @@ async function startLocalRuntimeService(input: {
companyId: input.agent.companyId,
projectId: input.workspace.projectId,
projectWorkspaceId: input.workspace.workspaceId,
executionWorkspaceId: input.executionWorkspaceId ?? null,
issueId: input.issue?.id ?? null,
serviceName,
status: "running",
@@ -781,8 +1211,8 @@ async function stopRuntimeService(serviceId: string) {
record.status = "stopped";
record.lastUsedAt = new Date().toISOString();
record.stoppedAt = new Date().toISOString();
if (record.child && !record.child.killed) {
record.child.kill("SIGTERM");
if (record.child && record.child.pid) {
terminateChildProcess(record.child);
}
runtimeServicesById.delete(serviceId);
if (record.reuseKey) {
@@ -791,6 +1221,28 @@ async function stopRuntimeService(serviceId: string) {
await persistRuntimeServiceRecord(record.db, record);
}
async function markPersistedRuntimeServicesStoppedForExecutionWorkspace(input: {
db: Db;
executionWorkspaceId: string;
}) {
const now = new Date();
await input.db
.update(workspaceRuntimeServices)
.set({
status: "stopped",
healthStatus: "unknown",
stoppedAt: now,
lastUsedAt: now,
updatedAt: now,
})
.where(
and(
eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId),
inArray(workspaceRuntimeServices.status, ["starting", "running"]),
),
);
}
function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord) {
record.db = db;
runtimeServicesById.set(record.id, record);
@@ -820,6 +1272,7 @@ export async function ensureRuntimeServicesForRun(input: {
agent: ExecutionWorkspaceAgentRef;
issue: ExecutionWorkspaceIssueRef | null;
workspace: RealizedExecutionWorkspace;
executionWorkspaceId?: string | null;
config: Record<string, unknown>;
adapterEnv: Record<string, string>;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
@@ -838,6 +1291,7 @@ export async function ensureRuntimeServicesForRun(input: {
const { scopeType, scopeId } = resolveServiceScopeId({
service,
workspace: input.workspace,
executionWorkspaceId: input.executionWorkspaceId,
issue: input.issue,
runId: input.runId,
agent: input.agent,
@@ -871,6 +1325,7 @@ export async function ensureRuntimeServicesForRun(input: {
agent: input.agent,
issue: input.issue,
workspace: input.workspace,
executionWorkspaceId: input.executionWorkspaceId,
adapterEnv: input.adapterEnv,
service,
onLog: input.onLog,
@@ -911,6 +1366,36 @@ export async function releaseRuntimeServicesForRun(runId: string) {
}
}
export async function stopRuntimeServicesForExecutionWorkspace(input: {
db?: Db;
executionWorkspaceId: string;
workspaceCwd?: string | null;
}) {
const normalizedWorkspaceCwd = input.workspaceCwd ? path.resolve(input.workspaceCwd) : null;
const matchingServiceIds = Array.from(runtimeServicesById.values())
.filter((record) => {
if (record.executionWorkspaceId === input.executionWorkspaceId) return true;
if (!normalizedWorkspaceCwd || !record.cwd) return false;
const resolvedCwd = path.resolve(record.cwd);
return (
resolvedCwd === normalizedWorkspaceCwd ||
resolvedCwd.startsWith(`${normalizedWorkspaceCwd}${path.sep}`)
);
})
.map((record) => record.id);
for (const serviceId of matchingServiceIds) {
await stopRuntimeService(serviceId);
}
if (input.db) {
await markPersistedRuntimeServicesStoppedForExecutionWorkspace({
db: input.db,
executionWorkspaceId: input.executionWorkspaceId,
});
}
}
export async function listWorkspaceRuntimeServicesForProjectWorkspaces(
db: Db,
companyId: string,
@@ -978,6 +1463,7 @@ export async function persistAdapterManagedRuntimeServices(input: {
agent: ExecutionWorkspaceAgentRef;
issue: ExecutionWorkspaceIssueRef | null;
workspace: RealizedExecutionWorkspace;
executionWorkspaceId?: string | null;
reports: AdapterRuntimeServiceReport[];
}) {
const refs = normalizeAdapterManagedRuntimeServices(input);
@@ -1000,6 +1486,7 @@ export async function persistAdapterManagedRuntimeServices(input: {
companyId: ref.companyId,
projectId: ref.projectId,
projectWorkspaceId: ref.projectWorkspaceId,
executionWorkspaceId: ref.executionWorkspaceId,
issueId: ref.issueId,
scopeType: ref.scopeType,
scopeId: ref.scopeId,
@@ -1028,6 +1515,7 @@ export async function persistAdapterManagedRuntimeServices(input: {
set: {
projectId: ref.projectId,
projectWorkspaceId: ref.projectWorkspaceId,
executionWorkspaceId: ref.executionWorkspaceId,
issueId: ref.issueId,
scopeType: ref.scopeType,
scopeId: ref.scopeId,

View File

@@ -14,6 +14,7 @@ import { Projects } from "./pages/Projects";
import { ProjectDetail } from "./pages/ProjectDetail";
import { Issues } from "./pages/Issues";
import { IssueDetail } from "./pages/IssueDetail";
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
import { Goals } from "./pages/Goals";
import { GoalDetail } from "./pages/GoalDetail";
import { Approvals } from "./pages/Approvals";
@@ -27,6 +28,7 @@ import { CompanyExport } from "./pages/CompanyExport";
import { CompanyImport } from "./pages/CompanyImport";
import { DesignGuide } from "./pages/DesignGuide";
import { InstanceSettings } from "./pages/InstanceSettings";
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
import { PluginManager } from "./pages/PluginManager";
import { PluginSettings } from "./pages/PluginSettings";
import { PluginPage } from "./pages/PluginPage";
@@ -147,6 +149,7 @@ function boardRoutes() {
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
@@ -311,6 +314,7 @@ export function App() {
<Route path="instance/settings" element={<Layout />}>
<Route index element={<Navigate to="heartbeats" replace />} />
<Route path="heartbeats" element={<InstanceSettings />} />
<Route path="experimental" element={<InstanceExperimentalSettings />} />
<Route path="plugins" element={<PluginManager />} />
<Route path="plugins/:pluginId" element={<PluginSettings />} />
</Route>

View File

@@ -0,0 +1,26 @@
import type { ExecutionWorkspace } from "@paperclipai/shared";
import { api } from "./client";
export const executionWorkspacesApi = {
list: (
companyId: string,
filters?: {
projectId?: string;
projectWorkspaceId?: string;
issueId?: string;
status?: string;
reuseEligible?: boolean;
},
) => {
const params = new URLSearchParams();
if (filters?.projectId) params.set("projectId", filters.projectId);
if (filters?.projectWorkspaceId) params.set("projectWorkspaceId", filters.projectWorkspaceId);
if (filters?.issueId) params.set("issueId", filters.issueId);
if (filters?.status) params.set("status", filters.status);
if (filters?.reuseEligible) params.set("reuseEligible", "true");
const qs = params.toString();
return api.get<ExecutionWorkspace[]>(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`);
},
get: (id: string) => api.get<ExecutionWorkspace>(`/execution-workspaces/${id}`),
update: (id: string, data: Record<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
};

View File

@@ -2,6 +2,7 @@ import type {
HeartbeatRun,
HeartbeatRunEvent,
InstanceSchedulerHeartbeatAgent,
WorkspaceOperation,
} from "@paperclipai/shared";
import { api } from "./client";
@@ -42,6 +43,12 @@ export const heartbeatsApi = {
api.get<{ runId: string; store: string; logRef: string; content: string; nextOffset?: number }>(
`/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
),
workspaceOperations: (runId: string) =>
api.get<WorkspaceOperation[]>(`/heartbeat-runs/${runId}/workspace-operations`),
workspaceOperationLog: (operationId: string, offset = 0, limitBytes = 256000) =>
api.get<{ operationId: string; store: string; logRef: string; content: string; nextOffset?: number }>(
`/workspace-operations/${operationId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
),
cancel: (runId: string) => api.post<void>(`/heartbeat-runs/${runId}/cancel`, {}),
liveRunsForIssue: (issueId: string) =>
api.get<LiveRunForIssue[]>(`/issues/${issueId}/live-runs`),

View File

@@ -12,5 +12,6 @@ export { costsApi } from "./costs";
export { activityApi } from "./activity";
export { dashboardApi } from "./dashboard";
export { heartbeatsApi } from "./heartbeats";
export { instanceSettingsApi } from "./instanceSettings";
export { sidebarBadgesApi } from "./sidebarBadges";
export { companySkillsApi } from "./companySkills";

View File

@@ -0,0 +1,12 @@
import type {
InstanceExperimentalSettings,
PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
import { api } from "./client";
export const instanceSettingsApi = {
getExperimental: () =>
api.get<InstanceExperimentalSettings>("/instance/settings/experimental"),
updateExperimental: (patch: PatchInstanceExperimentalSettings) =>
api.patch<InstanceExperimentalSettings>("/instance/settings/experimental", patch),
};

View File

@@ -6,6 +6,7 @@ import type {
IssueComment,
IssueDocument,
IssueLabel,
IssueWorkProduct,
UpsertIssueDocument,
} from "@paperclipai/shared";
import { api } from "./client";
@@ -90,4 +91,10 @@ export const issuesApi = {
api.post<Approval[]>(`/issues/${id}/approvals`, { approvalId }),
unlinkApproval: (id: string, approvalId: string) =>
api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`),
listWorkProducts: (id: string) => api.get<IssueWorkProduct[]>(`/issues/${id}/work-products`),
createWorkProduct: (id: string, data: Record<string, unknown>) =>
api.post<IssueWorkProduct>(`/issues/${id}/work-products`, data),
updateWorkProduct: (id: string, data: Record<string, unknown>) =>
api.patch<IssueWorkProduct>(`/work-products/${id}`, data),
deleteWorkProduct: (id: string) => api.delete<IssueWorkProduct>(`/work-products/${id}`),
};

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { Clock3, Puzzle, Settings } from "lucide-react";
import { Clock3, FlaskConical, Puzzle, Settings } from "lucide-react";
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
@@ -23,6 +23,7 @@ export function InstanceSidebar() {
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
{(plugins ?? []).length > 0 ? (
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">

View File

@@ -1,9 +1,11 @@
import { useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Link } from "@/lib/router";
import type { Issue } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { instanceSettingsApi } from "../api/instanceSettings";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
@@ -18,11 +20,38 @@ import { formatDate, cn, projectUrl } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react";
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2, Copy, Check } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
const EXECUTION_WORKSPACE_OPTIONS = [
{ value: "shared_workspace", label: "Project default" },
{ value: "isolated_workspace", label: "New isolated workspace" },
{ value: "reuse_existing", label: "Reuse existing workspace" },
] as const;
function defaultProjectWorkspaceIdForProject(project: {
workspaces?: Array<{ id: string; isPrimary: boolean }>;
executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null;
} | null | undefined) {
if (!project) return null;
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id
?? project.workspaces?.[0]?.id
?? null;
}
function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) {
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
if (defaultMode === "isolated_workspace" || defaultMode === "operator_branch") return defaultMode;
if (defaultMode === "adapter_default") return "agent_default";
return "shared_workspace";
}
function issueModeForExistingWorkspace(mode: string | null | undefined) {
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") return mode;
if (mode === "adapter_managed" || mode === "cloud_sandbox") return "agent_default";
return "shared_workspace";
}
interface IssuePropertiesProps {
issue: Issue;
@@ -101,6 +130,49 @@ function PropertyPicker({
);
}
/** Splits a string at `/` and `-` boundaries, inserting <wbr> for natural line breaks. */
function BreakablePath({ text }: { text: string }) {
const parts: React.ReactNode[] = [];
// Split on path separators and hyphens, keeping them in the output
const segments = text.split(/(?<=[\/-])/);
for (let i = 0; i < segments.length; i++) {
if (i > 0) parts.push(<wbr key={i} />);
parts.push(segments[i]);
}
return <>{parts}</>;
}
/** Displays a value with a copy-to-clipboard icon and "Copied!" feedback. */
function CopyableValue({ value, label, mono, className }: { value: string; label?: string; mono?: boolean; className?: string }) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
} catch { /* noop */ }
}, [value]);
return (
<div className={cn("flex items-start gap-1 group", className)}>
<span className="min-w-0" style={{ overflowWrap: "anywhere" }}>
{label && <span className="text-muted-foreground">{label} </span>}
<span className={mono ? "font-mono" : undefined}><BreakablePath text={value} /></span>
</span>
<button
type="button"
className="shrink-0 mt-0.5 p-0.5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 focus:opacity-100"
onClick={handleCopy}
title={copied ? "Copied!" : "Copy to clipboard"}
>
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
</button>
</div>
);
}
export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
@@ -118,6 +190,10 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const currentUserId = session?.user?.id ?? session?.session?.userId;
const { data: agents } = useQuery({
@@ -187,15 +263,44 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
const currentProject = issue.projectId
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
: null;
const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectExecutionWorkspacePolicy =
experimentalSettings?.enableIsolatedWorkspaces === true
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const usesIsolatedExecutionWorkspace = issue.executionWorkspaceSettings?.mode === "isolated"
? true
: issue.executionWorkspaceSettings?.mode === "project_primary"
? false
: currentProjectExecutionWorkspacePolicy?.defaultMode === "isolated";
const currentExecutionWorkspaceSelection =
issue.executionWorkspacePreference
?? issue.executionWorkspaceSettings?.mode
?? defaultExecutionWorkspaceModeForProject(currentProject);
const { data: reusableExecutionWorkspaces } = useQuery({
queryKey: queryKeys.executionWorkspaces.list(companyId!, {
projectId: issue.projectId ?? undefined,
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
reuseEligible: true,
}),
queryFn: () =>
executionWorkspacesApi.list(companyId!, {
projectId: issue.projectId ?? undefined,
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
reuseEligible: true,
}),
enabled: Boolean(companyId) && Boolean(issue.projectId),
});
const deduplicatedReusableWorkspaces = useMemo(() => {
const workspaces = reusableExecutionWorkspaces ?? [];
const seen = new Map<string, typeof workspaces[number]>();
for (const ws of workspaces) {
const key = ws.cwd ?? ws.id;
const existing = seen.get(key);
if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) {
seen.set(key, ws);
}
}
return Array.from(seen.values());
}, [reusableExecutionWorkspaces]);
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
(workspace) => workspace.id === issue.executionWorkspaceId,
);
const projectLink = (id: string | null) => {
if (!id) return null;
const project = projects?.find((p) => p.id === id) ?? null;
@@ -431,7 +536,13 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
!issue.projectId && "bg-accent"
)}
onClick={() => {
onUpdate({ projectId: null, executionWorkspaceSettings: null });
onUpdate({
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
});
setProjectOpen(false);
}}
>
@@ -451,10 +562,14 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
p.id === issue.projectId && "bg-accent"
)}
onClick={() => {
const defaultMode = defaultExecutionWorkspaceModeForProject(p);
onUpdate({
projectId: p.id,
executionWorkspaceSettings: SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI && p.executionWorkspacePolicy?.enabled
? { mode: p.executionWorkspacePolicy.defaultMode === "isolated" ? "isolated" : "project_primary" }
projectWorkspaceId: defaultProjectWorkspaceIdForProject(p),
executionWorkspaceId: null,
executionWorkspacePreference: defaultMode,
executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled
? { mode: defaultMode }
: null,
});
setProjectOpen(false);
@@ -545,36 +660,85 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
{currentProjectSupportsExecutionWorkspace && (
<PropertyRow label="Workspace">
<div className="flex items-center justify-between gap-3 w-full">
<div className="min-w-0">
<div className="text-sm">
{usesIsolatedExecutionWorkspace ? "Isolated issue checkout" : "Project primary checkout"}
</div>
<div className="text-[11px] text-muted-foreground">
Toggle whether this issue runs in its own execution workspace.
</div>
</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
usesIsolatedExecutionWorkspace ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
<div className="w-full space-y-2">
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={currentExecutionWorkspaceSelection}
onChange={(e) => {
const nextMode = e.target.value;
onUpdate({
executionWorkspacePreference: nextMode,
executionWorkspaceId: nextMode === "reuse_existing" ? issue.executionWorkspaceId : null,
executionWorkspaceSettings: {
mode: usesIsolatedExecutionWorkspace ? "project_primary" : "isolated",
mode:
nextMode === "reuse_existing"
? issueModeForExistingWorkspace(selectedReusableExecutionWorkspace?.mode)
: nextMode,
},
})
}
});
}}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
usesIsolatedExecutionWorkspace ? "translate-x-4.5" : "translate-x-0.5",
{EXECUTION_WORKSPACE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{currentExecutionWorkspaceSelection === "reuse_existing" && (
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={issue.executionWorkspaceId ?? ""}
onChange={(e) => {
const nextExecutionWorkspaceId = e.target.value || null;
const nextExecutionWorkspace = deduplicatedReusableWorkspaces.find(
(workspace) => workspace.id === nextExecutionWorkspaceId,
);
onUpdate({
executionWorkspacePreference: "reuse_existing",
executionWorkspaceId: nextExecutionWorkspaceId,
executionWorkspaceSettings: {
mode: issueModeForExistingWorkspace(nextExecutionWorkspace?.mode),
},
});
}}
>
<option value="">Choose an existing workspace</option>
{deduplicatedReusableWorkspaces.map((workspace) => (
<option key={workspace.id} value={workspace.id}>
{workspace.name} · {workspace.status} · {workspace.branchName ?? workspace.cwd ?? workspace.id.slice(0, 8)}
</option>
))}
</select>
)}
{issue.currentExecutionWorkspace && (
<div className="text-[11px] text-muted-foreground space-y-0.5">
<div style={{ overflowWrap: "anywhere" }}>
Current:{" "}
<Link
to={`/execution-workspaces/${issue.currentExecutionWorkspace.id}`}
className="hover:text-foreground hover:underline"
>
<BreakablePath text={issue.currentExecutionWorkspace.name} />
</Link>
{" · "}
{issue.currentExecutionWorkspace.status}
</div>
{issue.currentExecutionWorkspace.cwd && (
<CopyableValue value={issue.currentExecutionWorkspace.cwd} mono className="text-[11px]" />
)}
/>
</button>
{issue.currentExecutionWorkspace.branchName && (
<CopyableValue value={issue.currentExecutionWorkspace.branchName} label="Branch:" className="text-[11px]" />
)}
{issue.currentExecutionWorkspace.repoUrl && (
<CopyableValue value={issue.currentExecutionWorkspace.repoUrl} label="Repo:" mono className="text-[11px]" />
)}
</div>
)}
{!issue.currentExecutionWorkspace && currentProject?.primaryWorkspace?.cwd && (
<CopyableValue value={currentProject.primaryWorkspace.cwd} mono className="text-[11px] text-muted-foreground" />
)}
</div>
</PropertyRow>
)}

View File

@@ -24,32 +24,16 @@ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health";
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import {
DEFAULT_INSTANCE_SETTINGS_PATH,
normalizeRememberedInstanceSettingsPath,
} from "../lib/instance-settings";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { Button } from "@/components/ui/button";
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats";
function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
const match = rawPath.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
const pathname = match?.[1] ?? rawPath;
const search = match?.[2] ?? "";
const hash = match?.[3] ?? "";
if (pathname === "/instance/settings/heartbeats" || pathname === "/instance/settings/plugins") {
return `${pathname}${search}${hash}`;
}
if (/^\/instance\/settings\/plugins\/[^/?#]+$/.test(pathname)) {
return `${pathname}${search}${hash}`;
}
return DEFAULT_INSTANCE_SETTINGS_PATH;
}
function readRememberedInstanceSettingsPath(): string {
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;

View File

@@ -2,7 +2,9 @@ import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, ty
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { issuesApi } from "../api/issues";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
@@ -53,8 +55,6 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
const DRAFT_KEY = "paperclip:issue-draft";
const DEBOUNCE_MS = 800;
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
/** Return black or white hex based on background luminance (WCAG perceptual weights). */
function getContrastTextColor(hexColor: string): string {
@@ -74,10 +74,13 @@ interface IssueDraft {
assigneeValue: string;
assigneeId?: string;
projectId: string;
projectWorkspaceId?: string;
assigneeModelOverride: string;
assigneeThinkingEffort: string;
assigneeChrome: boolean;
useIsolatedExecutionWorkspace: boolean;
executionWorkspaceMode?: string;
selectedExecutionWorkspaceId?: string;
useIsolatedExecutionWorkspace?: boolean;
}
type StagedIssueFile = {
@@ -236,6 +239,42 @@ const priorities = [
{ value: "low", label: "Low", icon: ArrowDown, color: priorityColor.low ?? priorityColorDefault },
];
const EXECUTION_WORKSPACE_MODES = [
{ value: "shared_workspace", label: "Project default" },
{ value: "isolated_workspace", label: "New isolated workspace" },
{ value: "reuse_existing", label: "Reuse existing workspace" },
] as const;
function defaultProjectWorkspaceIdForProject(project: { workspaces?: Array<{ id: string; isPrimary: boolean }>; executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null } | null | undefined) {
if (!project) return "";
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id
?? project.workspaces?.[0]?.id
?? "";
}
function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) {
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
if (
defaultMode === "isolated_workspace" ||
defaultMode === "operator_branch" ||
defaultMode === "adapter_default"
) {
return defaultMode === "adapter_default" ? "agent_default" : defaultMode;
}
return "shared_workspace";
}
function issueExecutionWorkspaceModeForExistingWorkspace(mode: string | null | undefined) {
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") {
return mode;
}
if (mode === "adapter_managed" || mode === "cloud_sandbox") {
return "agent_default";
}
return "shared_workspace";
}
export function NewIssueDialog() {
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
const { companies, selectedCompanyId, selectedCompany } = useCompany();
@@ -247,11 +286,13 @@ export function NewIssueDialog() {
const [priority, setPriority] = useState("");
const [assigneeValue, setAssigneeValue] = useState("");
const [projectId, setProjectId] = useState("");
const [projectWorkspaceId, setProjectWorkspaceId] = useState("");
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
const [assigneeChrome, setAssigneeChrome] = useState(false);
const [useIsolatedExecutionWorkspace, setUseIsolatedExecutionWorkspace] = useState(false);
const [executionWorkspaceMode, setExecutionWorkspaceMode] = useState<string>("shared_workspace");
const [selectedExecutionWorkspaceId, setSelectedExecutionWorkspaceId] = useState("");
const [expanded, setExpanded] = useState(false);
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
const [stagedFiles, setStagedFiles] = useState<StagedIssueFile[]>([]);
@@ -283,10 +324,29 @@ export function NewIssueDialog() {
queryFn: () => projectsApi.list(effectiveCompanyId!),
enabled: !!effectiveCompanyId && newIssueOpen,
});
const { data: reusableExecutionWorkspaces } = useQuery({
queryKey: queryKeys.executionWorkspaces.list(effectiveCompanyId!, {
projectId,
projectWorkspaceId: projectWorkspaceId || undefined,
reuseEligible: true,
}),
queryFn: () =>
executionWorkspacesApi.list(effectiveCompanyId!, {
projectId,
projectWorkspaceId: projectWorkspaceId || undefined,
reuseEligible: true,
}),
enabled: Boolean(effectiveCompanyId) && newIssueOpen && Boolean(projectId),
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
enabled: newIssueOpen,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const activeProjects = useMemo(
() => (projects ?? []).filter((p) => !p.archivedAt),
@@ -417,10 +477,12 @@ export function NewIssueDialog() {
priority,
assigneeValue,
projectId,
projectWorkspaceId,
assigneeModelOverride,
assigneeThinkingEffort,
assigneeChrome,
useIsolatedExecutionWorkspace,
executionWorkspaceMode,
selectedExecutionWorkspaceId,
});
}, [
title,
@@ -429,10 +491,12 @@ export function NewIssueDialog() {
priority,
assigneeValue,
projectId,
projectWorkspaceId,
assigneeModelOverride,
assigneeThinkingEffort,
assigneeChrome,
useIsolatedExecutionWorkspace,
executionWorkspaceMode,
selectedExecutionWorkspaceId,
newIssueOpen,
scheduleSave,
]);
@@ -449,13 +513,20 @@ export function NewIssueDialog() {
setDescription(newIssueDefaults.description ?? "");
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(newIssueDefaults.projectId ?? "");
const defaultProjectId = newIssueDefaults.projectId ?? "";
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
setProjectId(defaultProjectId);
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setUseIsolatedExecutionWorkspace(false);
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
setSelectedExecutionWorkspaceId("");
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
} else if (draft && draft.title.trim()) {
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
setTitle(draft.title);
setDescription(draft.description);
setStatus(draft.status || "todo");
@@ -465,22 +536,33 @@ export function NewIssueDialog() {
? assigneeValueFromSelection(newIssueDefaults)
: (draft.assigneeValue ?? draft.assigneeId ?? ""),
);
setProjectId(newIssueDefaults.projectId ?? draft.projectId);
setProjectId(restoredProjectId);
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
setAssigneeChrome(draft.assigneeChrome ?? false);
setUseIsolatedExecutionWorkspace(draft.useIsolatedExecutionWorkspace ?? false);
setExecutionWorkspaceMode(
draft.executionWorkspaceMode
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject)),
);
setSelectedExecutionWorkspaceId(draft.selectedExecutionWorkspaceId ?? "");
executionWorkspaceDefaultProjectId.current = restoredProjectId || null;
} else {
const defaultProjectId = newIssueDefaults.projectId ?? "";
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(newIssueDefaults.projectId ?? "");
setProjectId(defaultProjectId);
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setUseIsolatedExecutionWorkspace(false);
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
setSelectedExecutionWorkspaceId("");
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
}
}, [newIssueOpen, newIssueDefaults]);
}, [newIssueOpen, newIssueDefaults, orderedProjects]);
useEffect(() => {
if (!supportsAssigneeOverrides) {
@@ -516,11 +598,13 @@ export function NewIssueDialog() {
setPriority("");
setAssigneeValue("");
setProjectId("");
setProjectWorkspaceId("");
setAssigneeOptionsOpen(false);
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setUseIsolatedExecutionWorkspace(false);
setExecutionWorkspaceMode("shared_workspace");
setSelectedExecutionWorkspaceId("");
setExpanded(false);
setDialogCompanyId(null);
setStagedFiles([]);
@@ -534,10 +618,12 @@ export function NewIssueDialog() {
setDialogCompanyId(companyId);
setAssigneeValue("");
setProjectId("");
setProjectWorkspaceId("");
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setUseIsolatedExecutionWorkspace(false);
setExecutionWorkspaceMode("shared_workspace");
setSelectedExecutionWorkspaceId("");
}
function discardDraft() {
@@ -555,13 +641,19 @@ export function NewIssueDialog() {
chrome: assigneeChrome,
});
const selectedProject = orderedProjects.find((project) => project.id === projectId);
const executionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
? selectedProject?.executionWorkspacePolicy
: null;
const executionWorkspacePolicy =
experimentalSettings?.enableIsolatedWorkspaces === true
? selectedProject?.executionWorkspacePolicy ?? null
: null;
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
(workspace) => workspace.id === selectedExecutionWorkspaceId,
);
const requestedExecutionWorkspaceMode =
executionWorkspaceMode === "reuse_existing"
? issueExecutionWorkspaceModeForExistingWorkspace(selectedReusableExecutionWorkspace?.mode)
: executionWorkspaceMode;
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
? {
mode: useIsolatedExecutionWorkspace ? "isolated" : "project_primary",
}
? { mode: requestedExecutionWorkspaceMode }
: null;
createIssue.mutate({
companyId: effectiveCompanyId,
@@ -573,7 +665,12 @@ export function NewIssueDialog() {
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
...(projectId ? { projectId } : {}),
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
...(executionWorkspacePolicy?.enabled ? { executionWorkspacePreference: executionWorkspaceMode } : {}),
...(executionWorkspaceMode === "reuse_existing" && selectedExecutionWorkspaceId
? { executionWorkspaceId: selectedExecutionWorkspaceId }
: {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
});
}
@@ -655,10 +752,26 @@ export function NewIssueDialog() {
? (agents ?? []).find((a) => a.id === selectedAssigneeAgentId)
: null;
const currentProject = orderedProjects.find((project) => project.id === projectId);
const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectExecutionWorkspacePolicy =
experimentalSettings?.enableIsolatedWorkspaces === true
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const deduplicatedReusableWorkspaces = useMemo(() => {
const workspaces = reusableExecutionWorkspaces ?? [];
const seen = new Map<string, typeof workspaces[number]>();
for (const ws of workspaces) {
const key = ws.cwd ?? ws.id;
const existing = seen.get(key);
if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) {
seen.set(key, ws);
}
}
return Array.from(seen.values());
}, [reusableExecutionWorkspaces]);
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
(workspace) => workspace.id === selectedExecutionWorkspaceId,
);
const assigneeOptionsTitle =
assigneeAdapterType === "claude_local"
? "Claude options"
@@ -708,9 +821,10 @@ export function NewIssueDialog() {
const handleProjectChange = useCallback((nextProjectId: string) => {
setProjectId(nextProjectId);
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
const policy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI ? nextProject?.executionWorkspacePolicy : null;
executionWorkspaceDefaultProjectId.current = nextProjectId || null;
setUseIsolatedExecutionWorkspace(Boolean(policy?.enabled && policy.defaultMode === "isolated"));
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(nextProject));
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(nextProject));
setSelectedExecutionWorkspaceId("");
}, [orderedProjects]);
useEffect(() => {
@@ -720,13 +834,9 @@ export function NewIssueDialog() {
const project = orderedProjects.find((entry) => entry.id === projectId);
if (!project) return;
executionWorkspaceDefaultProjectId.current = projectId;
setUseIsolatedExecutionWorkspace(
Boolean(
SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI &&
project.executionWorkspacePolicy?.enabled &&
project.executionWorkspacePolicy.defaultMode === "isolated",
),
);
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(project));
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(project));
setSelectedExecutionWorkspaceId("");
}, [newIssueOpen, orderedProjects, projectId]);
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
() => {
@@ -1007,30 +1117,48 @@ export function NewIssueDialog() {
</div>
</div>
{currentProjectSupportsExecutionWorkspace && (
<div className="px-4 py-3 shrink-0">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="text-xs font-medium">Use isolated issue checkout</div>
<div className="text-[11px] text-muted-foreground">
Create an issue-specific execution workspace instead of using the project's primary checkout.
</div>
{currentProject && currentProjectSupportsExecutionWorkspace && (
<div className="px-4 py-3 shrink-0 space-y-2">
<div className="space-y-1.5">
<div className="text-xs font-medium">Execution workspace</div>
<div className="text-[11px] text-muted-foreground">
Control whether this issue runs in the shared workspace, a new isolated workspace, or an existing one.
</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
useIsolatedExecutionWorkspace ? "bg-green-600" : "bg-muted",
)}
onClick={() => setUseIsolatedExecutionWorkspace((value) => !value)}
type="button"
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={executionWorkspaceMode}
onChange={(e) => {
setExecutionWorkspaceMode(e.target.value);
if (e.target.value !== "reuse_existing") {
setSelectedExecutionWorkspaceId("");
}
}}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
useIsolatedExecutionWorkspace ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
{EXECUTION_WORKSPACE_MODES.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{executionWorkspaceMode === "reuse_existing" && (
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={selectedExecutionWorkspaceId}
onChange={(e) => setSelectedExecutionWorkspaceId(e.target.value)}
>
<option value="">Choose an existing workspace</option>
{deduplicatedReusableWorkspaces.map((workspace) => (
<option key={workspace.id} value={workspace.id}>
{workspace.name} · {workspace.status} · {workspace.branchName ?? workspace.cwd ?? workspace.id.slice(0, 8)}
</option>
))}
</select>
)}
{executionWorkspaceMode === "reuse_existing" && selectedReusableExecutionWorkspace && (
<div className="text-[11px] text-muted-foreground">
Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? selectedReusableExecutionWorkspace.cwd ?? "existing execution workspace"}.
</div>
)}
</div>
</div>
)}

View File

@@ -42,7 +42,6 @@ const projectStatuses = [
];
type WorkspaceSetup = "none" | "local" | "repo" | "both";
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
export function NewProjectDialog() {
const { newProjectOpen, closeNewProject } = useDialog();
@@ -142,7 +141,7 @@ export function NewProjectDialog() {
return;
}
if (repoRequired && !isGitHubRepoUrl(repoUrl)) {
setWorkspaceError("Repo workspace must use a valid GitHub repo URL.");
setWorkspaceError("Repo must use a valid GitHub repo URL.");
return;
}
@@ -173,7 +172,6 @@ export function NewProjectDialog() {
} else if (repoRequired) {
workspacePayloads.push({
name: deriveWorkspaceNameFromRepo(repoUrl),
cwd: REPO_ONLY_CWD_SENTINEL,
repoUrl,
});
}
@@ -284,7 +282,7 @@ export function NewProjectDialog() {
<div className="px-4 pb-3 space-y-3 border-t border-border">
<div className="pt-3">
<p className="text-sm font-medium">Where will work be done on this project?</p>
<p className="text-xs text-muted-foreground">Add local folder and/or GitHub repo workspace hints.</p>
<p className="text-xs text-muted-foreground">Add a repo and/or local folder for this project.</p>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<button
@@ -311,7 +309,7 @@ export function NewProjectDialog() {
>
<div className="flex items-center gap-2 text-sm font-medium">
<Github className="h-4 w-4" />
A github repo
A repo
</div>
<p className="mt-1 text-xs text-muted-foreground">Paste a GitHub URL.</p>
</button>
@@ -327,7 +325,7 @@ export function NewProjectDialog() {
<GitBranch className="h-4 w-4" />
Both
</div>
<p className="mt-1 text-xs text-muted-foreground">Configure local + repo hints.</p>
<p className="mt-1 text-xs text-muted-foreground">Configure both repo and local folder.</p>
</button>
</div>
@@ -347,7 +345,7 @@ export function NewProjectDialog() {
)}
{(workspaceSetup === "repo" || workspaceSetup === "both") && (
<div className="rounded-md border border-border p-2">
<label className="mb-1 block text-xs text-muted-foreground">GitHub repo URL</label>
<label className="mb-1 block text-xs text-muted-foreground">Repo URL</label>
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={workspaceRepoUrl}

File diff suppressed because it is too large Load Diff

View File

@@ -110,6 +110,7 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue {
id,
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: `Issue ${id}`,
@@ -125,6 +126,8 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue {
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_INSTANCE_SETTINGS_PATH,
normalizeRememberedInstanceSettingsPath,
} from "./instance-settings";
describe("normalizeRememberedInstanceSettingsPath", () => {
it("keeps known instance settings pages", () => {
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/experimental")).toBe(
"/instance/settings/experimental",
);
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/plugins/example?tab=config#logs")).toBe(
"/instance/settings/plugins/example?tab=config#logs",
);
});
it("falls back to the default page for unknown paths", () => {
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/nope")).toBe(
DEFAULT_INSTANCE_SETTINGS_PATH,
);
expect(normalizeRememberedInstanceSettingsPath(null)).toBe(DEFAULT_INSTANCE_SETTINGS_PATH);
});
});

View File

@@ -0,0 +1,24 @@
export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats";
export function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
const match = rawPath.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
const pathname = match?.[1] ?? rawPath;
const search = match?.[2] ?? "";
const hash = match?.[3] ?? "";
if (
pathname === "/instance/settings/heartbeats" ||
pathname === "/instance/settings/plugins" ||
pathname === "/instance/settings/experimental"
) {
return `${pathname}${search}${hash}`;
}
if (/^\/instance\/settings\/plugins\/[^/?#]+$/.test(pathname)) {
return `${pathname}${search}${hash}`;
}
return DEFAULT_INSTANCE_SETTINGS_PATH;
}

View File

@@ -43,6 +43,12 @@ export const queryKeys = {
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
liveRuns: (issueId: string) => ["issues", "live-runs", issueId] as const,
activeRun: (issueId: string) => ["issues", "active-run", issueId] as const,
workProducts: (issueId: string) => ["issues", "work-products", issueId] as const,
},
executionWorkspaces: {
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
["execution-workspaces", companyId, filters ?? {}] as const,
detail: (id: string) => ["execution-workspaces", "detail", id] as const,
},
projects: {
list: (companyId: string) => ["projects", companyId] as const,
@@ -72,6 +78,7 @@ export const queryKeys = {
},
instance: {
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
experimentalSettings: ["instance", "experimental-settings"] as const,
},
health: ["health"] as const,
secrets: {
@@ -102,6 +109,7 @@ export const queryKeys = {
heartbeats: (companyId: string, agentId?: string) =>
["heartbeats", companyId, agentId] as const,
runDetail: (runId: string) => ["heartbeat-run", runId] as const,
runWorkspaceOperations: (runId: string) => ["heartbeat-run", runId, "workspace-operations"] as const,
liveRuns: (companyId: string) => ["live-runs", companyId] as const,
runIssues: (runId: string) => ["run-issues", runId] as const,
org: (companyId: string) => ["org", companyId] as const,

View File

@@ -71,6 +71,7 @@ import {
type HeartbeatRunEvent,
type AgentRuntimeState,
type LiveEvent,
type WorkspaceOperation,
} from "@paperclipai/shared";
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
import { agentRouteRef } from "../lib/utils";
@@ -243,6 +244,219 @@ function asNonEmptyString(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : null;
}
function parseStoredLogContent(content: string): RunLogChunk[] {
const parsed: RunLogChunk[] = [];
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown };
const stream =
raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout";
const chunk = typeof raw.chunk === "string" ? raw.chunk : "";
const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString();
if (!chunk) continue;
parsed.push({ ts, stream, chunk });
} catch {
// Ignore malformed log lines.
}
}
return parsed;
}
function workspaceOperationPhaseLabel(phase: WorkspaceOperation["phase"]) {
switch (phase) {
case "worktree_prepare":
return "Worktree setup";
case "workspace_provision":
return "Provision";
case "workspace_teardown":
return "Teardown";
case "worktree_cleanup":
return "Worktree cleanup";
default:
return phase;
}
}
function workspaceOperationStatusTone(status: WorkspaceOperation["status"]) {
switch (status) {
case "succeeded":
return "border-green-500/20 bg-green-500/10 text-green-700 dark:text-green-300";
case "failed":
return "border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300";
case "running":
return "border-cyan-500/20 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300";
case "skipped":
return "border-yellow-500/20 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300";
default:
return "border-border bg-muted/40 text-muted-foreground";
}
}
function WorkspaceOperationStatusBadge({ status }: { status: WorkspaceOperation["status"] }) {
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium capitalize",
workspaceOperationStatusTone(status),
)}
>
{status.replace("_", " ")}
</span>
);
}
function WorkspaceOperationLogViewer({ operation }: { operation: WorkspaceOperation }) {
const [open, setOpen] = useState(false);
const { data: logData, isLoading, error } = useQuery({
queryKey: ["workspace-operation-log", operation.id],
queryFn: () => heartbeatsApi.workspaceOperationLog(operation.id),
enabled: open && Boolean(operation.logRef),
refetchInterval: open && operation.status === "running" ? 2000 : false,
});
const chunks = useMemo(
() => (logData?.content ? parseStoredLogContent(logData.content) : []),
[logData?.content],
);
return (
<div className="space-y-2">
<button
type="button"
className="text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground"
onClick={() => setOpen((value) => !value)}
>
{open ? "Hide full log" : "Show full log"}
</button>
{open && (
<div className="rounded-md border border-border bg-background/70 p-2">
{isLoading && <div className="text-xs text-muted-foreground">Loading log...</div>}
{error && (
<div className="text-xs text-destructive">
{error instanceof Error ? error.message : "Failed to load workspace operation log"}
</div>
)}
{!isLoading && !error && chunks.length === 0 && (
<div className="text-xs text-muted-foreground">No persisted log lines.</div>
)}
{chunks.length > 0 && (
<div className="max-h-64 overflow-y-auto rounded bg-neutral-100 p-2 font-mono text-xs dark:bg-neutral-950">
{chunks.map((chunk, index) => (
<div key={`${chunk.ts}-${index}`} className="flex gap-2">
<span className="shrink-0 text-neutral-500">
{new Date(chunk.ts).toLocaleTimeString("en-US", { hour12: false })}
</span>
<span
className={cn(
"shrink-0 w-14",
chunk.stream === "stderr"
? "text-red-600 dark:text-red-300"
: chunk.stream === "system"
? "text-blue-600 dark:text-blue-300"
: "text-muted-foreground",
)}
>
[{chunk.stream}]
</span>
<span className="whitespace-pre-wrap break-all">{redactHomePathUserSegments(chunk.chunk)}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
function WorkspaceOperationsSection({ operations }: { operations: WorkspaceOperation[] }) {
if (operations.length === 0) return null;
return (
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-3">
<div className="text-xs font-medium text-muted-foreground">
Workspace ({operations.length})
</div>
<div className="space-y-3">
{operations.map((operation) => {
const metadata = asRecord(operation.metadata);
return (
<div key={operation.id} className="rounded-md border border-border/70 bg-background/70 p-3 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{workspaceOperationPhaseLabel(operation.phase)}</div>
<WorkspaceOperationStatusBadge status={operation.status} />
<div className="text-[11px] text-muted-foreground">
{relativeTime(operation.startedAt)}
{operation.finishedAt && ` to ${relativeTime(operation.finishedAt)}`}
</div>
</div>
{operation.command && (
<div className="text-xs break-all">
<span className="text-muted-foreground">Command: </span>
<span className="font-mono">{operation.command}</span>
</div>
)}
{operation.cwd && (
<div className="text-xs break-all">
<span className="text-muted-foreground">Working dir: </span>
<span className="font-mono">{operation.cwd}</span>
</div>
)}
{(asNonEmptyString(metadata?.branchName)
|| asNonEmptyString(metadata?.baseRef)
|| asNonEmptyString(metadata?.worktreePath)
|| asNonEmptyString(metadata?.repoRoot)
|| asNonEmptyString(metadata?.cleanupAction)) && (
<div className="grid gap-1 text-xs sm:grid-cols-2">
{asNonEmptyString(metadata?.branchName) && (
<div><span className="text-muted-foreground">Branch: </span><span className="font-mono">{metadata?.branchName as string}</span></div>
)}
{asNonEmptyString(metadata?.baseRef) && (
<div><span className="text-muted-foreground">Base ref: </span><span className="font-mono">{metadata?.baseRef as string}</span></div>
)}
{asNonEmptyString(metadata?.worktreePath) && (
<div className="break-all"><span className="text-muted-foreground">Worktree: </span><span className="font-mono">{metadata?.worktreePath as string}</span></div>
)}
{asNonEmptyString(metadata?.repoRoot) && (
<div className="break-all"><span className="text-muted-foreground">Repo root: </span><span className="font-mono">{metadata?.repoRoot as string}</span></div>
)}
{asNonEmptyString(metadata?.cleanupAction) && (
<div><span className="text-muted-foreground">Cleanup: </span><span className="font-mono">{metadata?.cleanupAction as string}</span></div>
)}
</div>
)}
{typeof metadata?.created === "boolean" && (
<div className="text-xs text-muted-foreground">
{metadata.created ? "Created by this run" : "Reused existing workspace"}
</div>
)}
{operation.stderrExcerpt && operation.stderrExcerpt.trim() && (
<div>
<div className="mb-1 text-xs text-red-700 dark:text-red-300">stderr excerpt</div>
<pre className="rounded-md bg-red-50 p-2 text-xs whitespace-pre-wrap break-all text-red-800 dark:bg-neutral-950 dark:text-red-100">
{redactHomePathUserSegments(operation.stderrExcerpt)}
</pre>
</div>
)}
{operation.stdoutExcerpt && operation.stdoutExcerpt.trim() && (
<div>
<div className="mb-1 text-xs text-muted-foreground">stdout excerpt</div>
<pre className="rounded-md bg-neutral-100 p-2 text-xs whitespace-pre-wrap break-all dark:bg-neutral-950">
{redactHomePathUserSegments(operation.stdoutExcerpt)}
</pre>
</div>
)}
{operation.logRef && <WorkspaceOperationLogViewer operation={operation} />}
</div>
);
})}
</div>
</div>
);
}
export function AgentDetail() {
const { companyPrefix, agentId, tab: urlTab, runId: urlRunId } = useParams<{
companyPrefix?: string;
@@ -2126,6 +2340,11 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
distanceFromBottom: Number.POSITIVE_INFINITY,
});
const isLive = run.status === "running" || run.status === "queued";
const { data: workspaceOperations = [] } = useQuery({
queryKey: queryKeys.runWorkspaceOperations(run.id),
queryFn: () => heartbeatsApi.workspaceOperations(run.id),
refetchInterval: isLive ? 2000 : false,
});
function isRunLogUnavailable(err: unknown): boolean {
return err instanceof ApiError && err.status === 404;
@@ -2496,6 +2715,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return (
<div className="space-y-3">
<WorkspaceOperationsSection operations={workspaceOperations} />
{adapterInvokePayload && (
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">Invocation</div>

View File

@@ -0,0 +1,82 @@
import { Link, useParams } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { ExternalLink } from "lucide-react";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { queryKeys } from "../lib/queryKeys";
function isSafeExternalUrl(value: string | null | undefined) {
if (!value) return false;
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 py-1.5">
<div className="w-28 shrink-0 text-xs text-muted-foreground">{label}</div>
<div className="min-w-0 flex-1 text-sm">{children}</div>
</div>
);
}
export function ExecutionWorkspaceDetail() {
const { workspaceId } = useParams<{ workspaceId: string }>();
const { data: workspace, isLoading, error } = useQuery({
queryKey: queryKeys.executionWorkspaces.detail(workspaceId!),
queryFn: () => executionWorkspacesApi.get(workspaceId!),
enabled: Boolean(workspaceId),
});
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
if (error) return <p className="text-sm text-destructive">{error instanceof Error ? error.message : "Failed to load workspace"}</p>;
if (!workspace) return null;
return (
<div className="max-w-2xl space-y-4">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Execution workspace</div>
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
<div className="text-sm text-muted-foreground">
{workspace.status} · {workspace.mode} · {workspace.providerType}
</div>
</div>
<div className="rounded-lg border border-border p-4">
<DetailRow label="Project">
{workspace.projectId ? <Link to={`/projects/${workspace.projectId}`} className="hover:underline">{workspace.projectId}</Link> : "None"}
</DetailRow>
<DetailRow label="Source issue">
{workspace.sourceIssueId ? <Link to={`/issues/${workspace.sourceIssueId}`} className="hover:underline">{workspace.sourceIssueId}</Link> : "None"}
</DetailRow>
<DetailRow label="Branch">{workspace.branchName ?? "None"}</DetailRow>
<DetailRow label="Base ref">{workspace.baseRef ?? "None"}</DetailRow>
<DetailRow label="Working dir">
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span>
</DetailRow>
<DetailRow label="Provider ref">
<span className="break-all font-mono text-xs">{workspace.providerRef ?? "None"}</span>
</DetailRow>
<DetailRow label="Repo URL">
{workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? (
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
{workspace.repoUrl}
<ExternalLink className="h-3 w-3" />
</a>
) : workspace.repoUrl ? (
<span className="break-all font-mono text-xs">{workspace.repoUrl}</span>
) : "None"}
</DetailRow>
<DetailRow label="Opened">{new Date(workspace.openedAt).toLocaleString()}</DetailRow>
<DetailRow label="Last used">{new Date(workspace.lastUsedAt).toLocaleString()}</DetailRow>
<DetailRow label="Cleanup">
{workspace.cleanupEligibleAt ? `${new Date(workspace.cleanupEligibleAt).toLocaleString()}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}` : "Not scheduled"}
</DetailRow>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical } from "lucide-react";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
export function InstanceExperimentalSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: "Instance Settings" },
{ label: "Experimental" },
]);
}, [setBreadcrumbs]);
const experimentalQuery = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const toggleMutation = useMutation({
mutationFn: async (enabled: boolean) =>
instanceSettingsApi.updateExperimental({ enableIsolatedWorkspaces: enabled }),
onSuccess: async () => {
setActionError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings });
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update experimental settings.");
},
});
if (experimentalQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading experimental settings...</div>;
}
if (experimentalQuery.error) {
return (
<div className="text-sm text-destructive">
{experimentalQuery.error instanceof Error
? experimentalQuery.error.message
: "Failed to load experimental settings."}
</div>
);
}
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
return (
<div className="max-w-4xl space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-2">
<FlaskConical className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Experimental</h1>
</div>
<p className="text-sm text-muted-foreground">
Opt into features that are still being evaluated before they become default behavior.
</p>
</div>
{actionError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
)}
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Enabled Isolated Workspaces</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Show execution workspace controls in project configuration and allow isolated workspace behavior for new
and existing issue runs.
</p>
</div>
<button
type="button"
aria-label="Toggle isolated workspaces experimental setting"
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
enableIsolatedWorkspaces ? "bg-green-600" : "bg-muted",
)}
onClick={() => toggleMutation.mutate(!enableIsolatedWorkspaces)}
>
<span
className={cn(
"inline-block h-4.5 w-4.5 rounded-full bg-white transition-transform",
enableIsolatedWorkspaces ? "translate-x-6" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
</div>
);
}

View File

@@ -591,7 +591,6 @@ export function IssueDetail() {
// Ancestors are returned oldest-first from the server (root at end, immediate parent at start)
const ancestors = issue.ancestors ?? [];
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
const files = evt.target.files;
if (!files || files.length === 0) return;

View File

@@ -296,6 +296,12 @@ export function ProjectDetail() {
pushToast({ title: `"${name}" has been unarchived`, tone: "success" });
}
},
onError: (_, archived) => {
pushToast({
title: archived ? "Failed to archive project" : "Failed to unarchive project",
tone: "error",
});
},
});
const uploadImage = useMutation({