Compare commits
35 commits
PAP-878-cr
...
pap-979-ru
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3d61a7561 | ||
|
|
d9005405b9 | ||
|
|
e3f07aad55 | ||
|
|
2fea39b814 | ||
|
|
0356040a29 | ||
|
|
caa7550e9f | ||
|
|
84d4c328f5 | ||
|
|
11f08ea5d5 | ||
|
|
1f1fe9c989 | ||
|
|
f1ad07616c | ||
|
|
868cfa8c50 | ||
|
|
6793dde597 | ||
|
|
cadfcd1bc6 | ||
|
|
c114ff4dc6 | ||
|
|
84e35b801c | ||
|
|
cbeefbfa5a | ||
|
|
2de691f023 | ||
|
|
41f2a80aa8 | ||
|
|
bb1732dd11 | ||
|
|
15e0e2ece9 | ||
|
|
b7b5d8dae3 | ||
|
|
0ff778ec29 | ||
|
|
b69f0b7dc4 | ||
|
|
b75ac76b13 | ||
|
|
6a72faf83b | ||
|
|
1fd40920db | ||
|
|
caef115b95 | ||
|
|
17e5322e28 | ||
|
|
582f4ceaf4 | ||
|
|
1583a2d65a | ||
|
|
9a70a4edaa | ||
|
|
0ac01a04e5 | ||
|
|
11ff24cd22 | ||
|
|
a5d47166e2 | ||
|
|
af5b980362 |
88 changed files with 7877 additions and 292 deletions
|
|
@ -39,6 +39,17 @@ This starts:
|
|||
|
||||
`pnpm dev` runs the server in watch mode and restarts on changes from workspace packages (including adapter packages). Use `pnpm dev:once` to run without file watching.
|
||||
|
||||
`pnpm dev:once` auto-applies pending local migrations by default before starting the dev server.
|
||||
|
||||
`pnpm dev` and `pnpm dev:once` are now idempotent for the current repo and instance: if the matching Paperclip dev runner is already alive, Paperclip reports the existing process instead of starting a duplicate.
|
||||
|
||||
Inspect or stop the current repo's managed dev runner:
|
||||
|
||||
```sh
|
||||
pnpm dev:list
|
||||
pnpm dev:stop
|
||||
```
|
||||
|
||||
`pnpm dev:once` now tracks backend-relevant file changes and pending migrations. When the current boot is stale, the board UI shows a `Restart required` banner. You can also enable guarded auto-restart in `Instance Settings > Experimental`, which waits for queued/running local agent runs to finish before restarting the dev server.
|
||||
|
||||
Tailscale/private-auth dev mode:
|
||||
|
|
|
|||
|
|
@ -20,9 +20,12 @@ When a heartbeat fires, Paperclip:
|
|||
|---------|----------|-------------|
|
||||
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
||||
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
|
||||
| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally |
|
||||
| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) |
|
||||
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
|
||||
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
|
||||
| Hermes Local | `hermes_local` | Runs Hermes CLI locally |
|
||||
| Cursor | `cursor` | Runs Cursor in background mode |
|
||||
| Pi Local | `pi_local` | Runs an embedded Pi agent locally |
|
||||
| OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint |
|
||||
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
||||
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
|
||||
|
||||
|
|
@ -55,7 +58,7 @@ Three registries consume these modules:
|
|||
|
||||
## Choosing an Adapter
|
||||
|
||||
- **Need a coding agent?** Use `claude_local`, `codex_local`, `gemini_local`, or `opencode_local`
|
||||
- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, or `hermes_local`
|
||||
- **Need to run a script or command?** Use `process`
|
||||
- **Need to call an external service?** Use `http`
|
||||
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@
|
|||
"guides/board-operator/managing-agents",
|
||||
"guides/board-operator/org-structure",
|
||||
"guides/board-operator/managing-tasks",
|
||||
"guides/board-operator/execution-workspaces-and-runtime-services",
|
||||
"guides/board-operator/delegation",
|
||||
"guides/board-operator/approvals",
|
||||
"guides/board-operator/costs-and-budgets",
|
||||
"guides/board-operator/activity-log",
|
||||
|
|
|
|||
122
docs/guides/board-operator/delegation.md
Normal file
122
docs/guides/board-operator/delegation.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
---
|
||||
title: How Delegation Works
|
||||
summary: How the CEO breaks down goals into tasks and assigns them to agents
|
||||
---
|
||||
|
||||
Delegation is one of Paperclip's most powerful features. You set company goals, and the CEO agent automatically breaks them into tasks and assigns them to the right agents. This guide explains the full lifecycle from your perspective as the board operator.
|
||||
|
||||
## The Delegation Lifecycle
|
||||
|
||||
When you create a company goal, the CEO doesn't just acknowledge it — it builds a plan and mobilizes the team:
|
||||
|
||||
```
|
||||
You set a company goal
|
||||
→ CEO wakes up on heartbeat
|
||||
→ CEO proposes a strategy (creates an approval for you)
|
||||
→ You approve the strategy
|
||||
→ CEO breaks goals into tasks and assigns them to reports
|
||||
→ Reports wake up (heartbeat triggered by assignment)
|
||||
→ Reports execute work and update task status
|
||||
→ CEO monitors progress, unblocks, and escalates
|
||||
→ You see results in the dashboard and activity log
|
||||
```
|
||||
|
||||
Each step is traceable. Every task links back to the goal through a parent hierarchy, so you can always see why work is happening.
|
||||
|
||||
## What You Need to Do
|
||||
|
||||
Your role is strategic oversight, not task management. Here's what the delegation model expects from you:
|
||||
|
||||
1. **Set clear company goals.** The CEO works from these. Specific, measurable goals produce better delegation. "Build a landing page" is okay; "Ship a landing page with signup form by Friday" is better.
|
||||
|
||||
2. **Approve the CEO's strategy.** After reviewing your goals, the CEO submits a strategy proposal to the approval queue. Review it, then approve, reject, or request revisions.
|
||||
|
||||
3. **Approve hire requests.** When the CEO needs more capacity (e.g., a frontend engineer to build the landing page), it submits a hire request. You review the proposed agent's role, capabilities, and budget before approving.
|
||||
|
||||
4. **Monitor progress.** Use the dashboard and activity log to track how work is flowing. Check task status, agent activity, and completion rates.
|
||||
|
||||
5. **Intervene only when things stall.** If progress stops, check these in order:
|
||||
- Is an approval pending in your queue?
|
||||
- Is an agent paused or in an error state?
|
||||
- Is the CEO's budget exhausted (above 80%, it focuses on critical tasks only)?
|
||||
|
||||
## What the CEO Does Automatically
|
||||
|
||||
You do **not** need to tell the CEO to engage specific agents. After you approve its strategy, the CEO:
|
||||
|
||||
- **Breaks goals into concrete tasks** with clear descriptions, priorities, and acceptance criteria
|
||||
- **Assigns tasks to the right agent** based on role and capabilities (e.g., engineering tasks go to the CTO or engineers, marketing tasks go to the CMO)
|
||||
- **Creates subtasks** when work needs to be decomposed further
|
||||
- **Hires new agents** when the team lacks capacity for a goal (subject to your approval)
|
||||
- **Monitors progress** on each heartbeat, checking task status and unblocking reports
|
||||
- **Escalates to you** when it encounters something it can't resolve — budget issues, blocked approvals, or strategic ambiguity
|
||||
|
||||
## Common Delegation Patterns
|
||||
|
||||
### Flat Hierarchy (Small Teams)
|
||||
|
||||
For small companies with 3-5 agents, the CEO delegates directly to each report:
|
||||
|
||||
```
|
||||
CEO
|
||||
├── CTO (engineering tasks)
|
||||
├── CMO (marketing tasks)
|
||||
└── Designer (design tasks)
|
||||
```
|
||||
|
||||
The CEO assigns tasks directly. Each agent works independently and reports status back.
|
||||
|
||||
### Three-Level Hierarchy (Larger Teams)
|
||||
|
||||
For larger organizations, managers delegate further down the chain:
|
||||
|
||||
```
|
||||
CEO
|
||||
├── CTO
|
||||
│ ├── Backend Engineer
|
||||
│ └── Frontend Engineer
|
||||
└── CMO
|
||||
└── Content Writer
|
||||
```
|
||||
|
||||
The CEO assigns high-level tasks to the CTO and CMO. They break those into subtasks and assign them to their own reports. You only interact with the CEO — the rest happens automatically.
|
||||
|
||||
### Hire-on-Demand
|
||||
|
||||
The CEO can start as the only agent and hire as work requires:
|
||||
|
||||
1. You set a goal that needs engineering work
|
||||
2. The CEO proposes a strategy that includes hiring a CTO
|
||||
3. You approve the hire
|
||||
4. The CEO assigns engineering tasks to the new CTO
|
||||
5. As scope grows, the CTO may request to hire engineers
|
||||
|
||||
This pattern lets you start small and scale the team based on actual work, not upfront planning.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Why isn't the CEO delegating?"
|
||||
|
||||
If you've set a goal but nothing is happening, check these common causes:
|
||||
|
||||
| Check | What to look for |
|
||||
|-------|-----------------|
|
||||
| **Approval queue** | The CEO may have submitted a strategy or hire request that's waiting for your approval. This is the most common reason. |
|
||||
| **Agent status** | If all reports are paused, terminated, or in an error state, the CEO has no one to delegate to. Check the Agents page. |
|
||||
| **Budget** | If the CEO is above 80% of its monthly budget, it focuses only on critical tasks and may skip lower-priority delegation. |
|
||||
| **Goals** | If no company goals are set, the CEO has nothing to work from. Create a goal first. |
|
||||
| **Heartbeat** | Is the CEO's heartbeat enabled and running? Check the agent detail page for recent heartbeat history. |
|
||||
| **Agent instructions** | The CEO's delegation behavior is driven by its `AGENTS.md` instructions file. Open the CEO agent's detail page and verify that its instructions path is set and that the file includes delegation directives (subtask creation, hiring, assignment). If AGENTS.md is missing or doesn't mention delegation, the CEO won't know to break down goals and assign work. |
|
||||
|
||||
### "Do I have to tell the CEO to engage engineering and marketing?"
|
||||
|
||||
**No.** The CEO will delegate automatically after you approve its strategy. It knows the org chart and assigns tasks based on each agent's role and capabilities. You set the goal and approve the plan — the CEO handles task breakdown and assignment.
|
||||
|
||||
### "A task seems stuck"
|
||||
|
||||
If a specific task isn't progressing:
|
||||
|
||||
1. Check the task's comment thread — the assigned agent may have posted a blocker
|
||||
2. Check if the task is in `blocked` status — read the blocker comment to understand why
|
||||
3. Check the assigned agent's status — it may be paused or over budget
|
||||
4. If the agent is stuck, you can reassign the task or add a comment with guidance
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
title: Execution Workspaces And Runtime Services
|
||||
summary: How project runtime configuration, execution workspaces, and issue runs fit together
|
||||
---
|
||||
|
||||
This guide documents the intended runtime model for projects, execution workspaces, and issue runs in Paperclip.
|
||||
|
||||
## Project runtime configuration
|
||||
|
||||
You can define how to run a project on the project workspace itself.
|
||||
|
||||
- Project workspace runtime config describes how to run services for that project checkout.
|
||||
- This is the default runtime configuration that child execution workspaces may inherit.
|
||||
- Defining the config does not start anything by itself.
|
||||
|
||||
## Manual runtime control
|
||||
|
||||
Runtime services are manually controlled from the UI.
|
||||
|
||||
- Project workspace runtime services are started and stopped from the project workspace UI.
|
||||
- Execution workspace runtime services are started and stopped from the execution workspace UI.
|
||||
- Paperclip does not automatically start or stop these runtime services as part of issue execution.
|
||||
- Paperclip also does not automatically restart workspace runtime services on server boot.
|
||||
|
||||
## Execution workspace inheritance
|
||||
|
||||
Execution workspaces isolate code and runtime state from the project primary workspace.
|
||||
|
||||
- An isolated execution workspace has its own checkout path, branch, and local runtime instance.
|
||||
- The runtime configuration may inherit from the linked project workspace by default.
|
||||
- The execution workspace may override that runtime configuration with its own workspace-specific settings.
|
||||
- The inherited configuration answers "how to run the service", but the running process is still specific to that execution workspace.
|
||||
|
||||
## Issues and execution workspaces
|
||||
|
||||
Issues are attached to execution workspace behavior, not to automatic runtime management.
|
||||
|
||||
- An issue may create a new execution workspace when you choose an isolated workspace mode.
|
||||
- An issue may reuse an existing execution workspace when you choose reuse.
|
||||
- Multiple issues may intentionally share one execution workspace so they can work against the same branch and running runtime services.
|
||||
- Assigning or running an issue does not automatically start or stop runtime services for that workspace.
|
||||
|
||||
## Execution workspace lifecycle
|
||||
|
||||
Execution workspaces are durable until a human closes them.
|
||||
|
||||
- The UI can archive an execution workspace.
|
||||
- Closing an execution workspace stops its runtime services and cleans up its workspace artifacts when allowed.
|
||||
- Shared workspaces that point at the project primary checkout are treated more conservatively during cleanup than disposable isolated workspaces.
|
||||
|
||||
## Resolved workspace logic during heartbeat runs
|
||||
|
||||
Heartbeat still resolves a workspace for the run, but that is about code location and session continuity, not runtime-service control.
|
||||
|
||||
1. Heartbeat resolves a base workspace for the run.
|
||||
2. Paperclip realizes the effective execution workspace, including creating or reusing a worktree when needed.
|
||||
3. Paperclip persists execution-workspace metadata such as paths, refs, and provisioning settings.
|
||||
4. Heartbeat passes the resolved code workspace to the agent run.
|
||||
5. Workspace runtime services remain manual UI-managed controls rather than automatic heartbeat-managed services.
|
||||
|
||||
## Current implementation guarantees
|
||||
|
||||
With the current implementation:
|
||||
|
||||
- Project workspace runtime config is the fallback for execution workspace UI controls.
|
||||
- Execution workspace runtime overrides are stored on the execution workspace.
|
||||
- Heartbeat runs do not auto-start workspace runtime services.
|
||||
- Server startup does not auto-restart workspace runtime services.
|
||||
|
|
@ -29,7 +29,7 @@ Create agents from the Agents page. Each agent requires:
|
|||
|
||||
Common adapter choices:
|
||||
- `claude_local` / `codex_local` / `opencode_local` for local coding agents
|
||||
- `openclaw` / `http` for webhook-based external agents
|
||||
- `openclaw_gateway` / `http` for webhook-based external agents
|
||||
- `process` for generic local command execution
|
||||
|
||||
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
---
|
||||
title: Core Concepts
|
||||
summary: Companies, agents, issues, heartbeats, and governance
|
||||
summary: Companies, agents, issues, delegation, heartbeats, and governance
|
||||
---
|
||||
|
||||
Paperclip organizes autonomous AI work around five key concepts.
|
||||
Paperclip organizes autonomous AI work around six key concepts.
|
||||
|
||||
## Company
|
||||
|
||||
|
|
@ -50,6 +50,17 @@ Terminal states: `done`, `cancelled`.
|
|||
|
||||
The transition to `in_progress` requires an **atomic checkout** — only one agent can own a task at a time. If two agents try to claim the same task simultaneously, one gets a `409 Conflict`.
|
||||
|
||||
## Delegation
|
||||
|
||||
The CEO is the primary delegator. When you set company goals, the CEO:
|
||||
|
||||
1. Creates a strategy and submits it for your approval
|
||||
2. Breaks approved goals into tasks
|
||||
3. Assigns tasks to agents based on their role and capabilities
|
||||
4. Hires new agents when needed (subject to your approval)
|
||||
|
||||
You don't need to manually assign every task — set the goals and let the CEO organize the work. You approve key decisions (strategy, hiring) and monitor progress. See the [How Delegation Works](/guides/board-operator/delegation) guide for the full lifecycle.
|
||||
|
||||
## Heartbeats
|
||||
|
||||
Agents don't run continuously. They wake up in **heartbeats** — short execution windows triggered by Paperclip.
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-runner.mjs watch",
|
||||
"dev:watch": "node scripts/dev-runner.mjs watch",
|
||||
"dev:once": "node scripts/dev-runner.mjs dev",
|
||||
"dev": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch",
|
||||
"dev:watch": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch",
|
||||
"dev:once": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts dev",
|
||||
"dev:list": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts list",
|
||||
"dev:stop": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts stop",
|
||||
"dev:server": "pnpm --filter @paperclipai/server dev",
|
||||
"dev:ui": "pnpm --filter @paperclipai/ui dev",
|
||||
"build": "pnpm -r build",
|
||||
|
|
|
|||
|
|
@ -201,6 +201,33 @@ export function redactEnvForLogs(env: Record<string, string>): Record<string, st
|
|||
return redacted;
|
||||
}
|
||||
|
||||
export function buildInvocationEnvForLogs(
|
||||
env: Record<string, string>,
|
||||
options: {
|
||||
runtimeEnv?: NodeJS.ProcessEnv | Record<string, string>;
|
||||
includeRuntimeKeys?: string[];
|
||||
resolvedCommand?: string | null;
|
||||
resolvedCommandEnvKey?: string;
|
||||
} = {},
|
||||
): Record<string, string> {
|
||||
const merged: Record<string, string> = { ...env };
|
||||
const runtimeEnv = options.runtimeEnv ?? {};
|
||||
|
||||
for (const key of options.includeRuntimeKeys ?? []) {
|
||||
if (key in merged) continue;
|
||||
const value = runtimeEnv[key];
|
||||
if (typeof value !== "string" || value.length === 0) continue;
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
const resolvedCommand = options.resolvedCommand?.trim();
|
||||
if (resolvedCommand) {
|
||||
merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = resolvedCommand;
|
||||
}
|
||||
|
||||
return redactEnvForLogs(merged);
|
||||
}
|
||||
|
||||
export function buildPaperclipEnv(agent: { id: string; companyId: string }): Record<string, string> {
|
||||
const resolveHostForUrl = (rawHost: string): string => {
|
||||
const host = rawHost.trim();
|
||||
|
|
@ -269,6 +296,10 @@ async function resolveCommandPath(command: string, cwd: string, env: NodeJS.Proc
|
|||
return null;
|
||||
}
|
||||
|
||||
export async function resolveCommandForLogs(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise<string> {
|
||||
return (await resolveCommandPath(command, cwd, env)) ?? command;
|
||||
}
|
||||
|
||||
function quoteForCmd(arg: string) {
|
||||
if (!arg.length) return '""';
|
||||
const escaped = arg.replace(/"/g, '""');
|
||||
|
|
|
|||
|
|
@ -287,6 +287,12 @@ export interface ServerAdapterModule {
|
|||
* without knowing provider-specific credential paths or API shapes.
|
||||
*/
|
||||
getQuotaWindows?: () => Promise<ProviderQuotaResult>;
|
||||
/**
|
||||
* Optional: detect the currently configured model from local config files.
|
||||
* Returns the detected model/provider and the config source, or null if
|
||||
* the adapter does not support detection or no config is found.
|
||||
*/
|
||||
detectModel?: () => Promise<{ model: string; provider: string; source: string } | null>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ Core fields:
|
|||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env
|
||||
- workspaceRuntime (object, optional): reserved for workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@ import {
|
|||
buildPaperclipEnv,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
joinPromptSections,
|
||||
redactEnvForLogs,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
resolveCommandForLogs,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
|
@ -68,11 +69,13 @@ interface ClaudeExecutionInput {
|
|||
|
||||
interface ClaudeRuntimeConfig {
|
||||
command: string;
|
||||
resolvedCommand: string;
|
||||
cwd: string;
|
||||
workspaceId: string | null;
|
||||
workspaceRepoUrl: string | null;
|
||||
workspaceRepoRef: string | null;
|
||||
env: Record<string, string>;
|
||||
loggedEnv: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
extraArgs: string[];
|
||||
|
|
@ -236,6 +239,12 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
|||
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
|
||||
resolvedCommand,
|
||||
});
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
|
|
@ -247,11 +256,13 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
|||
|
||||
return {
|
||||
command,
|
||||
resolvedCommand,
|
||||
cwd,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
env,
|
||||
loggedEnv,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
extraArgs,
|
||||
|
|
@ -324,11 +335,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
});
|
||||
const {
|
||||
command,
|
||||
resolvedCommand,
|
||||
cwd,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
env,
|
||||
loggedEnv,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
extraArgs,
|
||||
|
|
@ -440,11 +453,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "claude_local",
|
||||
command,
|
||||
command: resolvedCommand,
|
||||
cwd,
|
||||
commandArgs: args,
|
||||
commandNotes,
|
||||
env: redactEnvForLogs(env),
|
||||
env: loggedEnv,
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ Core fields:
|
|||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env
|
||||
- workspaceRuntime (object, optional): reserved for workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import {
|
|||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
redactEnvForLogs,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePaperclipSkillSymlink,
|
||||
ensurePathInEnv,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolveCommandForLogs,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
renderTemplate,
|
||||
joinPromptSections,
|
||||
|
|
@ -383,6 +384,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const billingType = resolveCodexBillingType(effectiveEnv);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
});
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
|
|
@ -490,14 +497,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "codex_local",
|
||||
command,
|
||||
command: resolvedCommand,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args.map((value, idx) => {
|
||||
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
|
||||
return value;
|
||||
}),
|
||||
env: redactEnvForLogs(env),
|
||||
env: loggedEnv,
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import {
|
|||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
redactEnvForLogs,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePaperclipSkillSymlink,
|
||||
ensurePathInEnv,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolveCommandForLogs,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
|
|
@ -271,6 +272,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const billingType = resolveCursorBillingType(effectiveEnv);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
});
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
|
|
@ -383,11 +390,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "cursor",
|
||||
command,
|
||||
command: resolvedCommand,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args,
|
||||
env: redactEnvForLogs(env),
|
||||
env: loggedEnv,
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
|
|
|
|||
|
|
@ -10,16 +10,17 @@ import {
|
|||
asString,
|
||||
asStringArray,
|
||||
buildPaperclipEnv,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePaperclipSkillSymlink,
|
||||
joinPromptSections,
|
||||
ensurePathInEnv,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolveCommandForLogs,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
parseObject,
|
||||
redactEnvForLogs,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
|
@ -220,6 +221,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const billingType = resolveGeminiBillingType(effectiveEnv);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
});
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
|
|
@ -333,13 +340,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "gemini_local",
|
||||
command,
|
||||
command: resolvedCommand,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args.map((value, index) => (
|
||||
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
|
||||
)),
|
||||
env: redactEnvForLogs(env),
|
||||
env: loggedEnv,
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ Gateway connect identity fields:
|
|||
|
||||
Request behavior fields:
|
||||
- payloadTemplate (object, optional): additional fields merged into gateway agent params
|
||||
- workspaceRuntime (object, optional): desired runtime service intents; Paperclip forwards these in a standardized paperclip.workspaceRuntime block for remote execution environments
|
||||
- workspaceRuntime (object, optional): reserved workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats
|
||||
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
|
||||
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
|
||||
- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true)
|
||||
|
|
@ -45,7 +45,7 @@ Standard outbound payload additions:
|
|||
- paperclip (object): standardized Paperclip context added to every gateway agent request
|
||||
- paperclip.workspace (object, optional): resolved execution workspace for this run
|
||||
- paperclip.workspaces (array, optional): additional workspace hints Paperclip exposed to the run
|
||||
- paperclip.workspaceRuntime (object, optional): normalized runtime service intent config for the workspace
|
||||
- paperclip.workspaceRuntime (object, optional): reserved workspace runtime metadata when explicitly supplied outside normal heartbeat execution
|
||||
|
||||
Standard result metadata supported:
|
||||
- meta.runtimeServices (array, optional): normalized adapter-managed runtime service reports
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ import {
|
|||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
joinPromptSections,
|
||||
redactEnvForLogs,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePaperclipSkillSymlink,
|
||||
ensurePathInEnv,
|
||||
resolveCommandForLogs,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
|
|
@ -186,6 +187,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
),
|
||||
);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
});
|
||||
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model,
|
||||
|
|
@ -298,11 +305,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "opencode_local",
|
||||
command,
|
||||
command: resolvedCommand,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
||||
env: redactEnvForLogs(preparedRuntimeConfig.env),
|
||||
env: loggedEnv,
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@ import {
|
|||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
joinPromptSections,
|
||||
redactEnvForLogs,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePaperclipSkillSymlink,
|
||||
ensurePathInEnv,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolveCommandForLogs,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
|
|
@ -204,6 +205,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
),
|
||||
);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
});
|
||||
|
||||
// Validate model is available before execution
|
||||
await ensurePiModelConfiguredAndAvailable({
|
||||
|
|
@ -356,11 +363,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "pi_local",
|
||||
command,
|
||||
command: resolvedCommand,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args,
|
||||
env: redactEnvForLogs(env),
|
||||
env: loggedEnv,
|
||||
prompt: userPrompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
|
|
|
|||
|
|
@ -186,10 +186,19 @@ export type {
|
|||
ProjectGoalRef,
|
||||
ProjectWorkspace,
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceConfig,
|
||||
ExecutionWorkspaceCloseAction,
|
||||
ExecutionWorkspaceCloseActionKind,
|
||||
ExecutionWorkspaceCloseGitReadiness,
|
||||
ExecutionWorkspaceCloseLinkedIssue,
|
||||
ExecutionWorkspaceCloseReadiness,
|
||||
ExecutionWorkspaceCloseReadinessState,
|
||||
ProjectWorkspaceRuntimeConfig,
|
||||
WorkspaceRuntimeService,
|
||||
WorkspaceOperation,
|
||||
WorkspaceOperationPhase,
|
||||
WorkspaceOperationStatus,
|
||||
WorkspaceRuntimeDesiredState,
|
||||
ExecutionWorkspaceStrategyType,
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceProviderType,
|
||||
|
|
@ -384,6 +393,12 @@ export {
|
|||
issueWorkProductReviewStateSchema,
|
||||
updateExecutionWorkspaceSchema,
|
||||
executionWorkspaceStatusSchema,
|
||||
executionWorkspaceCloseActionKindSchema,
|
||||
executionWorkspaceCloseActionSchema,
|
||||
executionWorkspaceCloseGitReadinessSchema,
|
||||
executionWorkspaceCloseLinkedIssueSchema,
|
||||
executionWorkspaceCloseReadinessSchema,
|
||||
executionWorkspaceCloseReadinessStateSchema,
|
||||
issueDocumentFormatSchema,
|
||||
issueDocumentKeySchema,
|
||||
upsertIssueDocumentSchema,
|
||||
|
|
|
|||
|
|
@ -50,7 +50,16 @@ export type { AssetImage } from "./asset.js";
|
|||
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js";
|
||||
export type {
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceConfig,
|
||||
ExecutionWorkspaceCloseAction,
|
||||
ExecutionWorkspaceCloseActionKind,
|
||||
ExecutionWorkspaceCloseGitReadiness,
|
||||
ExecutionWorkspaceCloseLinkedIssue,
|
||||
ExecutionWorkspaceCloseReadiness,
|
||||
ExecutionWorkspaceCloseReadinessState,
|
||||
ProjectWorkspaceRuntimeConfig,
|
||||
WorkspaceRuntimeService,
|
||||
WorkspaceRuntimeDesiredState,
|
||||
ExecutionWorkspaceStrategyType,
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceProviderType,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import type { PauseReason, ProjectStatus } from "../constants.js";
|
||||
import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js";
|
||||
import type {
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
ProjectWorkspaceRuntimeConfig,
|
||||
WorkspaceRuntimeService,
|
||||
} from "./workspace-runtime.js";
|
||||
|
||||
export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path";
|
||||
export type ProjectWorkspaceVisibility = "default" | "advanced";
|
||||
|
|
@ -26,6 +30,7 @@ export interface ProjectWorkspace {
|
|||
remoteWorkspaceRef: string | null;
|
||||
sharedWorkspaceKey: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
runtimeConfig: ProjectWorkspaceRuntimeConfig | null;
|
||||
isPrimary: boolean;
|
||||
runtimeServices?: WorkspaceRuntimeService[];
|
||||
createdAt: Date;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,22 @@ export type ExecutionWorkspaceStatus =
|
|||
| "archived"
|
||||
| "cleanup_failed";
|
||||
|
||||
export type ExecutionWorkspaceCloseReadinessState =
|
||||
| "ready"
|
||||
| "ready_with_warnings"
|
||||
| "blocked";
|
||||
|
||||
export type ExecutionWorkspaceCloseActionKind =
|
||||
| "archive_record"
|
||||
| "stop_runtime_services"
|
||||
| "cleanup_command"
|
||||
| "teardown_command"
|
||||
| "git_worktree_remove"
|
||||
| "git_branch_delete"
|
||||
| "remove_local_directory";
|
||||
|
||||
export type WorkspaceRuntimeDesiredState = "running" | "stopped";
|
||||
|
||||
export interface ExecutionWorkspaceStrategy {
|
||||
type: ExecutionWorkspaceStrategyType;
|
||||
baseRef?: string | null;
|
||||
|
|
@ -40,6 +56,63 @@ export interface ExecutionWorkspaceStrategy {
|
|||
teardownCommand?: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceConfig {
|
||||
provisionCommand: string | null;
|
||||
teardownCommand: string | null;
|
||||
cleanupCommand: string | null;
|
||||
workspaceRuntime: Record<string, unknown> | null;
|
||||
desiredState: WorkspaceRuntimeDesiredState | null;
|
||||
}
|
||||
|
||||
export interface ProjectWorkspaceRuntimeConfig {
|
||||
workspaceRuntime: Record<string, unknown> | null;
|
||||
desiredState: WorkspaceRuntimeDesiredState | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceCloseAction {
|
||||
kind: ExecutionWorkspaceCloseActionKind;
|
||||
label: string;
|
||||
description: string;
|
||||
command: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceCloseLinkedIssue {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
isTerminal: boolean;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceCloseGitReadiness {
|
||||
repoRoot: string | null;
|
||||
workspacePath: string | null;
|
||||
branchName: string | null;
|
||||
baseRef: string | null;
|
||||
hasDirtyTrackedFiles: boolean;
|
||||
hasUntrackedFiles: boolean;
|
||||
dirtyEntryCount: number;
|
||||
untrackedEntryCount: number;
|
||||
aheadCount: number | null;
|
||||
behindCount: number | null;
|
||||
isMergedIntoBase: boolean | null;
|
||||
createdByRuntime: boolean;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceCloseReadiness {
|
||||
workspaceId: string;
|
||||
state: ExecutionWorkspaceCloseReadinessState;
|
||||
blockingReasons: string[];
|
||||
warnings: string[];
|
||||
linkedIssues: ExecutionWorkspaceCloseLinkedIssue[];
|
||||
plannedActions: ExecutionWorkspaceCloseAction[];
|
||||
isDestructiveCloseAllowed: boolean;
|
||||
isSharedWorkspace: boolean;
|
||||
isProjectPrimaryWorkspace: boolean;
|
||||
git: ExecutionWorkspaceCloseGitReadiness | null;
|
||||
runtimeServices: WorkspaceRuntimeService[];
|
||||
}
|
||||
|
||||
export interface ProjectExecutionWorkspacePolicy {
|
||||
enabled: boolean;
|
||||
defaultMode?: ProjectExecutionWorkspaceDefaultMode;
|
||||
|
|
@ -81,7 +154,9 @@ export interface ExecutionWorkspace {
|
|||
closedAt: Date | null;
|
||||
cleanupEligibleAt: Date | null;
|
||||
cleanupReason: string | null;
|
||||
config: ExecutionWorkspaceConfig | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
runtimeServices?: WorkspaceRuntimeService[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,113 @@ export const executionWorkspaceStatusSchema = z.enum([
|
|||
"cleanup_failed",
|
||||
]);
|
||||
|
||||
export const executionWorkspaceConfigSchema = z.object({
|
||||
provisionCommand: z.string().optional().nullable(),
|
||||
teardownCommand: z.string().optional().nullable(),
|
||||
cleanupCommand: z.string().optional().nullable(),
|
||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||
desiredState: z.enum(["running", "stopped"]).optional().nullable(),
|
||||
}).strict();
|
||||
|
||||
export const executionWorkspaceCloseReadinessStateSchema = z.enum([
|
||||
"ready",
|
||||
"ready_with_warnings",
|
||||
"blocked",
|
||||
]);
|
||||
|
||||
export const executionWorkspaceCloseActionKindSchema = z.enum([
|
||||
"archive_record",
|
||||
"stop_runtime_services",
|
||||
"cleanup_command",
|
||||
"teardown_command",
|
||||
"git_worktree_remove",
|
||||
"git_branch_delete",
|
||||
"remove_local_directory",
|
||||
]);
|
||||
|
||||
export const executionWorkspaceCloseActionSchema = z.object({
|
||||
kind: executionWorkspaceCloseActionKindSchema,
|
||||
label: z.string(),
|
||||
description: z.string(),
|
||||
command: z.string().nullable(),
|
||||
}).strict();
|
||||
|
||||
export const executionWorkspaceCloseLinkedIssueSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
identifier: z.string().nullable(),
|
||||
title: z.string(),
|
||||
status: z.string(),
|
||||
isTerminal: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
export const executionWorkspaceCloseGitReadinessSchema = z.object({
|
||||
repoRoot: z.string().nullable(),
|
||||
workspacePath: z.string().nullable(),
|
||||
branchName: z.string().nullable(),
|
||||
baseRef: z.string().nullable(),
|
||||
hasDirtyTrackedFiles: z.boolean(),
|
||||
hasUntrackedFiles: z.boolean(),
|
||||
dirtyEntryCount: z.number().int().nonnegative(),
|
||||
untrackedEntryCount: z.number().int().nonnegative(),
|
||||
aheadCount: z.number().int().nonnegative().nullable(),
|
||||
behindCount: z.number().int().nonnegative().nullable(),
|
||||
isMergedIntoBase: z.boolean().nullable(),
|
||||
createdByRuntime: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
export const executionWorkspaceCloseReadinessSchema = z.object({
|
||||
workspaceId: z.string().uuid(),
|
||||
state: executionWorkspaceCloseReadinessStateSchema,
|
||||
blockingReasons: z.array(z.string()),
|
||||
warnings: z.array(z.string()),
|
||||
linkedIssues: z.array(executionWorkspaceCloseLinkedIssueSchema),
|
||||
plannedActions: z.array(executionWorkspaceCloseActionSchema),
|
||||
isDestructiveCloseAllowed: z.boolean(),
|
||||
isSharedWorkspace: z.boolean(),
|
||||
isProjectPrimaryWorkspace: z.boolean(),
|
||||
git: executionWorkspaceCloseGitReadinessSchema.nullable(),
|
||||
runtimeServices: z.array(z.object({
|
||||
id: z.string(),
|
||||
companyId: z.string().uuid(),
|
||||
projectId: z.string().uuid().nullable(),
|
||||
projectWorkspaceId: z.string().uuid().nullable(),
|
||||
executionWorkspaceId: z.string().uuid().nullable(),
|
||||
issueId: z.string().uuid().nullable(),
|
||||
scopeType: z.enum(["project_workspace", "execution_workspace", "run", "agent"]),
|
||||
scopeId: z.string().nullable(),
|
||||
serviceName: z.string(),
|
||||
status: z.enum(["starting", "running", "stopped", "failed"]),
|
||||
lifecycle: z.enum(["shared", "ephemeral"]),
|
||||
reuseKey: z.string().nullable(),
|
||||
command: z.string().nullable(),
|
||||
cwd: z.string().nullable(),
|
||||
port: z.number().int().nullable(),
|
||||
url: z.string().nullable(),
|
||||
provider: z.enum(["local_process", "adapter_managed"]),
|
||||
providerRef: z.string().nullable(),
|
||||
ownerAgentId: z.string().uuid().nullable(),
|
||||
startedByRunId: z.string().uuid().nullable(),
|
||||
lastUsedAt: z.coerce.date(),
|
||||
startedAt: z.coerce.date(),
|
||||
stoppedAt: z.coerce.date().nullable(),
|
||||
stopPolicy: z.record(z.unknown()).nullable(),
|
||||
healthStatus: z.enum(["unknown", "healthy", "unhealthy"]),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
}).strict()),
|
||||
}).strict();
|
||||
|
||||
export const updateExecutionWorkspaceSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
cwd: z.string().optional().nullable(),
|
||||
repoUrl: z.string().optional().nullable(),
|
||||
baseRef: z.string().optional().nullable(),
|
||||
branchName: z.string().optional().nullable(),
|
||||
providerRef: z.string().optional().nullable(),
|
||||
status: executionWorkspaceStatusSchema.optional(),
|
||||
cleanupEligibleAt: z.string().datetime().optional().nullable(),
|
||||
cleanupReason: z.string().optional().nullable(),
|
||||
config: executionWorkspaceConfigSchema.optional().nullable(),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
}).strict();
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ export {
|
|||
createProjectWorkspaceSchema,
|
||||
updateProjectWorkspaceSchema,
|
||||
projectExecutionWorkspacePolicySchema,
|
||||
projectWorkspaceRuntimeConfigSchema,
|
||||
type CreateProject,
|
||||
type UpdateProject,
|
||||
type CreateProjectWorkspace,
|
||||
|
|
@ -151,8 +152,15 @@ export {
|
|||
} from "./work-product.js";
|
||||
|
||||
export {
|
||||
executionWorkspaceConfigSchema,
|
||||
updateExecutionWorkspaceSchema,
|
||||
executionWorkspaceStatusSchema,
|
||||
executionWorkspaceCloseActionKindSchema,
|
||||
executionWorkspaceCloseActionSchema,
|
||||
executionWorkspaceCloseGitReadinessSchema,
|
||||
executionWorkspaceCloseLinkedIssueSchema,
|
||||
executionWorkspaceCloseReadinessSchema,
|
||||
executionWorkspaceCloseReadinessStateSchema,
|
||||
type UpdateExecutionWorkspace,
|
||||
} from "./execution-workspace.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ export const projectExecutionWorkspacePolicySchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
export const projectWorkspaceRuntimeConfigSchema = z.object({
|
||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||
desiredState: z.enum(["running", "stopped"]).optional().nullable(),
|
||||
}).strict();
|
||||
|
||||
const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]);
|
||||
const projectWorkspaceVisibilitySchema = z.enum(["default", "advanced"]);
|
||||
|
||||
|
|
@ -44,6 +49,7 @@ const projectWorkspaceFields = {
|
|||
remoteWorkspaceRef: z.string().optional().nullable(),
|
||||
sharedWorkspaceKey: z.string().optional().nullable(),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
runtimeConfig: projectWorkspaceRuntimeConfigSchema.optional().nullable(),
|
||||
};
|
||||
|
||||
function validateProjectWorkspace(value: Record<string, unknown>, ctx: z.RefinementCtx) {
|
||||
|
|
|
|||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
|
|
@ -504,8 +504,8 @@ importers:
|
|||
specifier: ^5.1.0
|
||||
version: 5.2.1
|
||||
hermes-paperclip-adapter:
|
||||
specifier: 0.1.1
|
||||
version: 0.1.1
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
jsdom:
|
||||
specifier: ^28.1.0
|
||||
version: 28.1.0(@noble/hashes@2.0.1)
|
||||
|
|
@ -639,6 +639,9 @@ importers:
|
|||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
hermes-paperclip-adapter:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
lexical:
|
||||
specifier: 0.35.0
|
||||
version: 0.35.0
|
||||
|
|
@ -2040,8 +2043,8 @@ packages:
|
|||
'@open-draft/deferred-promise@2.2.0':
|
||||
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
|
||||
|
||||
'@paperclipai/adapter-utils@0.3.1':
|
||||
resolution: {integrity: sha512-W66k+hJkQE8ma0asM/Sd90AC8HHy/BLG/sd0aOC+rDWw+gOasQyUkTnDoPv1zhQuTyKEEvLFV6ByOOKqEiAz/A==}
|
||||
'@paperclipai/adapter-utils@2026.325.0':
|
||||
resolution: {integrity: sha512-YDVSAgjkeJ0PvxXDJVN9MZDX7oYRzidLtGHmGgRGd6gSk/bF2ygAKvND4FI1YxDc/cRLQjqAFCpCYaC/9wqIEA==}
|
||||
|
||||
'@paralleldrive/cuid2@2.3.1':
|
||||
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
||||
|
|
@ -4468,8 +4471,8 @@ packages:
|
|||
help-me@5.0.0:
|
||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||
|
||||
hermes-paperclip-adapter@0.1.1:
|
||||
resolution: {integrity: sha512-kbdX349VxExSkVL8n4RwTpP9fUBf2yWpsTsJp02X12A9NynRJatlpYqt0vEkFyE/X7qEXqdJvpBm9tlvUHahsA==}
|
||||
hermes-paperclip-adapter@0.2.0:
|
||||
resolution: {integrity: sha512-6CP5vxfvY4jY9XJK5zu4ZUL9aB7HHNtEMk6q7m1Pu9Gzoby1Vx5VNmVqte3NUO+1cvVK9Arj1f67xLagWkbo5Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
|
|
@ -7740,7 +7743,7 @@ snapshots:
|
|||
|
||||
'@open-draft/deferred-promise@2.2.0': {}
|
||||
|
||||
'@paperclipai/adapter-utils@0.3.1': {}
|
||||
'@paperclipai/adapter-utils@2026.325.0': {}
|
||||
|
||||
'@paralleldrive/cuid2@2.3.1':
|
||||
dependencies:
|
||||
|
|
@ -10337,9 +10340,9 @@ snapshots:
|
|||
|
||||
help-me@5.0.0: {}
|
||||
|
||||
hermes-paperclip-adapter@0.1.1:
|
||||
hermes-paperclip-adapter@0.2.0:
|
||||
dependencies:
|
||||
'@paperclipai/adapter-utils': 0.3.1
|
||||
'@paperclipai/adapter-utils': 2026.325.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1):
|
||||
|
|
|
|||
656
scripts/dev-runner.ts
Normal file
656
scripts/dev-runner.ts
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
#!/usr/bin/env -S node --import tsx
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
|
||||
import {
|
||||
findAdoptableLocalService,
|
||||
removeLocalServiceRegistryRecord,
|
||||
touchLocalServiceRegistryRecord,
|
||||
writeLocalServiceRegistryRecord,
|
||||
} from "../server/src/services/local-service-supervisor.ts";
|
||||
|
||||
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
||||
const cliArgs = process.argv.slice(3);
|
||||
const scanIntervalMs = 1500;
|
||||
const autoRestartPollIntervalMs = 2500;
|
||||
const gracefulShutdownTimeoutMs = 10_000;
|
||||
const changedPathSampleLimit = 5;
|
||||
const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json");
|
||||
|
||||
const watchedDirectories = [
|
||||
"cli",
|
||||
"scripts",
|
||||
"server",
|
||||
"packages/adapter-utils",
|
||||
"packages/adapters",
|
||||
"packages/db",
|
||||
"packages/plugins/sdk",
|
||||
"packages/shared",
|
||||
].map((relativePath) => path.join(repoRoot, relativePath));
|
||||
|
||||
const watchedFiles = [
|
||||
".env",
|
||||
"package.json",
|
||||
"pnpm-workspace.yaml",
|
||||
"tsconfig.base.json",
|
||||
"tsconfig.json",
|
||||
"vitest.config.ts",
|
||||
].map((relativePath) => path.join(repoRoot, relativePath));
|
||||
|
||||
const ignoredDirectoryNames = new Set([
|
||||
".git",
|
||||
".turbo",
|
||||
".vite",
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"ui-dist",
|
||||
]);
|
||||
|
||||
const ignoredRelativePaths = new Set([
|
||||
".paperclip/dev-server-status.json",
|
||||
]);
|
||||
|
||||
const tailscaleAuthFlagNames = new Set([
|
||||
"--tailscale-auth",
|
||||
"--authenticated-private",
|
||||
]);
|
||||
|
||||
let tailscaleAuth = false;
|
||||
const forwardedArgs: string[] = [];
|
||||
|
||||
for (const arg of cliArgs) {
|
||||
if (tailscaleAuthFlagNames.has(arg)) {
|
||||
tailscaleAuth = true;
|
||||
continue;
|
||||
}
|
||||
forwardedArgs.push(arg);
|
||||
}
|
||||
|
||||
if (process.env.npm_config_tailscale_auth === "true") {
|
||||
tailscaleAuth = true;
|
||||
}
|
||||
if (process.env.npm_config_authenticated_private === "true") {
|
||||
tailscaleAuth = true;
|
||||
}
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
PAPERCLIP_UI_DEV_MIDDLEWARE: "true",
|
||||
};
|
||||
|
||||
if (mode === "dev") {
|
||||
env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath;
|
||||
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
|
||||
}
|
||||
|
||||
if (mode === "watch") {
|
||||
env.PAPERCLIP_MIGRATION_PROMPT ??= "never";
|
||||
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
|
||||
}
|
||||
|
||||
if (tailscaleAuth) {
|
||||
env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
|
||||
env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private";
|
||||
env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto";
|
||||
env.HOST = "0.0.0.0";
|
||||
console.log("[paperclip] dev mode: authenticated/private (tailscale-friendly) on 0.0.0.0");
|
||||
} else {
|
||||
console.log("[paperclip] dev mode: local_trusted (default)");
|
||||
}
|
||||
|
||||
const serverPort = Number.parseInt(env.PORT ?? process.env.PORT ?? "3100", 10) || 3100;
|
||||
const devService = createDevServiceIdentity({
|
||||
mode,
|
||||
forwardedArgs,
|
||||
tailscaleAuth,
|
||||
port: serverPort,
|
||||
});
|
||||
|
||||
const existingRunner = await findAdoptableLocalService({
|
||||
serviceKey: devService.serviceKey,
|
||||
cwd: repoRoot,
|
||||
envFingerprint: devService.envFingerprint,
|
||||
port: serverPort,
|
||||
});
|
||||
if (existingRunner) {
|
||||
console.log(
|
||||
`[paperclip] ${devService.serviceName} already running (pid ${existingRunner.pid}${typeof existingRunner.metadata?.childPid === "number" ? `, child ${existingRunner.metadata.childPid}` : ""})`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
let previousSnapshot = collectWatchedSnapshot();
|
||||
let dirtyPaths = new Set<string>();
|
||||
let pendingMigrations: string[] = [];
|
||||
let lastChangedAt: string | null = null;
|
||||
let lastRestartAt: string | null = null;
|
||||
let scanInFlight = false;
|
||||
let restartInFlight = false;
|
||||
let shuttingDown = false;
|
||||
let childExitWasExpected = false;
|
||||
let child: ReturnType<typeof spawn> | null = null;
|
||||
let childExitPromise: Promise<{ code: number; signal: NodeJS.Signals | null }> | null = null;
|
||||
let scanTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let autoRestartTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function toError(error: unknown, context = "Dev runner command failed") {
|
||||
if (error instanceof Error) return error;
|
||||
if (error === undefined) return new Error(context);
|
||||
if (typeof error === "string") return new Error(`${context}: ${error}`);
|
||||
|
||||
try {
|
||||
return new Error(`${context}: ${JSON.stringify(error)}`);
|
||||
} catch {
|
||||
return new Error(`${context}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.on("uncaughtException", async (error) => {
|
||||
await removeLocalServiceRegistryRecord(devService.serviceKey);
|
||||
const err = toError(error, "Uncaught exception in dev runner");
|
||||
process.stderr.write(`${err.stack ?? err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", async (reason) => {
|
||||
await removeLocalServiceRegistryRecord(devService.serviceKey);
|
||||
const err = toError(reason, "Unhandled promise rejection in dev runner");
|
||||
process.stderr.write(`${err.stack ?? err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
function formatPendingMigrationSummary(migrations: string[]) {
|
||||
if (migrations.length === 0) return "none";
|
||||
return migrations.length > 3
|
||||
? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)`
|
||||
: migrations.join(", ");
|
||||
}
|
||||
|
||||
function exitForSignal(signal: NodeJS.Signals) {
|
||||
if (signal === "SIGINT") {
|
||||
process.exit(130);
|
||||
}
|
||||
if (signal === "SIGTERM") {
|
||||
process.exit(143);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function toRelativePath(absolutePath: string) {
|
||||
return path.relative(repoRoot, absolutePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function readSignature(absolutePath: string) {
|
||||
const stats = statSync(absolutePath);
|
||||
return `${Math.trunc(stats.mtimeMs)}:${stats.size}`;
|
||||
}
|
||||
|
||||
function addFileToSnapshot(snapshot: Map<string, string>, absolutePath: string) {
|
||||
const relativePath = toRelativePath(absolutePath);
|
||||
if (ignoredRelativePaths.has(relativePath)) return;
|
||||
if (!shouldTrackDevServerPath(relativePath)) return;
|
||||
snapshot.set(relativePath, readSignature(absolutePath));
|
||||
}
|
||||
|
||||
function walkDirectory(snapshot: Map<string, string>, absoluteDirectory: string) {
|
||||
if (!existsSync(absoluteDirectory)) return;
|
||||
|
||||
for (const entry of readdirSync(absoluteDirectory, { withFileTypes: true })) {
|
||||
if (ignoredDirectoryNames.has(entry.name)) continue;
|
||||
|
||||
const absolutePath = path.join(absoluteDirectory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkDirectory(snapshot, absolutePath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() || entry.isSymbolicLink()) {
|
||||
addFileToSnapshot(snapshot, absolutePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectWatchedSnapshot() {
|
||||
const snapshot = new Map<string, string>();
|
||||
|
||||
for (const absoluteDirectory of watchedDirectories) {
|
||||
walkDirectory(snapshot, absoluteDirectory);
|
||||
}
|
||||
for (const absoluteFile of watchedFiles) {
|
||||
if (!existsSync(absoluteFile)) continue;
|
||||
addFileToSnapshot(snapshot, absoluteFile);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function diffSnapshots(previous: Map<string, string>, next: Map<string, string>) {
|
||||
const changed = new Set<string>();
|
||||
|
||||
for (const [relativePath, signature] of next) {
|
||||
if (previous.get(relativePath) !== signature) {
|
||||
changed.add(relativePath);
|
||||
}
|
||||
}
|
||||
for (const relativePath of previous.keys()) {
|
||||
if (!next.has(relativePath)) {
|
||||
changed.add(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return [...changed].sort();
|
||||
}
|
||||
|
||||
function ensureDevStatusDirectory() {
|
||||
mkdirSync(path.dirname(devServerStatusFilePath), { recursive: true });
|
||||
}
|
||||
|
||||
function writeDevServerStatus() {
|
||||
if (mode !== "dev") return;
|
||||
|
||||
ensureDevStatusDirectory();
|
||||
const changedPaths = [...dirtyPaths].sort();
|
||||
writeFileSync(
|
||||
devServerStatusFilePath,
|
||||
`${JSON.stringify({
|
||||
dirty: changedPaths.length > 0 || pendingMigrations.length > 0,
|
||||
lastChangedAt,
|
||||
changedPathCount: changedPaths.length,
|
||||
changedPathsSample: changedPaths.slice(0, changedPathSampleLimit),
|
||||
pendingMigrations,
|
||||
lastRestartAt,
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function clearDevServerStatus() {
|
||||
if (mode !== "dev") return;
|
||||
rmSync(devServerStatusFilePath, { force: true });
|
||||
}
|
||||
|
||||
async function updateDevServiceRecord(extra?: Record<string, unknown>) {
|
||||
await writeLocalServiceRegistryRecord({
|
||||
version: 1,
|
||||
serviceKey: devService.serviceKey,
|
||||
profileKind: "paperclip-dev",
|
||||
serviceName: devService.serviceName,
|
||||
command: "dev-runner.ts",
|
||||
cwd: repoRoot,
|
||||
envFingerprint: devService.envFingerprint,
|
||||
port: serverPort,
|
||||
url: `http://127.0.0.1:${serverPort}`,
|
||||
pid: process.pid,
|
||||
processGroupId: null,
|
||||
provider: "local_process",
|
||||
runtimeServiceId: null,
|
||||
reuseKey: null,
|
||||
startedAt: lastRestartAt ?? new Date().toISOString(),
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
repoRoot,
|
||||
mode,
|
||||
childPid: child?.pid ?? null,
|
||||
url: `http://127.0.0.1:${serverPort}`,
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runPnpm(args: string[], options: {
|
||||
stdio?: "inherit" | ["ignore", "pipe", "pipe"];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cwd?: string;
|
||||
} = {}) {
|
||||
return await new Promise<{ code: number; signal: NodeJS.Signals | null; stdout: string; stderr: string }>((resolve, reject) => {
|
||||
const spawned = spawn(pnpmBin, args, {
|
||||
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
||||
env: options.env ?? process.env,
|
||||
cwd: options.cwd,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
|
||||
if (spawned.stdout) {
|
||||
spawned.stdout.on("data", (chunk) => {
|
||||
stdoutBuffer += String(chunk);
|
||||
});
|
||||
}
|
||||
if (spawned.stderr) {
|
||||
spawned.stderr.on("data", (chunk) => {
|
||||
stderrBuffer += String(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
spawned.on("error", reject);
|
||||
spawned.on("exit", (code, signal) => {
|
||||
resolve({
|
||||
code: code ?? 0,
|
||||
signal,
|
||||
stdout: stdoutBuffer,
|
||||
stderr: stderrBuffer,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getMigrationStatusPayload() {
|
||||
const status = await runPnpm(
|
||||
["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"],
|
||||
{ env },
|
||||
);
|
||||
if (status.code !== 0) {
|
||||
process.stderr.write(
|
||||
status.stderr ||
|
||||
status.stdout ||
|
||||
`[paperclip] Command failed with code ${status.code}: pnpm --filter @paperclipai/db exec tsx src/migration-status.ts --json\n`,
|
||||
);
|
||||
process.exit(status.code);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(status.stdout.trim()) as { status?: string; pendingMigrations?: string[] };
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
status.stderr ||
|
||||
status.stdout ||
|
||||
"[paperclip] migration-status returned invalid JSON payload\n",
|
||||
);
|
||||
throw toError(error, "Unable to parse migration-status JSON output");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPendingMigrations() {
|
||||
const payload = await getMigrationStatusPayload();
|
||||
pendingMigrations =
|
||||
payload.status === "needsMigrations" && Array.isArray(payload.pendingMigrations)
|
||||
? payload.pendingMigrations.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
||||
: [];
|
||||
writeDevServerStatus();
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function maybePreflightMigrations(options: { interactive?: boolean; autoApply?: boolean; exitOnDecline?: boolean } = {}) {
|
||||
const interactive = options.interactive ?? mode === "watch";
|
||||
const autoApply = options.autoApply ?? env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true";
|
||||
const exitOnDecline = options.exitOnDecline ?? mode === "watch";
|
||||
|
||||
const payload = await refreshPendingMigrations();
|
||||
if (payload.status !== "needsMigrations" || pendingMigrations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shouldApply = autoApply;
|
||||
|
||||
if (!autoApply && interactive) {
|
||||
if (!stdin.isTTY || !stdout.isTTY) {
|
||||
shouldApply = true;
|
||||
} else {
|
||||
const prompt = createInterface({ input: stdin, output: stdout });
|
||||
try {
|
||||
const answer = (
|
||||
await prompt.question(
|
||||
`Apply pending migrations (${formatPendingMigrationSummary(pendingMigrations)}) now? (y/N): `,
|
||||
)
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
shouldApply = answer === "y" || answer === "yes";
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldApply) {
|
||||
if (exitOnDecline) {
|
||||
process.stderr.write(
|
||||
`[paperclip] Pending migrations detected (${formatPendingMigrationSummary(pendingMigrations)}). Refusing to start watch mode against a stale schema.\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const exit = await runPnpm(["db:migrate"], {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
cwd: repoRoot,
|
||||
});
|
||||
if (exit.signal) {
|
||||
exitForSignal(exit.signal);
|
||||
return;
|
||||
}
|
||||
if (exit.code !== 0) {
|
||||
process.exit(exit.code);
|
||||
}
|
||||
|
||||
await refreshPendingMigrations();
|
||||
}
|
||||
|
||||
async function buildPluginSdk() {
|
||||
console.log("[paperclip] building plugin sdk...");
|
||||
const result = await runPnpm(
|
||||
["--filter", "@paperclipai/plugin-sdk", "build"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (result.signal) {
|
||||
exitForSignal(result.signal);
|
||||
return;
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
console.error("[paperclip] plugin sdk build failed");
|
||||
process.exit(result.code);
|
||||
}
|
||||
}
|
||||
|
||||
async function markChildAsCurrent() {
|
||||
previousSnapshot = collectWatchedSnapshot();
|
||||
dirtyPaths = new Set();
|
||||
lastChangedAt = null;
|
||||
lastRestartAt = new Date().toISOString();
|
||||
await refreshPendingMigrations();
|
||||
await updateDevServiceRecord();
|
||||
}
|
||||
|
||||
async function scanForBackendChanges() {
|
||||
if (mode !== "dev" || scanInFlight || restartInFlight) return;
|
||||
scanInFlight = true;
|
||||
try {
|
||||
const nextSnapshot = collectWatchedSnapshot();
|
||||
const changed = diffSnapshots(previousSnapshot, nextSnapshot);
|
||||
previousSnapshot = nextSnapshot;
|
||||
if (changed.length === 0) return;
|
||||
|
||||
for (const relativePath of changed) {
|
||||
dirtyPaths.add(relativePath);
|
||||
}
|
||||
lastChangedAt = new Date().toISOString();
|
||||
await refreshPendingMigrations();
|
||||
} finally {
|
||||
scanInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getDevHealthPayload() {
|
||||
const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Health request failed (${response.status})`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function waitForChildExit() {
|
||||
if (!childExitPromise) {
|
||||
return { code: 0, signal: null };
|
||||
}
|
||||
return await childExitPromise;
|
||||
}
|
||||
|
||||
async function stopChildForRestart() {
|
||||
if (!child) return { code: 0, signal: null };
|
||||
childExitWasExpected = true;
|
||||
child.kill("SIGTERM");
|
||||
const killTimer = setTimeout(() => {
|
||||
if (child) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, gracefulShutdownTimeoutMs);
|
||||
try {
|
||||
return await waitForChildExit();
|
||||
} finally {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
}
|
||||
|
||||
async function startServerChild() {
|
||||
await buildPluginSdk();
|
||||
|
||||
const serverScript = mode === "watch" ? "dev:watch" : "dev";
|
||||
child = spawn(
|
||||
pnpmBin,
|
||||
["--filter", "@paperclipai/server", serverScript, ...forwardedArgs],
|
||||
{ stdio: "inherit", env, shell: process.platform === "win32" },
|
||||
);
|
||||
|
||||
childExitPromise = new Promise((resolve, reject) => {
|
||||
child?.on("error", reject);
|
||||
child?.on("exit", (code, signal) => {
|
||||
const expected = childExitWasExpected;
|
||||
childExitWasExpected = false;
|
||||
child = null;
|
||||
childExitPromise = null;
|
||||
void touchLocalServiceRegistryRecord(devService.serviceKey, {
|
||||
metadata: {
|
||||
repoRoot,
|
||||
mode,
|
||||
childPid: null,
|
||||
url: `http://127.0.0.1:${serverPort}`,
|
||||
},
|
||||
});
|
||||
resolve({ code: code ?? 0, signal });
|
||||
|
||||
if (restartInFlight || expected || shuttingDown) {
|
||||
return;
|
||||
}
|
||||
if (signal) {
|
||||
exitForSignal(signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
});
|
||||
|
||||
await markChildAsCurrent();
|
||||
}
|
||||
|
||||
async function maybeAutoRestartChild() {
|
||||
if (mode !== "dev" || restartInFlight || !child) return;
|
||||
if (dirtyPaths.size === 0 && pendingMigrations.length === 0) return;
|
||||
|
||||
restartInFlight = true;
|
||||
let health: { devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } } | null = null;
|
||||
try {
|
||||
health = await getDevHealthPayload();
|
||||
} catch {
|
||||
restartInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const devServer = health?.devServer;
|
||||
if (!devServer?.enabled || devServer.autoRestartEnabled !== true) {
|
||||
restartInFlight = false;
|
||||
return;
|
||||
}
|
||||
if ((devServer.activeRunCount ?? 0) > 0) {
|
||||
restartInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await maybePreflightMigrations({
|
||||
autoApply: true,
|
||||
interactive: false,
|
||||
exitOnDecline: false,
|
||||
});
|
||||
await stopChildForRestart();
|
||||
await startServerChild();
|
||||
} catch (error) {
|
||||
const err = toError(error, "Auto-restart failed");
|
||||
process.stderr.write(`${err.stack ?? err.message}\n`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
restartInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function installDevIntervals() {
|
||||
if (mode !== "dev") return;
|
||||
|
||||
scanTimer = setInterval(() => {
|
||||
void scanForBackendChanges();
|
||||
}, scanIntervalMs);
|
||||
autoRestartTimer = setInterval(() => {
|
||||
void maybeAutoRestartChild();
|
||||
}, autoRestartPollIntervalMs);
|
||||
}
|
||||
|
||||
function clearDevIntervals() {
|
||||
if (scanTimer) {
|
||||
clearInterval(scanTimer);
|
||||
scanTimer = null;
|
||||
}
|
||||
if (autoRestartTimer) {
|
||||
clearInterval(autoRestartTimer);
|
||||
autoRestartTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdown(signal: NodeJS.Signals) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
clearDevIntervals();
|
||||
clearDevServerStatus();
|
||||
await removeLocalServiceRegistryRecord(devService.serviceKey);
|
||||
|
||||
if (!child) {
|
||||
exitForSignal(signal);
|
||||
return;
|
||||
}
|
||||
|
||||
childExitWasExpected = true;
|
||||
child.kill(signal);
|
||||
const exit = await waitForChildExit();
|
||||
if (exit.signal) {
|
||||
exitForSignal(exit.signal);
|
||||
return;
|
||||
}
|
||||
process.exit(exit.code ?? 0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
|
||||
await maybePreflightMigrations();
|
||||
await startServerChild();
|
||||
installDevIntervals();
|
||||
|
||||
if (mode === "watch") {
|
||||
const exit = await waitForChildExit();
|
||||
await removeLocalServiceRegistryRecord(devService.serviceKey);
|
||||
if (exit.signal) {
|
||||
exitForSignal(exit.signal);
|
||||
}
|
||||
process.exit(exit.code ?? 0);
|
||||
}
|
||||
44
scripts/dev-service-profile.ts
Normal file
44
scripts/dev-service-profile.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createLocalServiceKey } from "../server/src/services/local-service-supervisor.ts";
|
||||
|
||||
export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export function createDevServiceIdentity(input: {
|
||||
mode: "watch" | "dev";
|
||||
forwardedArgs: string[];
|
||||
tailscaleAuth: boolean;
|
||||
port: number;
|
||||
}) {
|
||||
const envFingerprint = createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
mode: input.mode,
|
||||
forwardedArgs: input.forwardedArgs,
|
||||
tailscaleAuth: input.tailscaleAuth,
|
||||
port: input.port,
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
|
||||
const serviceName = input.mode === "watch" ? "paperclip-dev-watch" : "paperclip-dev-once";
|
||||
const serviceKey = createLocalServiceKey({
|
||||
profileKind: "paperclip-dev",
|
||||
serviceName,
|
||||
cwd: repoRoot,
|
||||
command: "dev-runner.ts",
|
||||
envFingerprint,
|
||||
port: input.port,
|
||||
scope: {
|
||||
repoRoot,
|
||||
mode: input.mode,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
serviceKey,
|
||||
serviceName,
|
||||
envFingerprint,
|
||||
};
|
||||
}
|
||||
44
scripts/dev-service.ts
Normal file
44
scripts/dev-service.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env -S node --import tsx
|
||||
import { listLocalServiceRegistryRecords, removeLocalServiceRegistryRecord, terminateLocalService } from "../server/src/services/local-service-supervisor.ts";
|
||||
import { repoRoot } from "./dev-service-profile.ts";
|
||||
|
||||
function toDisplayLines(records: Awaited<ReturnType<typeof listLocalServiceRegistryRecords>>) {
|
||||
return records.map((record) => {
|
||||
const childPid = typeof record.metadata?.childPid === "number" ? ` child=${record.metadata.childPid}` : "";
|
||||
const url = typeof record.metadata?.url === "string" ? ` url=${record.metadata.url}` : "";
|
||||
return `${record.serviceName} pid=${record.pid}${childPid} cwd=${record.cwd}${url}`;
|
||||
});
|
||||
}
|
||||
|
||||
const command = process.argv[2] ?? "list";
|
||||
const records = await listLocalServiceRegistryRecords({
|
||||
profileKind: "paperclip-dev",
|
||||
metadata: { repoRoot },
|
||||
});
|
||||
|
||||
if (command === "list") {
|
||||
if (records.length === 0) {
|
||||
console.log("No Paperclip dev services registered for this repo.");
|
||||
process.exit(0);
|
||||
}
|
||||
for (const line of toDisplayLines(records)) {
|
||||
console.log(line);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "stop") {
|
||||
if (records.length === 0) {
|
||||
console.log("No Paperclip dev services registered for this repo.");
|
||||
process.exit(0);
|
||||
}
|
||||
for (const record of records) {
|
||||
await terminateLocalService(record);
|
||||
await removeLocalServiceRegistryRecord(record.serviceKey);
|
||||
console.log(`Stopped ${record.serviceName} (pid ${record.pid})`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`Unknown dev-service command: ${command}`);
|
||||
process.exit(1);
|
||||
|
|
@ -8,64 +8,199 @@
|
|||
#
|
||||
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
DRY_RUN=false
|
||||
if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then
|
||||
DRY_RUN=true
|
||||
fi
|
||||
|
||||
# Collect PIDs of node processes running from any paperclip directory.
|
||||
# Matches paths like /Users/*/paperclip/... or /Users/*/paperclip-*/...
|
||||
# Excludes postgres-related processes.
|
||||
pids=()
|
||||
lines=()
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
REPO_PARENT="$(dirname "$REPO_ROOT")"
|
||||
|
||||
node_pids=()
|
||||
node_lines=()
|
||||
pg_pids=()
|
||||
pg_pidfiles=()
|
||||
pg_data_dirs=()
|
||||
|
||||
is_pid_running() {
|
||||
local pid="$1"
|
||||
kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
read_pidfile_pid() {
|
||||
local pidfile="$1"
|
||||
local first_line
|
||||
first_line="$(head -n 1 "$pidfile" 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
if [[ "$first_line" =~ ^[0-9]+$ ]] && (( first_line > 0 )); then
|
||||
printf '%s\n' "$first_line"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
command_for_pid() {
|
||||
local pid="$1"
|
||||
ps -o command= -p "$pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
append_postgres_from_pidfile() {
|
||||
local pidfile="$1"
|
||||
local pid cmd data_dir
|
||||
pid="$(read_pidfile_pid "$pidfile" || true)"
|
||||
[[ -n "$pid" ]] || return 0
|
||||
is_pid_running "$pid" || return 0
|
||||
cmd="$(command_for_pid "$pid")"
|
||||
[[ "$cmd" == *postgres* ]] || return 0
|
||||
|
||||
for existing_pid in "${pg_pids[@]:-}"; do
|
||||
[[ "$existing_pid" == "$pid" ]] && return 0
|
||||
done
|
||||
|
||||
data_dir="$(dirname "$pidfile")"
|
||||
pg_pids+=("$pid")
|
||||
pg_pidfiles+=("$pidfile")
|
||||
pg_data_dirs+=("$data_dir")
|
||||
}
|
||||
|
||||
wait_for_pid_exit() {
|
||||
local pid="$1"
|
||||
local timeout_sec="$2"
|
||||
local waited=0
|
||||
while is_pid_running "$pid"; do
|
||||
if (( waited >= timeout_sec * 10 )); then
|
||||
return 1
|
||||
fi
|
||||
sleep 0.1
|
||||
((waited += 1))
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
# skip postgres processes
|
||||
[[ "$line" == *postgres* ]] && continue
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
pids+=("$pid")
|
||||
lines+=("$line")
|
||||
node_pids+=("$pid")
|
||||
node_lines+=("$line")
|
||||
done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true)
|
||||
|
||||
if [[ ${#pids[@]} -eq 0 ]]; then
|
||||
candidate_pidfiles=()
|
||||
candidate_pidfiles+=(
|
||||
"$HOME"/.paperclip/instances/*/db/postmaster.pid
|
||||
"$REPO_ROOT"/.paperclip/instances/*/db/postmaster.pid
|
||||
"$REPO_ROOT"/.paperclip/runtime-services/instances/*/db/postmaster.pid
|
||||
)
|
||||
|
||||
for sibling_root in "$REPO_PARENT"/paperclip*; do
|
||||
[[ -d "$sibling_root" ]] || continue
|
||||
candidate_pidfiles+=(
|
||||
"$sibling_root"/.paperclip/instances/*/db/postmaster.pid
|
||||
"$sibling_root"/.paperclip/runtime-services/instances/*/db/postmaster.pid
|
||||
)
|
||||
done
|
||||
|
||||
for pidfile in "${candidate_pidfiles[@]:-}"; do
|
||||
[[ -f "$pidfile" ]] || continue
|
||||
append_postgres_from_pidfile "$pidfile"
|
||||
done
|
||||
|
||||
if [[ ${#node_pids[@]} -eq 0 && ${#pg_pids[@]} -eq 0 ]]; then
|
||||
echo "No Paperclip dev processes found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found ${#pids[@]} Paperclip dev process(es):"
|
||||
echo ""
|
||||
if [[ ${#node_pids[@]} -gt 0 ]]; then
|
||||
echo "Found ${#node_pids[@]} Paperclip dev node process(es):"
|
||||
echo ""
|
||||
|
||||
for i in "${!pids[@]}"; do
|
||||
line="${lines[$i]}"
|
||||
for i in "${!node_pids[@]:-}"; do
|
||||
line="${node_lines[$i]}"
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
start=$(echo "$line" | awk '{print $9}')
|
||||
cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}')
|
||||
# Shorten the command for readability
|
||||
cmd=$(echo "$cmd" | sed "s|$HOME/||g")
|
||||
printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd"
|
||||
done
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ ${#pg_pids[@]} -gt 0 ]]; then
|
||||
echo "Found ${#pg_pids[@]} embedded PostgreSQL master process(es):"
|
||||
echo ""
|
||||
|
||||
for i in "${!pg_pids[@]:-}"; do
|
||||
pid="${pg_pids[$i]}"
|
||||
data_dir="${pg_data_dirs[$i]}"
|
||||
pidfile="${pg_pidfiles[$i]}"
|
||||
short_data_dir="${data_dir/#$HOME\//}"
|
||||
short_pidfile="${pidfile/#$HOME\//}"
|
||||
printf " PID %-7s data %-55s pidfile %s\n" "$pid" "$short_data_dir" "$short_pidfile"
|
||||
done
|
||||
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Dry run — re-run without --dry to kill these processes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Sending SIGTERM..."
|
||||
for pid in "${pids[@]}"; do
|
||||
kill "$pid" 2>/dev/null && echo " killed $pid" || echo " $pid already gone"
|
||||
done
|
||||
if [[ ${#node_pids[@]} -gt 0 ]]; then
|
||||
echo "Sending SIGTERM to Paperclip node processes..."
|
||||
for pid in "${node_pids[@]}"; do
|
||||
kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone"
|
||||
done
|
||||
echo "Waiting briefly for node processes to exit..."
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Give processes a moment to exit, then SIGKILL any stragglers
|
||||
sleep 2
|
||||
for pid in "${pids[@]}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo " $pid still alive, sending SIGKILL..."
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
leftover_pg_pids=()
|
||||
leftover_pg_data_dirs=()
|
||||
for i in "${!pg_pids[@]:-}"; do
|
||||
pid="${pg_pids[$i]}"
|
||||
if is_pid_running "$pid"; then
|
||||
leftover_pg_pids+=("$pid")
|
||||
leftover_pg_data_dirs+=("${pg_data_dirs[$i]}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#leftover_pg_pids[@]} -gt 0 ]]; then
|
||||
echo "Sending SIGTERM to leftover embedded PostgreSQL processes..."
|
||||
for i in "${!leftover_pg_pids[@]:-}"; do
|
||||
pid="${leftover_pg_pids[$i]}"
|
||||
data_dir="${leftover_pg_data_dirs[$i]}"
|
||||
kill -TERM "$pid" 2>/dev/null \
|
||||
&& echo " signaled $pid ($data_dir)" \
|
||||
|| echo " $pid already gone"
|
||||
done
|
||||
echo "Waiting up to 15s for PostgreSQL to shut down cleanly..."
|
||||
for pid in "${leftover_pg_pids[@]:-}"; do
|
||||
if wait_for_pid_exit "$pid" 15; then
|
||||
echo " postgres $pid exited cleanly"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#node_pids[@]} -gt 0 ]]; then
|
||||
for pid in "${node_pids[@]:-}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo " node $pid still alive, sending SIGKILL..."
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#pg_pids[@]} -gt 0 ]]; then
|
||||
for pid in "${pg_pids[@]:-}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo " postgres $pid still alive, sending SIGKILL..."
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
"drizzle-orm": "^0.38.4",
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
"express": "^5.1.0",
|
||||
"hermes-paperclip-adapter": "0.1.1",
|
||||
"hermes-paperclip-adapter": "^0.2.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"open": "^11.0.0",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
|||
import { resolveServerDevWatchIgnorePaths } from "../src/dev-watch-ignore.ts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const tsxCliPath = require.resolve("tsx/dist/cli.mjs");
|
||||
const tsxCliPath = require.resolve("tsx/cli");
|
||||
const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--exclude", ignorePath]);
|
||||
|
||||
|
|
|
|||
99
server/src/__tests__/claude-local-execute.test.ts
Normal file
99
server/src/__tests__/claude-local-execute.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execute } from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
async function writeFakeClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
const payload = {
|
||||
argv: process.argv.slice(2),
|
||||
prompt: fs.readFileSync(0, "utf8"),
|
||||
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
|
||||
};
|
||||
if (capturePath) {
|
||||
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
||||
}
|
||||
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }));
|
||||
console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } }));
|
||||
console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
describe("claude execute", () => {
|
||||
it("logs HOME, CLAUDE_CONFIG_DIR, and the resolved executable path in invocation metadata", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-meta-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const binDir = path.join(root, "bin");
|
||||
const commandPath = path.join(binDir, "claude");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
const claudeConfigDir = path.join(root, "claude-config");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.mkdir(claudeConfigDir, { recursive: true });
|
||||
await writeFakeClaudeCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
const previousPath = process.env.PATH;
|
||||
const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.HOME = root;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir;
|
||||
|
||||
let loggedCommand: string | null = null;
|
||||
let loggedEnv: Record<string, string> = {};
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-meta",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Claude Coder",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: "claude",
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
loggedCommand = meta.command;
|
||||
loggedEnv = meta.env ?? {};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect(loggedCommand).toBe(commandPath);
|
||||
expect(loggedEnv.HOME).toBe(root);
|
||||
expect(loggedEnv.CLAUDE_CONFIG_DIR).toBe(claudeConfigDir);
|
||||
expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(commandPath);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = previousPath;
|
||||
if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -195,6 +195,70 @@ describe("codex execute", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("logs HOME and the resolved executable path in invocation metadata", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-meta-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const binDir = path.join(root, "bin");
|
||||
const commandPath = path.join(binDir, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.HOME = root;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
||||
|
||||
let loggedCommand: string | null = null;
|
||||
let loggedEnv: Record<string, string> = {};
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-meta",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: "codex",
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
loggedCommand = meta.command;
|
||||
loggedEnv = meta.env ?? {};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect(loggedCommand).toBe(commandPath);
|
||||
expect(loggedEnv.HOME).toBe(root);
|
||||
expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(commandPath);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = previousPath;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
|
|
|||
325
server/src/__tests__/execution-workspaces-service.test.ts
Normal file
325
server/src/__tests__/execution-workspaces-service.test.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
companies,
|
||||
createDb,
|
||||
executionWorkspaces,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
executionWorkspaceService,
|
||||
mergeExecutionWorkspaceConfig,
|
||||
readExecutionWorkspaceConfig,
|
||||
} from "../services/execution-workspaces.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
describe("execution workspace config helpers", () => {
|
||||
it("reads typed config from persisted metadata", () => {
|
||||
expect(readExecutionWorkspaceConfig({
|
||||
source: "project_primary",
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
|
||||
},
|
||||
},
|
||||
})).toEqual({
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
desiredState: null,
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("merges config patches without dropping unrelated metadata", () => {
|
||||
expect(mergeExecutionWorkspaceConfig(
|
||||
{
|
||||
source: "project_primary",
|
||||
createdByRuntime: false,
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
},
|
||||
},
|
||||
{
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
},
|
||||
},
|
||||
)).toEqual({
|
||||
source: "project_primary",
|
||||
createdByRuntime: false,
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
desiredState: null,
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the nested config block when requested", () => {
|
||||
expect(mergeExecutionWorkspaceConfig(
|
||||
{
|
||||
source: "project_primary",
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
},
|
||||
},
|
||||
null,
|
||||
)).toEqual({
|
||||
source: "project_primary",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres execution workspace service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runGit(cwd: string, args: string[]) {
|
||||
await execFileAsync("git", ["-C", cwd, ...args], { cwd });
|
||||
}
|
||||
|
||||
async function createTempRepo() {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-execution-workspace-"));
|
||||
await runGit(repoRoot, ["init"]);
|
||||
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
||||
await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]);
|
||||
await fs.writeFile(path.join(repoRoot, "README.md"), "# Test repo\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "README.md"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
|
||||
await runGit(repoRoot, ["branch", "-M", "main"]);
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof executionWorkspaceService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-execution-workspaces-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = executionWorkspaceService(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(companies);
|
||||
|
||||
for (const dir of tempDirs) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("allows archiving shared workspace sessions with warnings even when issues are still open", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspaces",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
sourceType: "local_path",
|
||||
isPrimary: true,
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Shared workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
metadata: {
|
||||
config: {
|
||||
teardownCommand: "bash ./scripts/teardown.sh",
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Still working",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
});
|
||||
|
||||
const readiness = await svc.getCloseReadiness(executionWorkspaceId);
|
||||
|
||||
expect(readiness).toMatchObject({
|
||||
workspaceId: executionWorkspaceId,
|
||||
state: "ready_with_warnings",
|
||||
isSharedWorkspace: true,
|
||||
isProjectPrimaryWorkspace: true,
|
||||
isDestructiveCloseAllowed: true,
|
||||
});
|
||||
expect(readiness?.blockingReasons).toEqual([]);
|
||||
expect(readiness?.warnings).toEqual(expect.arrayContaining([
|
||||
"This workspace is still linked to an open issue. Archiving it will detach this shared workspace session from those issues, but keep the underlying project workspace available.",
|
||||
"This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record.",
|
||||
]));
|
||||
});
|
||||
|
||||
it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
tempDirs.add(repoRoot);
|
||||
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-worktree-${randomUUID()}`);
|
||||
tempDirs.add(worktreePath);
|
||||
|
||||
await runGit(repoRoot, ["branch", "paperclip-close-check"]);
|
||||
await runGit(repoRoot, ["worktree", "add", worktreePath, "paperclip-close-check"]);
|
||||
await fs.writeFile(path.join(worktreePath, "feature.txt"), "hello\n", "utf8");
|
||||
await runGit(worktreePath, ["add", "feature.txt"]);
|
||||
await runGit(worktreePath, ["commit", "-m", "Feature commit"]);
|
||||
await fs.writeFile(path.join(worktreePath, "untracked.txt"), "left behind\n", "utf8");
|
||||
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspaces",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
teardownCommand: "bash ./scripts/project-teardown.sh",
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
sourceType: "git_repo",
|
||||
isPrimary: true,
|
||||
cwd: repoRoot,
|
||||
cleanupCommand: "printf 'project cleanup\\n'",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Feature workspace",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
providerRef: worktreePath,
|
||||
branchName: "paperclip-close-check",
|
||||
baseRef: "main",
|
||||
metadata: {
|
||||
createdByRuntime: true,
|
||||
config: {
|
||||
cleanupCommand: "printf 'workspace cleanup\\n'",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const readiness = await svc.getCloseReadiness(executionWorkspaceId);
|
||||
|
||||
expect(readiness).toMatchObject({
|
||||
workspaceId: executionWorkspaceId,
|
||||
state: "ready_with_warnings",
|
||||
isSharedWorkspace: false,
|
||||
isProjectPrimaryWorkspace: false,
|
||||
isDestructiveCloseAllowed: true,
|
||||
git: {
|
||||
workspacePath: worktreePath,
|
||||
branchName: "paperclip-close-check",
|
||||
baseRef: "main",
|
||||
createdByRuntime: true,
|
||||
hasDirtyTrackedFiles: false,
|
||||
hasUntrackedFiles: true,
|
||||
aheadCount: 1,
|
||||
behindCount: 0,
|
||||
isMergedIntoBase: false,
|
||||
},
|
||||
});
|
||||
expect(readiness?.warnings).toEqual(expect.arrayContaining([
|
||||
"The workspace has 1 untracked file.",
|
||||
"This workspace is 1 commit ahead of main and is not merged.",
|
||||
]));
|
||||
expect(readiness?.plannedActions.map((action) => action.kind)).toEqual(expect.arrayContaining([
|
||||
"archive_record",
|
||||
"cleanup_command",
|
||||
"teardown_command",
|
||||
"git_worktree_remove",
|
||||
"git_branch_delete",
|
||||
]));
|
||||
}, 20_000);
|
||||
});
|
||||
|
|
@ -3,11 +3,13 @@ import type { agents } from "@paperclipai/db";
|
|||
import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||
import {
|
||||
applyPersistedExecutionWorkspaceConfig,
|
||||
buildExplicitResumeSessionOverride,
|
||||
formatRuntimeWorkspaceWarningLog,
|
||||
prioritizeProjectWorkspaceCandidatesForRun,
|
||||
parseSessionCompactionPolicy,
|
||||
resolveRuntimeSessionParamsForWorkspace,
|
||||
stripWorkspaceRuntimeFromExecutionRunConfig,
|
||||
shouldResetTaskSessionForWake,
|
||||
type ResolvedWorkspaceForRun,
|
||||
} from "../services/heartbeat.ts";
|
||||
|
|
@ -120,6 +122,64 @@ describe("resolveRuntimeSessionParamsForWorkspace", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("applyPersistedExecutionWorkspaceConfig", () => {
|
||||
it("does not add workspace runtime when only the project workspace had manual runtime config", () => {
|
||||
const result = applyPersistedExecutionWorkspaceConfig({
|
||||
config: {},
|
||||
workspaceConfig: null,
|
||||
mode: "isolated_workspace",
|
||||
});
|
||||
|
||||
expect("workspaceRuntime" in result).toBe(false);
|
||||
});
|
||||
|
||||
it("applies explicit persisted execution workspace runtime config when present", () => {
|
||||
const result = applyPersistedExecutionWorkspaceConfig({
|
||||
config: {},
|
||||
workspaceConfig: {
|
||||
provisionCommand: null,
|
||||
teardownCommand: null,
|
||||
cleanupCommand: null,
|
||||
desiredState: null,
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "workspace-web" }],
|
||||
},
|
||||
},
|
||||
mode: "isolated_workspace",
|
||||
});
|
||||
|
||||
expect(result.workspaceRuntime).toEqual({
|
||||
services: [{ name: "workspace-web" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => {
|
||||
it("removes workspace runtime before heartbeat execution", () => {
|
||||
const input = {
|
||||
cwd: "/tmp/project",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
},
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = stripWorkspaceRuntimeFromExecutionRunConfig(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
cwd: "/tmp/project",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
},
|
||||
});
|
||||
expect(input.workspaceRuntime).toEqual({
|
||||
services: [{ name: "web" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldResetTaskSessionForWake", () => {
|
||||
it("resets session context on assignment wake", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true);
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import {
|
|||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
executionWorkspaces,
|
||||
issueComments,
|
||||
issueInboxArchives,
|
||||
issues,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -40,6 +42,8 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|||
await db.delete(issueInboxArchives);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
|
@ -219,6 +223,86 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|||
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
|
||||
});
|
||||
|
||||
it("filters issues by execution workspace id", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const targetWorkspaceId = randomUUID();
|
||||
const otherWorkspaceId = randomUUID();
|
||||
const linkedIssueId = randomUUID();
|
||||
const otherLinkedIssueId = randomUUID();
|
||||
const unlinkedIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(executionWorkspaces).values([
|
||||
{
|
||||
id: targetWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Target workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
},
|
||||
{
|
||||
id: otherWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Other workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: linkedIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Linked issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceId: targetWorkspaceId,
|
||||
},
|
||||
{
|
||||
id: otherLinkedIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Other linked issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceId: otherWorkspaceId,
|
||||
},
|
||||
{
|
||||
id: unlinkedIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Unlinked issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await svc.list(companyId, { executionWorkspaceId: targetWorkspaceId });
|
||||
|
||||
expect(result.map((issue) => issue.id)).toEqual([linkedIssueId]);
|
||||
});
|
||||
|
||||
it("hides archived inbox issues until new external activity arrives", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "user-1";
|
||||
|
|
|
|||
|
|
@ -1,25 +1,51 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
executionWorkspaces,
|
||||
heartbeatRuns,
|
||||
projects,
|
||||
workspaceRuntimeServices,
|
||||
} from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
ensureRuntimeServicesForRun,
|
||||
normalizeAdapterManagedRuntimeServices,
|
||||
reconcilePersistedRuntimeServicesOnStartup,
|
||||
realizeExecutionWorkspace,
|
||||
releaseRuntimeServicesForRun,
|
||||
resetRuntimeServicesForTests,
|
||||
sanitizeRuntimeServiceBaseEnv,
|
||||
stopRuntimeServicesForExecutionWorkspace,
|
||||
type RealizedExecutionWorkspace,
|
||||
} from "../services/workspace-runtime.ts";
|
||||
import { resolvePaperclipConfigPath } from "../paths.ts";
|
||||
import type { WorkspaceOperation } from "@paperclipai/shared";
|
||||
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const leasedRunIds = new Set<string>();
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres workspace-runtime tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runGit(cwd: string, args: string[]) {
|
||||
await execFileAsync("git", args, { cwd });
|
||||
|
|
@ -128,6 +154,28 @@ afterEach(async () => {
|
|||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.PAPERCLIP_WORKTREES_DIR;
|
||||
delete process.env.DATABASE_URL;
|
||||
await resetRuntimeServicesForTests();
|
||||
});
|
||||
|
||||
describe("sanitizeRuntimeServiceBaseEnv", () => {
|
||||
it("removes inherited Paperclip and pnpm auth flags before spawning runtime services", () => {
|
||||
const sanitized = sanitizeRuntimeServiceBaseEnv({
|
||||
PATH: process.env.PATH,
|
||||
DATABASE_URL: "postgres://example.test/paperclip",
|
||||
PAPERCLIP_HOME: "/tmp/paperclip-home",
|
||||
PAPERCLIP_INSTANCE_ID: "runtime-instance",
|
||||
npm_config_tailscale_auth: "true",
|
||||
npm_config_authenticated_private: "true",
|
||||
HOST: "0.0.0.0",
|
||||
});
|
||||
|
||||
expect(sanitized.PAPERCLIP_HOME).toBeUndefined();
|
||||
expect(sanitized.PAPERCLIP_INSTANCE_ID).toBeUndefined();
|
||||
expect(sanitized.DATABASE_URL).toBeUndefined();
|
||||
expect(sanitized.npm_config_tailscale_auth).toBeUndefined();
|
||||
expect(sanitized.npm_config_authenticated_private).toBeUndefined();
|
||||
expect(sanitized.HOST).toBe("0.0.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("realizeExecutionWorkspace", () => {
|
||||
|
|
@ -834,6 +882,101 @@ describe("ensureRuntimeServicesForRun", () => {
|
|||
expect(third[0]?.id).not.toBe(first[0]?.id);
|
||||
});
|
||||
|
||||
it("does not reuse project-scoped shared services across different workspace launch contexts", async () => {
|
||||
const primaryWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-primary-"));
|
||||
const worktreeWorkspaceRoot = path.join(primaryWorkspaceRoot, ".paperclip", "worktrees", "PAP-874-chat-speed-issues");
|
||||
await fs.mkdir(worktreeWorkspaceRoot, { recursive: true });
|
||||
|
||||
const primaryWorkspace = buildWorkspace(primaryWorkspaceRoot);
|
||||
const executionWorkspace: RealizedExecutionWorkspace = {
|
||||
...buildWorkspace(worktreeWorkspaceRoot),
|
||||
source: "task_session",
|
||||
strategy: "git_worktree",
|
||||
cwd: worktreeWorkspaceRoot,
|
||||
branchName: "PAP-874-chat-speed-issues",
|
||||
worktreePath: worktreeWorkspaceRoot,
|
||||
};
|
||||
const serviceCommand =
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end(process.env.PAPERCLIP_HOME)).listen(Number(process.env.PORT), '127.0.0.1')\"";
|
||||
const config = {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "paperclip-dev",
|
||||
command: serviceCommand,
|
||||
cwd: ".",
|
||||
env: {
|
||||
PAPERCLIP_HOME: "{{workspace.cwd}}/.paperclip/runtime-services",
|
||||
},
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
expose: {
|
||||
type: "url",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "project_workspace",
|
||||
stopPolicy: {
|
||||
type: "on_run_finish",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const primaryRunId = "run-project-workspace";
|
||||
const executionRunId = "run-execution-workspace";
|
||||
leasedRunIds.add(primaryRunId);
|
||||
leasedRunIds.add(executionRunId);
|
||||
|
||||
const primaryServices = await ensureRuntimeServicesForRun({
|
||||
runId: primaryRunId,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace: primaryWorkspace,
|
||||
config,
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
const executionServices = await ensureRuntimeServicesForRun({
|
||||
runId: executionRunId,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace: executionWorkspace,
|
||||
executionWorkspaceId: "execution-workspace-1",
|
||||
config,
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(primaryServices).toHaveLength(1);
|
||||
expect(executionServices).toHaveLength(1);
|
||||
expect(primaryServices[0]?.reused).toBe(false);
|
||||
expect(executionServices[0]?.reused).toBe(false);
|
||||
expect(executionServices[0]?.id).not.toBe(primaryServices[0]?.id);
|
||||
expect(executionServices[0]?.executionWorkspaceId).toBe("execution-workspace-1");
|
||||
expect(executionServices[0]?.cwd).toBe(worktreeWorkspaceRoot);
|
||||
expect(executionServices[0]?.url).not.toBe(primaryServices[0]?.url);
|
||||
|
||||
const primaryResponse = await fetch(primaryServices[0]!.url!);
|
||||
expect(await primaryResponse.text()).toBe(path.join(primaryWorkspaceRoot, ".paperclip", "runtime-services"));
|
||||
|
||||
const executionResponse = await fetch(executionServices[0]!.url!);
|
||||
expect(await executionResponse.text()).toBe(path.join(worktreeWorkspaceRoot, ".paperclip", "runtime-services"));
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
@ -1028,6 +1171,258 @@ describe("ensureRuntimeServicesForRun", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-workspace-runtime-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(workspaceRuntimeServices);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
it("adopts a live auto-port shared service after runtime state is reset", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-reconcile-"));
|
||||
const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-home-"));
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = `runtime-reconcile-${randomUUID()}`;
|
||||
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Codex Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "manual",
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const workspace = {
|
||||
...buildWorkspace(workspaceRoot),
|
||||
projectId: null,
|
||||
workspaceId: null,
|
||||
};
|
||||
leasedRunIds.add(runId);
|
||||
|
||||
const services = await ensureRuntimeServicesForRun({
|
||||
db,
|
||||
runId,
|
||||
agent: {
|
||||
id: agentId,
|
||||
name: "Codex Coder",
|
||||
companyId,
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
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: "agent",
|
||||
stopPolicy: {
|
||||
type: "manual",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(services).toHaveLength(1);
|
||||
const service = services[0];
|
||||
expect(service?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
await expect(fetch(service!.url!)).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await resetRuntimeServicesForTests();
|
||||
|
||||
const result = await reconcilePersistedRuntimeServicesOnStartup(db);
|
||||
expect(result).toMatchObject({ reconciled: 1, adopted: 1, stopped: 0 });
|
||||
|
||||
const persisted = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(eq(workspaceRuntimeServices.id, service!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(persisted?.status).toBe("running");
|
||||
expect(persisted?.providerRef).toMatch(/^\d+$/);
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
db,
|
||||
executionWorkspaceId,
|
||||
workspaceCwd: workspace.cwd,
|
||||
});
|
||||
|
||||
await expect(fetch(service!.url!)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("persists controlled execution workspace stops as stopped", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-"));
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Codex Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Runtime stop test",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Execution workspace stop test",
|
||||
status: "active",
|
||||
cwd: workspaceRoot,
|
||||
providerType: "local_fs",
|
||||
providerRef: workspaceRoot,
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "manual",
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const workspace = {
|
||||
...buildWorkspace(workspaceRoot),
|
||||
projectId: null,
|
||||
workspaceId: null,
|
||||
};
|
||||
leasedRunIds.add(runId);
|
||||
|
||||
const services = await ensureRuntimeServicesForRun({
|
||||
db,
|
||||
runId,
|
||||
agent: {
|
||||
id: agentId,
|
||||
name: "Codex Coder",
|
||||
companyId,
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
executionWorkspaceId,
|
||||
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({
|
||||
db,
|
||||
executionWorkspaceId,
|
||||
workspaceCwd: workspace.cwd,
|
||||
});
|
||||
await releaseRuntimeServicesForRun(runId);
|
||||
leasedRunIds.delete(runId);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
await expect(fetch(services[0]!.url!)).rejects.toThrow();
|
||||
|
||||
const persisted = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(eq(workspaceRuntimeServices.id, services[0]!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
expect(persisted?.status).toBe("stopped");
|
||||
expect(persisted?.healthStatus).toBe("unknown");
|
||||
expect(persisted?.stoppedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAdapterManagedRuntimeServices", () => {
|
||||
it("fills workspace defaults and derives stable ids for adapter-managed services", () => {
|
||||
const workspace = buildWorkspace("/tmp/project");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter } from "./registry.js";
|
||||
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter, detectAdapterModel } from "./registry.js";
|
||||
export type {
|
||||
ServerAdapterModule,
|
||||
AdapterExecutionContext,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import {
|
|||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
redactEnvForLogs,
|
||||
buildInvocationEnvForLogs,
|
||||
ensurePathInEnv,
|
||||
resolveCommandForLogs,
|
||||
runChildProcess,
|
||||
} from "../utils.js";
|
||||
|
||||
|
|
@ -21,6 +23,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
});
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 15);
|
||||
|
|
@ -28,10 +37,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "process",
|
||||
command,
|
||||
command: resolvedCommand,
|
||||
cwd,
|
||||
commandArgs: args,
|
||||
env: redactEnvForLogs(env),
|
||||
env: loggedEnv,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ import {
|
|||
execute as hermesExecute,
|
||||
testEnvironment as hermesTestEnvironment,
|
||||
sessionCodec as hermesSessionCodec,
|
||||
listSkills as hermesListSkills,
|
||||
syncSkills as hermesSyncSkills,
|
||||
detectModel as detectModelFromHermes,
|
||||
} from "hermes-paperclip-adapter/server";
|
||||
import {
|
||||
agentConfigurationDoc as hermesAgentConfigurationDoc,
|
||||
|
|
@ -176,9 +179,12 @@ const hermesLocalAdapter: ServerAdapterModule = {
|
|||
execute: hermesExecute,
|
||||
testEnvironment: hermesTestEnvironment,
|
||||
sessionCodec: hermesSessionCodec,
|
||||
listSkills: hermesListSkills,
|
||||
syncSkills: hermesSyncSkills,
|
||||
models: hermesModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: hermesAgentConfigurationDoc,
|
||||
detectModel: () => detectModelFromHermes(),
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
|
|
@ -219,6 +225,15 @@ export function listServerAdapters(): ServerAdapterModule[] {
|
|||
return Array.from(adaptersByType.values());
|
||||
}
|
||||
|
||||
export async function detectAdapterModel(
|
||||
type: string,
|
||||
): Promise<{ model: string; provider: string; source: string } | null> {
|
||||
const adapter = adaptersByType.get(type);
|
||||
if (!adapter?.detectModel) return null;
|
||||
const detected = await adapter.detectModel();
|
||||
return detected ? { model: detected.model, provider: detected.provider, source: detected.source } : null;
|
||||
}
|
||||
|
||||
export function findServerAdapter(type: string): ServerAdapterModule | null {
|
||||
return adaptersByType.get(type) ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@ export {
|
|||
resolvePathValue,
|
||||
renderTemplate,
|
||||
redactEnvForLogs,
|
||||
buildInvocationEnvForLogs,
|
||||
buildPaperclipEnv,
|
||||
defaultPathForPlatform,
|
||||
ensurePathInEnv,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
resolveCommandForLogs,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
// Re-export runChildProcess with the server's pino logger wired in.
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import {
|
|||
} from "../services/index.js";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
||||
import { findServerAdapter, listAdapterModels, detectAdapterModel } from "../adapters/index.js";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
|
||||
|
|
@ -671,6 +671,15 @@ export function agentRoutes(db: Db) {
|
|||
res.json(models);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const type = req.params.type as string;
|
||||
|
||||
const detected = await detectAdapterModel(type);
|
||||
res.json(detected);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/adapters/:type/test-environment",
|
||||
validate(testAdapterEnvironmentSchema),
|
||||
|
|
|
|||
|
|
@ -5,15 +5,16 @@ 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 { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
|
||||
import { readProjectWorkspaceRuntimeConfig } from "../services/project-workspace-runtime-config.js";
|
||||
import {
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
startRuntimeServicesForWorkspaceControl,
|
||||
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);
|
||||
|
|
@ -43,6 +44,202 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
res.json(workspace);
|
||||
});
|
||||
|
||||
router.get("/execution-workspaces/:id/close-readiness", 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);
|
||||
const readiness = await svc.getCloseReadiness(id);
|
||||
if (!readiness) {
|
||||
res.status(404).json({ error: "Execution workspace not found" });
|
||||
return;
|
||||
}
|
||||
res.json(readiness);
|
||||
});
|
||||
|
||||
router.get("/execution-workspaces/:id/workspace-operations", 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);
|
||||
const operations = await workspaceOperationsSvc.listForExecutionWorkspace(id);
|
||||
res.json(operations);
|
||||
});
|
||||
|
||||
router.post("/execution-workspaces/:id/runtime-services/:action", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const action = String(req.params.action ?? "").trim().toLowerCase();
|
||||
if (action !== "start" && action !== "stop" && action !== "restart") {
|
||||
res.status(404).json({ error: "Runtime service action not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await svc.getById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Execution workspace not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const workspaceCwd = existing.cwd;
|
||||
if (!workspaceCwd) {
|
||||
res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can manage local runtime services" });
|
||||
return;
|
||||
}
|
||||
|
||||
const projectWorkspace = existing.projectWorkspaceId
|
||||
? await db
|
||||
.select({
|
||||
id: projectWorkspaces.id,
|
||||
cwd: projectWorkspaces.cwd,
|
||||
repoUrl: projectWorkspaces.repoUrl,
|
||||
repoRef: projectWorkspaces.repoRef,
|
||||
defaultRef: projectWorkspaces.defaultRef,
|
||||
metadata: projectWorkspaces.metadata,
|
||||
})
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.id, existing.projectWorkspaceId),
|
||||
eq(projectWorkspaces.companyId, existing.companyId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const projectWorkspaceRuntime = readProjectWorkspaceRuntimeConfig(
|
||||
(projectWorkspace?.metadata as Record<string, unknown> | null) ?? null,
|
||||
)?.workspaceRuntime ?? null;
|
||||
const effectiveRuntimeConfig = existing.config?.workspaceRuntime ?? projectWorkspaceRuntime ?? null;
|
||||
|
||||
if ((action === "start" || action === "restart") && !effectiveRuntimeConfig) {
|
||||
res.status(422).json({ error: "Execution workspace has no runtime service configuration or inherited project workspace default" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const recorder = workspaceOperationsSvc.createRecorder({
|
||||
companyId: existing.companyId,
|
||||
executionWorkspaceId: existing.id,
|
||||
});
|
||||
let runtimeServiceCount = existing.runtimeServices?.length ?? 0;
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
|
||||
const operation = await recorder.recordOperation({
|
||||
phase: action === "stop" ? "workspace_teardown" : "workspace_provision",
|
||||
command: `workspace runtime ${action}`,
|
||||
cwd: existing.cwd,
|
||||
metadata: {
|
||||
action,
|
||||
executionWorkspaceId: existing.id,
|
||||
},
|
||||
run: async () => {
|
||||
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
||||
if (stream === "stdout") stdout.push(chunk);
|
||||
else stderr.push(chunk);
|
||||
};
|
||||
|
||||
if (action === "stop" || action === "restart") {
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
db,
|
||||
executionWorkspaceId: existing.id,
|
||||
workspaceCwd,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "start" || action === "restart") {
|
||||
const startedServices = await startRuntimeServicesForWorkspaceControl({
|
||||
db,
|
||||
actor: {
|
||||
id: actor.agentId ?? null,
|
||||
name: actor.actorType === "user" ? "Board" : "Agent",
|
||||
companyId: existing.companyId,
|
||||
},
|
||||
issue: existing.sourceIssueId
|
||||
? {
|
||||
id: existing.sourceIssueId,
|
||||
identifier: null,
|
||||
title: existing.name,
|
||||
}
|
||||
: null,
|
||||
workspace: {
|
||||
baseCwd: workspaceCwd,
|
||||
source: existing.mode === "shared_workspace" ? "project_primary" : "task_session",
|
||||
projectId: existing.projectId,
|
||||
workspaceId: existing.projectWorkspaceId,
|
||||
repoUrl: existing.repoUrl,
|
||||
repoRef: existing.baseRef,
|
||||
strategy: existing.strategyType === "git_worktree" ? "git_worktree" : "project_primary",
|
||||
cwd: workspaceCwd,
|
||||
branchName: existing.branchName,
|
||||
worktreePath: existing.strategyType === "git_worktree" ? workspaceCwd : null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
},
|
||||
executionWorkspaceId: existing.id,
|
||||
config: { workspaceRuntime: effectiveRuntimeConfig },
|
||||
adapterEnv: {},
|
||||
onLog,
|
||||
});
|
||||
runtimeServiceCount = startedServices.length;
|
||||
} else {
|
||||
runtimeServiceCount = 0;
|
||||
}
|
||||
|
||||
const metadata = mergeExecutionWorkspaceConfig(existing.metadata as Record<string, unknown> | null, {
|
||||
desiredState: action === "stop" ? "stopped" : "running",
|
||||
});
|
||||
await svc.update(existing.id, { metadata });
|
||||
|
||||
return {
|
||||
status: "succeeded",
|
||||
stdout: stdout.join(""),
|
||||
stderr: stderr.join(""),
|
||||
system:
|
||||
action === "stop"
|
||||
? "Stopped execution workspace runtime services.\n"
|
||||
: action === "restart"
|
||||
? "Restarted execution workspace runtime services.\n"
|
||||
: "Started execution workspace runtime services.\n",
|
||||
metadata: {
|
||||
runtimeServiceCount,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const workspace = await svc.getById(id);
|
||||
if (!workspace) {
|
||||
res.status(404).json({ error: "Execution workspace not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: existing.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: `execution_workspace.runtime_${action}`,
|
||||
entityType: "execution_workspace",
|
||||
entityId: existing.id,
|
||||
details: {
|
||||
runtimeServiceCount,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
workspace,
|
||||
operation,
|
||||
});
|
||||
});
|
||||
|
||||
router.patch("/execution-workspaces/:id", validate(updateExecutionWorkspaceSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getById(id);
|
||||
|
|
@ -52,25 +249,43 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const patch: Record<string, unknown> = {
|
||||
...req.body,
|
||||
...(req.body.cleanupEligibleAt ? { cleanupEligibleAt: new Date(req.body.cleanupEligibleAt) } : {}),
|
||||
...(req.body.name === undefined ? {} : { name: req.body.name }),
|
||||
...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }),
|
||||
...(req.body.repoUrl === undefined ? {} : { repoUrl: req.body.repoUrl }),
|
||||
...(req.body.baseRef === undefined ? {} : { baseRef: req.body.baseRef }),
|
||||
...(req.body.branchName === undefined ? {} : { branchName: req.body.branchName }),
|
||||
...(req.body.providerRef === undefined ? {} : { providerRef: req.body.providerRef }),
|
||||
...(req.body.status === undefined ? {} : { status: req.body.status }),
|
||||
...(req.body.cleanupReason === undefined ? {} : { cleanupReason: req.body.cleanupReason }),
|
||||
...(req.body.cleanupEligibleAt !== undefined
|
||||
? { cleanupEligibleAt: req.body.cleanupEligibleAt ? new Date(req.body.cleanupEligibleAt) : null }
|
||||
: {}),
|
||||
};
|
||||
if (req.body.metadata !== undefined || req.body.config !== undefined) {
|
||||
const requestedMetadata = req.body.metadata === undefined
|
||||
? (existing.metadata as Record<string, unknown> | null)
|
||||
: (req.body.metadata as Record<string, unknown> | null);
|
||||
patch.metadata = req.body.config === undefined
|
||||
? requestedMetadata
|
||||
: mergeExecutionWorkspaceConfig(requestedMetadata, req.body.config ?? null);
|
||||
}
|
||||
let workspace = existing;
|
||||
let cleanupWarnings: string[] = [];
|
||||
const configForCleanup = readExecutionWorkspaceConfig(
|
||||
((patch.metadata as Record<string, unknown> | null | undefined) ?? (existing.metadata as Record<string, unknown> | null)) ?? null,
|
||||
);
|
||||
|
||||
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));
|
||||
const readiness = await svc.getCloseReadiness(existing.id);
|
||||
if (!readiness) {
|
||||
res.status(404).json({ error: "Execution workspace not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeLinkedIssues.length > 0) {
|
||||
if (readiness.state === "blocked") {
|
||||
res.status(409).json({
|
||||
error: `Cannot archive execution workspace while ${activeLinkedIssues.length} linked issue(s) are still open`,
|
||||
error: readiness.blockingReasons[0] ?? "Execution workspace cannot be closed right now",
|
||||
closeReadiness: readiness,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -88,6 +303,21 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
}
|
||||
workspace = archivedWorkspace;
|
||||
|
||||
if (existing.mode === "shared_workspace") {
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
executionWorkspaceId: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, existing.companyId),
|
||||
eq(issues.executionWorkspaceId, existing.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
db,
|
||||
|
|
@ -121,7 +351,8 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
const cleanupResult = await cleanupExecutionWorkspaceArtifacts({
|
||||
workspace: existing,
|
||||
projectWorkspace,
|
||||
teardownCommand: projectPolicy?.workspaceStrategy?.teardownCommand ?? null,
|
||||
teardownCommand: configForCleanup?.teardownCommand ?? projectPolicy?.workspaceStrategy?.teardownCommand ?? null,
|
||||
cleanupCommand: configForCleanup?.cleanupCommand ?? null,
|
||||
recorder: workspaceOperationsSvc.createRecorder({
|
||||
companyId: existing.companyId,
|
||||
executionWorkspaceId: existing.id,
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
inboxArchivedByUserId,
|
||||
unreadForUserId,
|
||||
projectId: req.query.projectId as string | undefined,
|
||||
executionWorkspaceId: req.query.executionWorkspaceId as string | undefined,
|
||||
parentId: req.query.parentId as string | undefined,
|
||||
labelId: req.query.labelId as string | undefined,
|
||||
originKind: req.query.originKind as string | undefined,
|
||||
|
|
|
|||
|
|
@ -8,13 +8,15 @@ import {
|
|||
updateProjectWorkspaceSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { projectService, logActivity } from "../services/index.js";
|
||||
import { projectService, logActivity, workspaceOperationService } from "../services/index.js";
|
||||
import { conflict } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js";
|
||||
|
||||
export function projectRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = projectService(db);
|
||||
const workspaceOperations = workspaceOperationService(db);
|
||||
|
||||
async function resolveCompanyIdForProjectReference(req: Request) {
|
||||
const companyIdQuery = req.query.companyId;
|
||||
|
|
@ -229,6 +231,145 @@ export function projectRoutes(db: Db) {
|
|||
},
|
||||
);
|
||||
|
||||
router.post("/projects/:id/workspaces/:workspaceId/runtime-services/:action", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const workspaceId = req.params.workspaceId as string;
|
||||
const action = String(req.params.action ?? "").trim().toLowerCase();
|
||||
if (action !== "start" && action !== "stop" && action !== "restart") {
|
||||
res.status(404).json({ error: "Runtime service action not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const project = await svc.getById(id);
|
||||
if (!project) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, project.companyId);
|
||||
|
||||
const workspace = project.workspaces.find((entry) => entry.id === workspaceId) ?? null;
|
||||
if (!workspace) {
|
||||
res.status(404).json({ error: "Project workspace not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceCwd = workspace.cwd;
|
||||
if (!workspaceCwd) {
|
||||
res.status(422).json({ error: "Project workspace needs a local path before Paperclip can manage local runtime services" });
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeConfig = workspace.runtimeConfig?.workspaceRuntime ?? null;
|
||||
if ((action === "start" || action === "restart") && !runtimeConfig) {
|
||||
res.status(422).json({ error: "Project workspace has no runtime service configuration" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const recorder = workspaceOperations.createRecorder({ companyId: project.companyId });
|
||||
let runtimeServiceCount = workspace.runtimeServices?.length ?? 0;
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
|
||||
const operation = await recorder.recordOperation({
|
||||
phase: action === "stop" ? "workspace_teardown" : "workspace_provision",
|
||||
command: `workspace runtime ${action}`,
|
||||
cwd: workspace.cwd,
|
||||
metadata: {
|
||||
action,
|
||||
projectId: project.id,
|
||||
projectWorkspaceId: workspace.id,
|
||||
},
|
||||
run: async () => {
|
||||
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
||||
if (stream === "stdout") stdout.push(chunk);
|
||||
else stderr.push(chunk);
|
||||
};
|
||||
|
||||
if (action === "stop" || action === "restart") {
|
||||
await stopRuntimeServicesForProjectWorkspace({
|
||||
db,
|
||||
projectWorkspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "start" || action === "restart") {
|
||||
const startedServices = await startRuntimeServicesForWorkspaceControl({
|
||||
db,
|
||||
actor: {
|
||||
id: actor.agentId ?? null,
|
||||
name: actor.actorType === "user" ? "Board" : "Agent",
|
||||
companyId: project.companyId,
|
||||
},
|
||||
issue: null,
|
||||
workspace: {
|
||||
baseCwd: workspaceCwd,
|
||||
source: "project_primary",
|
||||
projectId: project.id,
|
||||
workspaceId: workspace.id,
|
||||
repoUrl: workspace.repoUrl,
|
||||
repoRef: workspace.repoRef,
|
||||
strategy: "project_primary",
|
||||
cwd: workspaceCwd,
|
||||
branchName: workspace.defaultRef ?? workspace.repoRef ?? null,
|
||||
worktreePath: null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
},
|
||||
config: { workspaceRuntime: runtimeConfig },
|
||||
adapterEnv: {},
|
||||
onLog,
|
||||
});
|
||||
runtimeServiceCount = startedServices.length;
|
||||
} else {
|
||||
runtimeServiceCount = 0;
|
||||
}
|
||||
|
||||
await svc.updateWorkspace(project.id, workspace.id, {
|
||||
runtimeConfig: {
|
||||
desiredState: action === "stop" ? "stopped" : "running",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: "succeeded",
|
||||
stdout: stdout.join(""),
|
||||
stderr: stderr.join(""),
|
||||
system:
|
||||
action === "stop"
|
||||
? "Stopped project workspace runtime services.\n"
|
||||
: action === "restart"
|
||||
? "Restarted project workspace runtime services.\n"
|
||||
: "Started project workspace runtime services.\n",
|
||||
metadata: {
|
||||
runtimeServiceCount,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const updatedWorkspace = (await svc.listWorkspaces(project.id)).find((entry) => entry.id === workspace.id) ?? workspace;
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: project.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
action: `project.workspace_runtime_${action}`,
|
||||
entityType: "project",
|
||||
entityId: project.id,
|
||||
details: {
|
||||
projectWorkspaceId: workspace.id,
|
||||
runtimeServiceCount,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
workspace: updatedWorkspace,
|
||||
operation,
|
||||
});
|
||||
});
|
||||
|
||||
router.delete("/projects/:id/workspaces/:workspaceId", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const workspaceId = req.params.workspaceId as string;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,292 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
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";
|
||||
import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import type {
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceCloseAction,
|
||||
ExecutionWorkspaceCloseGitReadiness,
|
||||
ExecutionWorkspaceCloseReadiness,
|
||||
ExecutionWorkspaceConfig,
|
||||
WorkspaceRuntimeService,
|
||||
} from "@paperclipai/shared";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
|
||||
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
|
||||
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
||||
const execFileAsync = promisify(execFile);
|
||||
const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
|
||||
|
||||
function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace {
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readNullableString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function cloneRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
async function pathExists(value: string | null | undefined) {
|
||||
if (!value) return false;
|
||||
try {
|
||||
await fs.access(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runGit(args: string[], cwd: string) {
|
||||
return await execFileAsync("git", ["-C", cwd, ...args], { cwd });
|
||||
}
|
||||
|
||||
async function inspectGitCloseReadiness(workspace: ExecutionWorkspace): Promise<{
|
||||
git: ExecutionWorkspaceCloseGitReadiness | null;
|
||||
warnings: string[];
|
||||
}> {
|
||||
const warnings: string[] = [];
|
||||
const workspacePath = readNullableString(workspace.providerRef) ?? readNullableString(workspace.cwd);
|
||||
const createdByRuntime = workspace.metadata?.createdByRuntime === true;
|
||||
const expectsGitInspection =
|
||||
workspace.providerType === "git_worktree" ||
|
||||
Boolean(workspace.repoUrl || workspace.baseRef || workspace.branchName || workspacePath);
|
||||
|
||||
if (!expectsGitInspection) {
|
||||
return { git: null, warnings };
|
||||
}
|
||||
|
||||
if (!workspacePath) {
|
||||
warnings.push("Workspace has no local path, so Paperclip cannot inspect git status before close.");
|
||||
return { git: null, warnings };
|
||||
}
|
||||
|
||||
if (!(await pathExists(workspacePath))) {
|
||||
warnings.push(`Workspace path "${workspacePath}" does not exist, so Paperclip cannot inspect git status before close.`);
|
||||
return {
|
||||
git: {
|
||||
repoRoot: null,
|
||||
workspacePath,
|
||||
branchName: workspace.branchName,
|
||||
baseRef: workspace.baseRef,
|
||||
hasDirtyTrackedFiles: false,
|
||||
hasUntrackedFiles: false,
|
||||
dirtyEntryCount: 0,
|
||||
untrackedEntryCount: 0,
|
||||
aheadCount: null,
|
||||
behindCount: null,
|
||||
isMergedIntoBase: null,
|
||||
createdByRuntime,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
let repoRoot: string | null = null;
|
||||
try {
|
||||
repoRoot = (await runGit(["rev-parse", "--show-toplevel"], workspacePath)).stdout.trim() || null;
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`Could not inspect git status for "${workspacePath}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
let branchName = workspace.branchName;
|
||||
if (repoRoot && !branchName) {
|
||||
try {
|
||||
branchName = (await runGit(["rev-parse", "--abbrev-ref", "HEAD"], workspacePath)).stdout.trim() || null;
|
||||
} catch {
|
||||
branchName = workspace.branchName;
|
||||
}
|
||||
}
|
||||
|
||||
let dirtyEntryCount = 0;
|
||||
let untrackedEntryCount = 0;
|
||||
if (repoRoot) {
|
||||
try {
|
||||
const statusOutput = (await runGit(["status", "--porcelain=v1", "--untracked-files=all"], workspacePath)).stdout;
|
||||
for (const line of statusOutput.split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
if (line.startsWith("??")) {
|
||||
untrackedEntryCount += 1;
|
||||
continue;
|
||||
}
|
||||
dirtyEntryCount += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`Could not read git working tree status for "${workspacePath}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let aheadCount: number | null = null;
|
||||
let behindCount: number | null = null;
|
||||
let isMergedIntoBase: boolean | null = null;
|
||||
const baseRef = workspace.baseRef;
|
||||
|
||||
if (repoRoot && baseRef) {
|
||||
try {
|
||||
const counts = (await runGit(["rev-list", "--left-right", "--count", `${baseRef}...HEAD`], workspacePath)).stdout.trim();
|
||||
const [behindRaw, aheadRaw] = counts.split(/\s+/);
|
||||
behindCount = behindRaw ? Number.parseInt(behindRaw, 10) : 0;
|
||||
aheadCount = aheadRaw ? Number.parseInt(aheadRaw, 10) : 0;
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`Could not compare this workspace against ${baseRef}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await runGit(["merge-base", "--is-ancestor", "HEAD", baseRef], workspacePath);
|
||||
isMergedIntoBase = true;
|
||||
} catch (error) {
|
||||
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : null;
|
||||
if (code === 1) isMergedIntoBase = false;
|
||||
else {
|
||||
warnings.push(
|
||||
`Could not determine whether this workspace is merged into ${baseRef}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
git: {
|
||||
repoRoot,
|
||||
workspacePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
hasDirtyTrackedFiles: dirtyEntryCount > 0,
|
||||
hasUntrackedFiles: untrackedEntryCount > 0,
|
||||
dirtyEntryCount,
|
||||
untrackedEntryCount,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
isMergedIntoBase,
|
||||
createdByRuntime,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function readExecutionWorkspaceConfig(metadata: Record<string, unknown> | null | undefined): ExecutionWorkspaceConfig | null {
|
||||
const raw = isRecord(metadata?.config) ? metadata.config : null;
|
||||
if (!raw) return null;
|
||||
|
||||
const config: ExecutionWorkspaceConfig = {
|
||||
provisionCommand: readNullableString(raw.provisionCommand),
|
||||
teardownCommand: readNullableString(raw.teardownCommand),
|
||||
cleanupCommand: readNullableString(raw.cleanupCommand),
|
||||
workspaceRuntime: cloneRecord(raw.workspaceRuntime),
|
||||
desiredState: raw.desiredState === "running" || raw.desiredState === "stopped" ? raw.desiredState : null,
|
||||
};
|
||||
|
||||
const hasConfig = Object.values(config).some((value) => {
|
||||
if (value === null) return false;
|
||||
if (typeof value === "object") return Object.keys(value).length > 0;
|
||||
return true;
|
||||
});
|
||||
|
||||
return hasConfig ? config : null;
|
||||
}
|
||||
|
||||
export function mergeExecutionWorkspaceConfig(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
patch: Partial<ExecutionWorkspaceConfig> | null,
|
||||
): Record<string, unknown> | null {
|
||||
const nextMetadata = isRecord(metadata) ? { ...metadata } : {};
|
||||
const current = readExecutionWorkspaceConfig(metadata) ?? {
|
||||
provisionCommand: null,
|
||||
teardownCommand: null,
|
||||
cleanupCommand: null,
|
||||
workspaceRuntime: null,
|
||||
desiredState: null,
|
||||
};
|
||||
|
||||
if (patch === null) {
|
||||
delete nextMetadata.config;
|
||||
return Object.keys(nextMetadata).length > 0 ? nextMetadata : null;
|
||||
}
|
||||
|
||||
const nextConfig: ExecutionWorkspaceConfig = {
|
||||
provisionCommand: patch.provisionCommand !== undefined ? readNullableString(patch.provisionCommand) : current.provisionCommand,
|
||||
teardownCommand: patch.teardownCommand !== undefined ? readNullableString(patch.teardownCommand) : current.teardownCommand,
|
||||
cleanupCommand: patch.cleanupCommand !== undefined ? readNullableString(patch.cleanupCommand) : current.cleanupCommand,
|
||||
workspaceRuntime: patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime,
|
||||
desiredState:
|
||||
patch.desiredState !== undefined
|
||||
? patch.desiredState === "running" || patch.desiredState === "stopped"
|
||||
? patch.desiredState
|
||||
: null
|
||||
: current.desiredState,
|
||||
};
|
||||
|
||||
const hasConfig = Object.values(nextConfig).some((value) => {
|
||||
if (value === null) return false;
|
||||
if (typeof value === "object") return Object.keys(value).length > 0;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (hasConfig) {
|
||||
nextMetadata.config = {
|
||||
provisionCommand: nextConfig.provisionCommand,
|
||||
teardownCommand: nextConfig.teardownCommand,
|
||||
cleanupCommand: nextConfig.cleanupCommand,
|
||||
workspaceRuntime: nextConfig.workspaceRuntime,
|
||||
desiredState: nextConfig.desiredState,
|
||||
};
|
||||
} else {
|
||||
delete nextMetadata.config;
|
||||
}
|
||||
|
||||
return Object.keys(nextMetadata).length > 0 ? nextMetadata : null;
|
||||
}
|
||||
|
||||
function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeService {
|
||||
return {
|
||||
id: row.id,
|
||||
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,
|
||||
serviceName: row.serviceName,
|
||||
status: row.status as WorkspaceRuntimeService["status"],
|
||||
lifecycle: row.lifecycle as WorkspaceRuntimeService["lifecycle"],
|
||||
reuseKey: row.reuseKey ?? null,
|
||||
command: row.command ?? null,
|
||||
cwd: row.cwd ?? null,
|
||||
port: row.port ?? null,
|
||||
url: row.url ?? null,
|
||||
provider: row.provider as WorkspaceRuntimeService["provider"],
|
||||
providerRef: row.providerRef ?? null,
|
||||
ownerAgentId: row.ownerAgentId ?? null,
|
||||
startedByRunId: row.startedByRunId ?? null,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
startedAt: row.startedAt,
|
||||
stoppedAt: row.stoppedAt ?? null,
|
||||
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
|
||||
healthStatus: row.healthStatus as WorkspaceRuntimeService["healthStatus"],
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toExecutionWorkspace(
|
||||
row: ExecutionWorkspaceRow,
|
||||
runtimeServices: WorkspaceRuntimeService[] = [],
|
||||
): ExecutionWorkspace {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
|
|
@ -28,7 +309,9 @@ function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace {
|
|||
closedAt: row.closedAt ?? null,
|
||||
cleanupEligibleAt: row.cleanupEligibleAt ?? null,
|
||||
cleanupReason: row.cleanupReason ?? null,
|
||||
config: readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null),
|
||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||
runtimeServices,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
|
|
@ -63,7 +346,7 @@ export function executionWorkspaceService(db: Db) {
|
|||
.from(executionWorkspaces)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
|
||||
return rows.map(toExecutionWorkspace);
|
||||
return rows.map((row) => toExecutionWorkspace(row));
|
||||
},
|
||||
|
||||
getById: async (id: string) => {
|
||||
|
|
@ -72,7 +355,268 @@ export function executionWorkspaceService(db: Db) {
|
|||
.from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toExecutionWorkspace(row) : null;
|
||||
if (!row) return null;
|
||||
const runtimeServiceRows = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id))
|
||||
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
||||
return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService));
|
||||
},
|
||||
|
||||
getCloseReadiness: async (id: string): Promise<ExecutionWorkspaceCloseReadiness | null> => {
|
||||
const workspace = await db
|
||||
.select()
|
||||
.from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!workspace) return null;
|
||||
|
||||
const runtimeServiceRows = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id))
|
||||
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
||||
const runtimeServices = runtimeServiceRows.map(toRuntimeService);
|
||||
|
||||
const linkedIssues = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, workspace.companyId), eq(issues.executionWorkspaceId, workspace.id)));
|
||||
|
||||
const projectWorkspace = workspace.projectWorkspaceId
|
||||
? await db
|
||||
.select({
|
||||
id: projectWorkspaces.id,
|
||||
cwd: projectWorkspaces.cwd,
|
||||
cleanupCommand: projectWorkspaces.cleanupCommand,
|
||||
isPrimary: projectWorkspaces.isPrimary,
|
||||
})
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, workspace.companyId),
|
||||
eq(projectWorkspaces.id, workspace.projectWorkspaceId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
|
||||
const primaryProjectWorkspace = workspace.projectId
|
||||
? await db
|
||||
.select({
|
||||
id: projectWorkspaces.id,
|
||||
})
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, workspace.companyId),
|
||||
eq(projectWorkspaces.projectId, workspace.projectId),
|
||||
eq(projectWorkspaces.isPrimary, true),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
|
||||
const projectPolicy = workspace.projectId
|
||||
? await db
|
||||
.select({
|
||||
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||
})
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, workspace.companyId)))
|
||||
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy))
|
||||
: null;
|
||||
|
||||
const executionWorkspace = toExecutionWorkspace(workspace, runtimeServices);
|
||||
const config = readExecutionWorkspaceConfig((workspace.metadata as Record<string, unknown> | null) ?? null);
|
||||
const { git, warnings: gitWarnings } = await inspectGitCloseReadiness(executionWorkspace);
|
||||
const warnings = [...gitWarnings];
|
||||
const blockingReasons: string[] = [];
|
||||
const isSharedWorkspace = executionWorkspace.mode === "shared_workspace";
|
||||
const workspacePath = readNullableString(executionWorkspace.providerRef) ?? readNullableString(executionWorkspace.cwd);
|
||||
const resolvedWorkspacePath = workspacePath ? path.resolve(workspacePath) : null;
|
||||
const resolvedPrimaryWorkspacePath = projectWorkspace?.cwd ? path.resolve(projectWorkspace.cwd) : null;
|
||||
const isProjectPrimaryWorkspace =
|
||||
workspace.projectWorkspaceId != null
|
||||
&& workspace.projectWorkspaceId === primaryProjectWorkspace?.id
|
||||
&& resolvedWorkspacePath != null
|
||||
&& resolvedPrimaryWorkspacePath != null
|
||||
&& resolvedWorkspacePath === resolvedPrimaryWorkspacePath;
|
||||
|
||||
const linkedIssueSummaries = linkedIssues.map((issue) => ({
|
||||
...issue,
|
||||
isTerminal: TERMINAL_ISSUE_STATUSES.has(issue.status),
|
||||
}));
|
||||
|
||||
const blockingIssues = linkedIssueSummaries.filter((issue) => !issue.isTerminal);
|
||||
if (blockingIssues.length > 0) {
|
||||
const linkedIssueMessage =
|
||||
blockingIssues.length === 1
|
||||
? "This workspace is still linked to an open issue."
|
||||
: `This workspace is still linked to ${blockingIssues.length} open issues.`;
|
||||
if (isSharedWorkspace) {
|
||||
warnings.push(`${linkedIssueMessage} Archiving it will detach this shared workspace session from those issues, but keep the underlying project workspace available.`);
|
||||
} else {
|
||||
blockingReasons.push(linkedIssueMessage);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSharedWorkspace) {
|
||||
warnings.push("This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record.");
|
||||
}
|
||||
|
||||
if (runtimeServices.some((service) => service.status !== "stopped")) {
|
||||
warnings.push(
|
||||
runtimeServices.length === 1
|
||||
? "Closing this workspace will stop 1 attached runtime service."
|
||||
: `Closing this workspace will stop ${runtimeServices.length} attached runtime services.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (git?.hasDirtyTrackedFiles) {
|
||||
warnings.push(
|
||||
git.dirtyEntryCount === 1
|
||||
? "The workspace has 1 modified tracked file."
|
||||
: `The workspace has ${git.dirtyEntryCount} modified tracked files.`,
|
||||
);
|
||||
}
|
||||
if (git?.hasUntrackedFiles) {
|
||||
warnings.push(
|
||||
git.untrackedEntryCount === 1
|
||||
? "The workspace has 1 untracked file."
|
||||
: `The workspace has ${git.untrackedEntryCount} untracked files.`,
|
||||
);
|
||||
}
|
||||
if (git?.aheadCount && git.aheadCount > 0 && git.isMergedIntoBase === false) {
|
||||
warnings.push(
|
||||
git.aheadCount === 1
|
||||
? `This workspace is 1 commit ahead of ${git.baseRef ?? "the base ref"} and is not merged.`
|
||||
: `This workspace is ${git.aheadCount} commits ahead of ${git.baseRef ?? "the base ref"} and is not merged.`,
|
||||
);
|
||||
}
|
||||
if (git?.behindCount && git.behindCount > 0) {
|
||||
warnings.push(
|
||||
git.behindCount === 1
|
||||
? `This workspace is 1 commit behind ${git.baseRef ?? "the base ref"}.`
|
||||
: `This workspace is ${git.behindCount} commits behind ${git.baseRef ?? "the base ref"}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const plannedActions: ExecutionWorkspaceCloseAction[] = [
|
||||
{
|
||||
kind: "archive_record",
|
||||
label: "Archive workspace record",
|
||||
description: "Keep the execution workspace history and issue linkage, but remove it from active workspace lists.",
|
||||
command: null,
|
||||
},
|
||||
];
|
||||
|
||||
if (runtimeServices.some((service) => service.status !== "stopped")) {
|
||||
plannedActions.push({
|
||||
kind: "stop_runtime_services",
|
||||
label: runtimeServices.length === 1 ? "Stop attached runtime service" : "Stop attached runtime services",
|
||||
description:
|
||||
runtimeServices.length === 1
|
||||
? `${runtimeServices[0]?.serviceName ?? "A runtime service"} will be stopped before cleanup.`
|
||||
: `${runtimeServices.length} runtime services will be stopped before cleanup.`,
|
||||
command: null,
|
||||
});
|
||||
}
|
||||
|
||||
const configuredCleanupCommands = [
|
||||
{
|
||||
kind: "cleanup_command" as const,
|
||||
label: "Run workspace cleanup command",
|
||||
description: "Workspace-specific cleanup runs before teardown.",
|
||||
command: config?.cleanupCommand ?? null,
|
||||
},
|
||||
{
|
||||
kind: "cleanup_command" as const,
|
||||
label: "Run project workspace cleanup command",
|
||||
description: "Project workspace cleanup runs before execution workspace teardown.",
|
||||
command: projectWorkspace?.cleanupCommand ?? null,
|
||||
},
|
||||
];
|
||||
for (const action of configuredCleanupCommands) {
|
||||
if (!action.command) continue;
|
||||
plannedActions.push(action);
|
||||
}
|
||||
|
||||
const teardownCommand = config?.teardownCommand ?? projectPolicy?.workspaceStrategy?.teardownCommand ?? null;
|
||||
if (teardownCommand) {
|
||||
plannedActions.push({
|
||||
kind: "teardown_command",
|
||||
label: "Run teardown command",
|
||||
description: "Teardown runs after cleanup commands during workspace close.",
|
||||
command: teardownCommand,
|
||||
});
|
||||
}
|
||||
|
||||
if (executionWorkspace.providerType === "git_worktree" && workspacePath) {
|
||||
plannedActions.push({
|
||||
kind: "git_worktree_remove",
|
||||
label: "Remove git worktree",
|
||||
description: `Paperclip will run git worktree cleanup for ${workspacePath}.`,
|
||||
command: `git worktree remove --force ${workspacePath}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (git?.createdByRuntime && executionWorkspace.branchName) {
|
||||
plannedActions.push({
|
||||
kind: "git_branch_delete",
|
||||
label: "Delete runtime-created branch",
|
||||
description: "Paperclip will try to delete the runtime-created branch after removing the worktree.",
|
||||
command: `git branch -d ${executionWorkspace.branchName}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (executionWorkspace.providerType === "local_fs" && git?.createdByRuntime && workspacePath) {
|
||||
const resolvedWorkspacePath = path.resolve(workspacePath);
|
||||
const resolvedProjectWorkspacePath = projectWorkspace?.cwd ? path.resolve(projectWorkspace.cwd) : null;
|
||||
const containsProjectWorkspace = resolvedProjectWorkspacePath
|
||||
? (
|
||||
resolvedWorkspacePath === resolvedProjectWorkspacePath ||
|
||||
resolvedProjectWorkspacePath.startsWith(`${resolvedWorkspacePath}${path.sep}`)
|
||||
)
|
||||
: false;
|
||||
if (containsProjectWorkspace) {
|
||||
warnings.push(`Paperclip will archive this workspace but keep "${workspacePath}" because it contains the project workspace.`);
|
||||
} else {
|
||||
plannedActions.push({
|
||||
kind: "remove_local_directory",
|
||||
label: "Remove runtime-created directory",
|
||||
description: `Paperclip will remove the runtime-created directory at ${workspacePath}.`,
|
||||
command: `rm -rf ${workspacePath}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const state =
|
||||
blockingReasons.length > 0
|
||||
? "blocked"
|
||||
: warnings.length > 0
|
||||
? "ready_with_warnings"
|
||||
: "ready";
|
||||
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
state,
|
||||
blockingReasons,
|
||||
warnings,
|
||||
linkedIssues: linkedIssueSummaries,
|
||||
plannedActions,
|
||||
isDestructiveCloseAllowed: blockingReasons.length === 0,
|
||||
isSharedWorkspace,
|
||||
isProjectPrimaryWorkspace,
|
||||
git,
|
||||
runtimeServices,
|
||||
};
|
||||
},
|
||||
|
||||
create: async (data: typeof executionWorkspaces.$inferInsert) => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ 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";
|
||||
import type { BillingType, ExecutionWorkspaceConfig } from "@paperclipai/shared";
|
||||
import {
|
||||
agents,
|
||||
agentRuntimeState,
|
||||
|
|
@ -40,7 +40,7 @@ import {
|
|||
sanitizeRuntimeServiceBaseEnv,
|
||||
} from "./workspace-runtime.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
||||
import { workspaceOperationService } from "./workspace-operations.js";
|
||||
import {
|
||||
buildExecutionWorkspaceAdapterConfig,
|
||||
|
|
@ -76,6 +76,61 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
|
|||
"pi_local",
|
||||
]);
|
||||
|
||||
export function applyPersistedExecutionWorkspaceConfig(input: {
|
||||
config: Record<string, unknown>;
|
||||
workspaceConfig: ExecutionWorkspaceConfig | null;
|
||||
mode: ReturnType<typeof resolveExecutionWorkspaceMode>;
|
||||
}) {
|
||||
const nextConfig = { ...input.config };
|
||||
|
||||
if (input.mode !== "agent_default") {
|
||||
if (input.workspaceConfig?.workspaceRuntime === null) {
|
||||
delete nextConfig.workspaceRuntime;
|
||||
} else if (input.workspaceConfig?.workspaceRuntime) {
|
||||
nextConfig.workspaceRuntime = { ...input.workspaceConfig.workspaceRuntime };
|
||||
}
|
||||
}
|
||||
|
||||
if (input.workspaceConfig && input.mode === "isolated_workspace") {
|
||||
const nextStrategy = parseObject(nextConfig.workspaceStrategy);
|
||||
if (input.workspaceConfig.provisionCommand === null) delete nextStrategy.provisionCommand;
|
||||
else nextStrategy.provisionCommand = input.workspaceConfig.provisionCommand;
|
||||
if (input.workspaceConfig.teardownCommand === null) delete nextStrategy.teardownCommand;
|
||||
else nextStrategy.teardownCommand = input.workspaceConfig.teardownCommand;
|
||||
nextConfig.workspaceStrategy = nextStrategy;
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
export function stripWorkspaceRuntimeFromExecutionRunConfig(config: Record<string, unknown>) {
|
||||
const nextConfig = { ...config };
|
||||
delete nextConfig.workspaceRuntime;
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>): Partial<ExecutionWorkspaceConfig> | null {
|
||||
const strategy = parseObject(config.workspaceStrategy);
|
||||
const snapshot: Partial<ExecutionWorkspaceConfig> = {};
|
||||
|
||||
if ("workspaceStrategy" in config) {
|
||||
snapshot.provisionCommand = typeof strategy.provisionCommand === "string" ? strategy.provisionCommand : null;
|
||||
snapshot.teardownCommand = typeof strategy.teardownCommand === "string" ? strategy.teardownCommand : null;
|
||||
}
|
||||
|
||||
if ("workspaceRuntime" in config) {
|
||||
const workspaceRuntime = parseObject(config.workspaceRuntime);
|
||||
snapshot.workspaceRuntime = Object.keys(workspaceRuntime).length > 0 ? workspaceRuntime : null;
|
||||
}
|
||||
|
||||
const hasSnapshot = Object.values(snapshot).some((value) => {
|
||||
if (value === null) return false;
|
||||
if (typeof value === "object") return Object.keys(value).length > 0;
|
||||
return true;
|
||||
});
|
||||
return hasSnapshot ? snapshot : null;
|
||||
}
|
||||
|
||||
function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null {
|
||||
const trimmed = repoUrl?.trim() ?? "";
|
||||
if (!trimmed) return null;
|
||||
|
|
@ -2048,18 +2103,6 @@ export function heartbeatService(db: Db) {
|
|||
mode: executionWorkspaceMode,
|
||||
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
|
||||
});
|
||||
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
||||
? { ...workspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
|
||||
: workspaceManagedConfig;
|
||||
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
agent.companyId,
|
||||
mergedConfig,
|
||||
);
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
|
||||
const runtimeConfig = {
|
||||
...resolvedConfig,
|
||||
paperclipRuntimeSkills: runtimeSkillEntries,
|
||||
};
|
||||
const issueRef = issueContext
|
||||
? {
|
||||
id: issueContext.id,
|
||||
|
|
@ -2073,6 +2116,25 @@ export function heartbeatService(db: Db) {
|
|||
: null;
|
||||
const existingExecutionWorkspace =
|
||||
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
||||
const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({
|
||||
config: workspaceManagedConfig,
|
||||
workspaceConfig: existingExecutionWorkspace?.config ?? null,
|
||||
mode: executionWorkspaceMode,
|
||||
});
|
||||
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
||||
? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
|
||||
: persistedWorkspaceManagedConfig;
|
||||
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig);
|
||||
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
||||
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
agent.companyId,
|
||||
executionRunConfig,
|
||||
);
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
|
||||
const runtimeConfig = {
|
||||
...resolvedConfig,
|
||||
paperclipRuntimeSkills: runtimeSkillEntries,
|
||||
};
|
||||
const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({
|
||||
companyId: agent.companyId,
|
||||
heartbeatRunId: run.id,
|
||||
|
|
@ -2103,6 +2165,14 @@ export function heartbeatService(db: Db) {
|
|||
existingExecutionWorkspace &&
|
||||
existingExecutionWorkspace.status !== "archived";
|
||||
let persistedExecutionWorkspace = null;
|
||||
const nextExecutionWorkspaceMetadataBase = {
|
||||
...(existingExecutionWorkspace?.metadata ?? {}),
|
||||
source: executionWorkspace.source,
|
||||
createdByRuntime: executionWorkspace.created,
|
||||
} as Record<string, unknown>;
|
||||
const nextExecutionWorkspaceMetadata = configSnapshot
|
||||
? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot)
|
||||
: nextExecutionWorkspaceMetadataBase;
|
||||
try {
|
||||
persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
|
||||
? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, {
|
||||
|
|
@ -2114,11 +2184,7 @@ export function heartbeatService(db: Db) {
|
|||
providerRef: executionWorkspace.worktreePath,
|
||||
status: "active",
|
||||
lastUsedAt: new Date(),
|
||||
metadata: {
|
||||
...(existingExecutionWorkspace.metadata ?? {}),
|
||||
source: executionWorkspace.source,
|
||||
createdByRuntime: executionWorkspace.created,
|
||||
},
|
||||
metadata: nextExecutionWorkspaceMetadata,
|
||||
})
|
||||
: resolvedProjectId
|
||||
? await executionWorkspacesSvc.create({
|
||||
|
|
@ -2145,10 +2211,7 @@ export function heartbeatService(db: Db) {
|
|||
providerRef: executionWorkspace.worktreePath,
|
||||
lastUsedAt: new Date(),
|
||||
openedAt: new Date(),
|
||||
metadata: {
|
||||
source: executionWorkspace.source,
|
||||
createdByRuntime: executionWorkspace.created,
|
||||
},
|
||||
metadata: nextExecutionWorkspaceMetadata,
|
||||
})
|
||||
: null;
|
||||
} catch (error) {
|
||||
|
|
@ -2175,7 +2238,8 @@ export function heartbeatService(db: Db) {
|
|||
cwd: resolvedWorkspace.cwd,
|
||||
cleanupCommand: null,
|
||||
},
|
||||
teardownCommand: projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null,
|
||||
cleanupCommand: configSnapshot?.cleanupCommand ?? null,
|
||||
teardownCommand: configSnapshot?.teardownCommand ?? projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null,
|
||||
recorder: workspaceOperationRecorder,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
|
|
|
|||
|
|
@ -28,5 +28,5 @@ 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";
|
||||
export { reconcilePersistedRuntimeServicesOnStartup } from "./workspace-runtime.js";
|
||||
export { reconcilePersistedRuntimeServicesOnStartup, restartDesiredRuntimeServicesOnStartup } from "./workspace-runtime.js";
|
||||
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export interface IssueFilters {
|
|||
inboxArchivedByUserId?: string;
|
||||
unreadForUserId?: string;
|
||||
projectId?: string;
|
||||
executionWorkspaceId?: string;
|
||||
parentId?: string;
|
||||
labelId?: string;
|
||||
originKind?: string;
|
||||
|
|
@ -647,6 +648,9 @@ export function issueService(db: Db) {
|
|||
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
|
||||
}
|
||||
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
|
||||
if (filters?.executionWorkspaceId) {
|
||||
conditions.push(eq(issues.executionWorkspaceId, filters.executionWorkspaceId));
|
||||
}
|
||||
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
|
||||
if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
|
||||
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
|
||||
|
|
|
|||
302
server/src/services/local-service-supervisor.ts
Normal file
302
server/src/services/local-service-supervisor.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface LocalServiceRegistryRecord {
|
||||
version: 1;
|
||||
serviceKey: string;
|
||||
profileKind: string;
|
||||
serviceName: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
envFingerprint: string;
|
||||
port: number | null;
|
||||
url: string | null;
|
||||
pid: number;
|
||||
processGroupId: number | null;
|
||||
provider: "local_process";
|
||||
runtimeServiceId: string | null;
|
||||
reuseKey: string | null;
|
||||
startedAt: string;
|
||||
lastSeenAt: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface LocalServiceIdentityInput {
|
||||
profileKind: string;
|
||||
serviceName: string;
|
||||
cwd: string;
|
||||
command: string;
|
||||
envFingerprint: string;
|
||||
port: number | null;
|
||||
scope: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const rec = value as Record<string, unknown>;
|
||||
return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(rec[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function sanitizeServiceKeySegment(value: string, fallback: string): string {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function getRuntimeServicesDir() {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "runtime-services");
|
||||
}
|
||||
|
||||
function getRuntimeServiceRegistryPath(serviceKey: string) {
|
||||
return path.resolve(getRuntimeServicesDir(), `${serviceKey}.json`);
|
||||
}
|
||||
|
||||
function normalizeRegistryRecord(raw: unknown): LocalServiceRegistryRecord | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (
|
||||
rec.version !== 1 ||
|
||||
typeof rec.serviceKey !== "string" ||
|
||||
typeof rec.profileKind !== "string" ||
|
||||
typeof rec.serviceName !== "string" ||
|
||||
typeof rec.command !== "string" ||
|
||||
typeof rec.cwd !== "string" ||
|
||||
typeof rec.envFingerprint !== "string" ||
|
||||
typeof rec.pid !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
serviceKey: rec.serviceKey,
|
||||
profileKind: rec.profileKind,
|
||||
serviceName: rec.serviceName,
|
||||
command: rec.command,
|
||||
cwd: rec.cwd,
|
||||
envFingerprint: rec.envFingerprint,
|
||||
port: typeof rec.port === "number" ? rec.port : null,
|
||||
url: typeof rec.url === "string" ? rec.url : null,
|
||||
pid: rec.pid,
|
||||
processGroupId: typeof rec.processGroupId === "number" ? rec.processGroupId : null,
|
||||
provider: "local_process",
|
||||
runtimeServiceId: typeof rec.runtimeServiceId === "string" ? rec.runtimeServiceId : null,
|
||||
reuseKey: typeof rec.reuseKey === "string" ? rec.reuseKey : null,
|
||||
startedAt: typeof rec.startedAt === "string" ? rec.startedAt : new Date().toISOString(),
|
||||
lastSeenAt: typeof rec.lastSeenAt === "string" ? rec.lastSeenAt : new Date().toISOString(),
|
||||
metadata:
|
||||
rec.metadata && typeof rec.metadata === "object" && !Array.isArray(rec.metadata)
|
||||
? (rec.metadata as Record<string, unknown>)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function safeReadRegistryRecord(filePath: string) {
|
||||
try {
|
||||
const raw = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
|
||||
return normalizeRegistryRecord(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalServiceKey(input: LocalServiceIdentityInput) {
|
||||
const digest = createHash("sha256")
|
||||
.update(
|
||||
stableStringify({
|
||||
profileKind: input.profileKind,
|
||||
serviceName: input.serviceName,
|
||||
cwd: path.resolve(input.cwd),
|
||||
command: input.command,
|
||||
envFingerprint: input.envFingerprint,
|
||||
port: input.port,
|
||||
scope: input.scope ?? null,
|
||||
}),
|
||||
)
|
||||
.digest("hex")
|
||||
.slice(0, 24);
|
||||
|
||||
return `${sanitizeServiceKeySegment(input.profileKind, "service")}-${sanitizeServiceKeySegment(input.serviceName, "service")}-${digest}`;
|
||||
}
|
||||
|
||||
export async function writeLocalServiceRegistryRecord(record: LocalServiceRegistryRecord) {
|
||||
await fs.mkdir(getRuntimeServicesDir(), { recursive: true });
|
||||
await fs.writeFile(
|
||||
getRuntimeServiceRegistryPath(record.serviceKey),
|
||||
`${JSON.stringify(record, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeLocalServiceRegistryRecord(serviceKey: string) {
|
||||
await fs.rm(getRuntimeServiceRegistryPath(serviceKey), { force: true });
|
||||
}
|
||||
|
||||
export async function readLocalServiceRegistryRecord(serviceKey: string) {
|
||||
return await safeReadRegistryRecord(getRuntimeServiceRegistryPath(serviceKey));
|
||||
}
|
||||
|
||||
export async function listLocalServiceRegistryRecords(filter?: {
|
||||
profileKind?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
try {
|
||||
const entries = await fs.readdir(getRuntimeServicesDir(), { withFileTypes: true });
|
||||
const records = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
||||
.map((entry) => safeReadRegistryRecord(path.resolve(getRuntimeServicesDir(), entry.name))),
|
||||
);
|
||||
|
||||
return records
|
||||
.filter((record): record is LocalServiceRegistryRecord => record !== null)
|
||||
.filter((record) => {
|
||||
if (filter?.profileKind && record.profileKind !== filter.profileKind) return false;
|
||||
if (!filter?.metadata) return true;
|
||||
return Object.entries(filter.metadata).every(([key, value]) => record.metadata?.[key] === value);
|
||||
})
|
||||
.sort((left, right) => left.serviceKey.localeCompare(right.serviceKey));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: {
|
||||
runtimeServiceId: string;
|
||||
profileKind?: string;
|
||||
}) {
|
||||
const records = await listLocalServiceRegistryRecords(
|
||||
input.profileKind ? { profileKind: input.profileKind } : undefined,
|
||||
);
|
||||
return records.find((record) => record.runtimeServiceId === input.runtimeServiceId) ?? null;
|
||||
}
|
||||
|
||||
export function isPidAlive(pid: number) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
|
||||
if (process.platform === "win32") return true;
|
||||
try {
|
||||
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
|
||||
const commandLine = stdout.trim();
|
||||
if (!commandLine) return false;
|
||||
return commandLine.includes(record.command) || commandLine.includes(record.serviceName);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findAdoptableLocalService(input: {
|
||||
serviceKey: string;
|
||||
command?: string | null;
|
||||
cwd?: string | null;
|
||||
envFingerprint?: string | null;
|
||||
port?: number | null;
|
||||
}) {
|
||||
const record = await readLocalServiceRegistryRecord(input.serviceKey);
|
||||
if (!record) return null;
|
||||
|
||||
if (!isPidAlive(record.pid)) {
|
||||
await removeLocalServiceRegistryRecord(input.serviceKey);
|
||||
return null;
|
||||
}
|
||||
if (!(await isLikelyMatchingCommand(record))) {
|
||||
await removeLocalServiceRegistryRecord(input.serviceKey);
|
||||
return null;
|
||||
}
|
||||
if (input.command && record.command !== input.command) return null;
|
||||
if (input.cwd && path.resolve(record.cwd) !== path.resolve(input.cwd)) return null;
|
||||
if (input.envFingerprint && record.envFingerprint !== input.envFingerprint) return null;
|
||||
if (input.port !== undefined && input.port !== null && record.port !== input.port) return null;
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function touchLocalServiceRegistryRecord(
|
||||
serviceKey: string,
|
||||
patch?: Partial<Omit<LocalServiceRegistryRecord, "serviceKey" | "version">>,
|
||||
) {
|
||||
const existing = await readLocalServiceRegistryRecord(serviceKey);
|
||||
if (!existing) return null;
|
||||
const next: LocalServiceRegistryRecord = {
|
||||
...existing,
|
||||
...patch,
|
||||
version: 1,
|
||||
serviceKey,
|
||||
lastSeenAt: patch?.lastSeenAt ?? new Date().toISOString(),
|
||||
};
|
||||
await writeLocalServiceRegistryRecord(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function terminateLocalService(
|
||||
record: Pick<LocalServiceRegistryRecord, "pid" | "processGroupId">,
|
||||
opts?: { signal?: NodeJS.Signals; forceAfterMs?: number },
|
||||
) {
|
||||
const signal = opts?.signal ?? "SIGTERM";
|
||||
const targetProcessGroup = process.platform !== "win32" && record.processGroupId && record.processGroupId > 0;
|
||||
try {
|
||||
if (targetProcessGroup) {
|
||||
process.kill(-record.processGroupId!, signal);
|
||||
} else {
|
||||
process.kill(record.pid, signal);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + (opts?.forceAfterMs ?? 2_000);
|
||||
while (Date.now() < deadline) {
|
||||
if (!isPidAlive(record.pid)) {
|
||||
return;
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
|
||||
if (!isPidAlive(record.pid)) return;
|
||||
try {
|
||||
if (targetProcessGroup) {
|
||||
process.kill(-record.processGroupId!, "SIGKILL");
|
||||
} else {
|
||||
process.kill(record.pid, "SIGKILL");
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup races.
|
||||
}
|
||||
}
|
||||
|
||||
export async function readLocalServicePortOwner(port: number) {
|
||||
if (!Number.isInteger(port) || port <= 0 || process.platform === "win32") return null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync("lsof", ["-nPiTCP", `:${port}`, "-sTCP:LISTEN", "-t"]);
|
||||
const firstPid = stdout
|
||||
.split("\n")
|
||||
.map((line) => Number.parseInt(line.trim(), 10))
|
||||
.find((value) => Number.isInteger(value) && value > 0);
|
||||
return firstPid ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
59
server/src/services/project-workspace-runtime-config.ts
Normal file
59
server/src/services/project-workspace-runtime-config.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { ProjectWorkspaceRuntimeConfig } from "@paperclipai/shared";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneRecord(value: unknown): Record<string, unknown> | null {
|
||||
return isRecord(value) ? { ...value } : null;
|
||||
}
|
||||
|
||||
function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desiredState"] {
|
||||
return value === "running" || value === "stopped" ? value : null;
|
||||
}
|
||||
|
||||
export function readProjectWorkspaceRuntimeConfig(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): ProjectWorkspaceRuntimeConfig | null {
|
||||
const raw = isRecord(metadata?.runtimeConfig) ? metadata.runtimeConfig : null;
|
||||
if (!raw) return null;
|
||||
|
||||
const config: ProjectWorkspaceRuntimeConfig = {
|
||||
workspaceRuntime: cloneRecord(raw.workspaceRuntime),
|
||||
desiredState: readDesiredState(raw.desiredState),
|
||||
};
|
||||
|
||||
const hasConfig = config.workspaceRuntime !== null || config.desiredState !== null;
|
||||
return hasConfig ? config : null;
|
||||
}
|
||||
|
||||
export function mergeProjectWorkspaceRuntimeConfig(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
patch: Partial<ProjectWorkspaceRuntimeConfig> | null,
|
||||
): Record<string, unknown> | null {
|
||||
const nextMetadata = isRecord(metadata) ? { ...metadata } : {};
|
||||
const current = readProjectWorkspaceRuntimeConfig(metadata) ?? {
|
||||
workspaceRuntime: null,
|
||||
desiredState: null,
|
||||
};
|
||||
|
||||
if (patch === null) {
|
||||
delete nextMetadata.runtimeConfig;
|
||||
return Object.keys(nextMetadata).length > 0 ? nextMetadata : null;
|
||||
}
|
||||
|
||||
const nextConfig: ProjectWorkspaceRuntimeConfig = {
|
||||
workspaceRuntime:
|
||||
patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime,
|
||||
desiredState:
|
||||
patch.desiredState !== undefined ? readDesiredState(patch.desiredState) : current.desiredState,
|
||||
};
|
||||
|
||||
if (nextConfig.workspaceRuntime === null && nextConfig.desiredState === null) {
|
||||
delete nextMetadata.runtimeConfig;
|
||||
} else {
|
||||
nextMetadata.runtimeConfig = nextConfig;
|
||||
}
|
||||
|
||||
return Object.keys(nextMetadata).length > 0 ? nextMetadata : null;
|
||||
}
|
||||
|
|
@ -9,11 +9,13 @@ import {
|
|||
type ProjectCodebase,
|
||||
type ProjectExecutionWorkspacePolicy,
|
||||
type ProjectGoalRef,
|
||||
type ProjectWorkspaceRuntimeConfig,
|
||||
type ProjectWorkspace,
|
||||
type WorkspaceRuntimeService,
|
||||
} from "@paperclipai/shared";
|
||||
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
||||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
|
||||
type ProjectRow = typeof projects.$inferSelect;
|
||||
|
|
@ -34,6 +36,7 @@ type CreateWorkspaceInput = {
|
|||
remoteWorkspaceRef?: string | null;
|
||||
sharedWorkspaceKey?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
runtimeConfig?: Partial<ProjectWorkspaceRuntimeConfig> | null;
|
||||
isPrimary?: boolean;
|
||||
};
|
||||
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
|
||||
|
|
@ -149,6 +152,7 @@ function toWorkspace(
|
|||
remoteWorkspaceRef: row.remoteWorkspaceRef ?? null,
|
||||
sharedWorkspaceKey: row.sharedWorkspaceKey ?? null,
|
||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||
runtimeConfig: readProjectWorkspaceRuntimeConfig((row.metadata as Record<string, unknown> | null) ?? null),
|
||||
isPrimary: row.isPrimary,
|
||||
runtimeServices,
|
||||
createdAt: row.createdAt,
|
||||
|
|
@ -611,7 +615,13 @@ export function projectService(db: Db) {
|
|||
remoteProvider: readNonEmptyString(data.remoteProvider),
|
||||
remoteWorkspaceRef,
|
||||
sharedWorkspaceKey: readNonEmptyString(data.sharedWorkspaceKey),
|
||||
metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
|
||||
metadata:
|
||||
data.runtimeConfig !== undefined
|
||||
? mergeProjectWorkspaceRuntimeConfig(
|
||||
(data.metadata as Record<string, unknown> | null | undefined) ?? null,
|
||||
data.runtimeConfig ?? null,
|
||||
)
|
||||
: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
|
||||
isPrimary: shouldBePrimary,
|
||||
})
|
||||
.returning()
|
||||
|
|
@ -681,7 +691,17 @@ export function projectService(db: Db) {
|
|||
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;
|
||||
if (data.metadata !== undefined || data.runtimeConfig !== undefined) {
|
||||
patch.metadata =
|
||||
data.runtimeConfig !== undefined
|
||||
? mergeProjectWorkspaceRuntimeConfig(
|
||||
data.metadata !== undefined
|
||||
? (data.metadata as Record<string, unknown> | null | undefined)
|
||||
: ((existing.metadata as Record<string, unknown> | null | undefined) ?? null),
|
||||
data.runtimeConfig ?? null,
|
||||
)
|
||||
: data.metadata;
|
||||
}
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
if (data.isPrimary === true) {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,23 @@ import path from "node:path";
|
|||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import { executionWorkspaces, projectWorkspaces, 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 {
|
||||
createLocalServiceKey,
|
||||
findLocalServiceRegistryRecordByRuntimeServiceId,
|
||||
findAdoptableLocalService,
|
||||
readLocalServicePortOwner,
|
||||
removeLocalServiceRegistryRecord,
|
||||
terminateLocalService,
|
||||
touchLocalServiceRegistryRecord,
|
||||
writeLocalServiceRegistryRecord,
|
||||
} from "./local-service-supervisor.js";
|
||||
import type { WorkspaceOperationRecorder } from "./workspace-operations.js";
|
||||
import { readExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
||||
import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
||||
|
||||
export interface ExecutionWorkspaceInput {
|
||||
baseCwd: string;
|
||||
|
|
@ -28,7 +40,7 @@ export interface ExecutionWorkspaceIssueRef {
|
|||
}
|
||||
|
||||
export interface ExecutionWorkspaceAgentRef {
|
||||
id: string;
|
||||
id: string | null;
|
||||
name: string;
|
||||
companyId: string;
|
||||
}
|
||||
|
|
@ -77,12 +89,24 @@ interface RuntimeServiceRecord extends RuntimeServiceRef {
|
|||
leaseRunIds: Set<string>;
|
||||
idleTimer: ReturnType<typeof globalThis.setTimeout> | null;
|
||||
envFingerprint: string;
|
||||
serviceKey: string;
|
||||
profileKind: string;
|
||||
processGroupId: number | null;
|
||||
}
|
||||
|
||||
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
|
||||
const runtimeServicesByReuseKey = new Map<string, string>();
|
||||
const runtimeServiceLeasesByRun = new Map<string, string[]>();
|
||||
|
||||
export async function resetRuntimeServicesForTests() {
|
||||
for (const record of runtimeServicesById.values()) {
|
||||
clearIdleTimer(record);
|
||||
}
|
||||
runtimeServicesById.clear();
|
||||
runtimeServicesByReuseKey.clear();
|
||||
runtimeServiceLeasesByRun.clear();
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
||||
|
|
@ -102,6 +126,8 @@ export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJ
|
|||
}
|
||||
}
|
||||
delete env.DATABASE_URL;
|
||||
delete env.npm_config_tailscale_auth;
|
||||
delete env.npm_config_authenticated_private;
|
||||
return env;
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +215,7 @@ function renderWorkspaceTemplate(template: string, input: {
|
|||
title: input.issue?.title ?? "",
|
||||
},
|
||||
agent: {
|
||||
id: input.agent.id,
|
||||
id: input.agent.id ?? "",
|
||||
name: input.agent.name,
|
||||
},
|
||||
project: {
|
||||
|
|
@ -312,7 +338,7 @@ function buildWorkspaceCommandEnv(input: {
|
|||
env.PAPERCLIP_WORKSPACE_CREATED = input.created ? "true" : "false";
|
||||
env.PAPERCLIP_PROJECT_ID = input.base.projectId ?? "";
|
||||
env.PAPERCLIP_PROJECT_WORKSPACE_ID = input.base.workspaceId ?? "";
|
||||
env.PAPERCLIP_AGENT_ID = input.agent.id;
|
||||
env.PAPERCLIP_AGENT_ID = input.agent.id ?? "";
|
||||
env.PAPERCLIP_AGENT_NAME = input.agent.name;
|
||||
env.PAPERCLIP_COMPANY_ID = input.agent.companyId;
|
||||
env.PAPERCLIP_ISSUE_ID = input.issue?.id ?? "";
|
||||
|
|
@ -702,6 +728,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
|||
cwd: string | null;
|
||||
cleanupCommand: string | null;
|
||||
} | null;
|
||||
cleanupCommand?: string | null;
|
||||
teardownCommand?: string | null;
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
}) {
|
||||
|
|
@ -713,6 +740,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
|||
});
|
||||
const createdByRuntime = input.workspace.metadata?.createdByRuntime === true;
|
||||
const cleanupCommands = [
|
||||
input.cleanupCommand ?? null,
|
||||
input.projectWorkspace?.cleanupCommand ?? null,
|
||||
input.teardownCommand ?? null,
|
||||
]
|
||||
|
|
@ -879,13 +907,95 @@ function buildTemplateData(input: {
|
|||
title: input.issue?.title ?? "",
|
||||
},
|
||||
agent: {
|
||||
id: input.agent.id,
|
||||
id: input.agent.id ?? "",
|
||||
name: input.agent.name,
|
||||
},
|
||||
port: input.port ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function renderRuntimeServiceEnv(input: {
|
||||
envConfig: Record<string, unknown>;
|
||||
templateData: ReturnType<typeof buildTemplateData>;
|
||||
}) {
|
||||
const rendered: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input.envConfig)) {
|
||||
if (typeof value !== "string") continue;
|
||||
rendered[key] = renderTemplate(value, input.templateData);
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function resolveRuntimeServiceReuseIdentity(input: {
|
||||
service: Record<string, unknown>;
|
||||
workspace: RealizedExecutionWorkspace;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
adapterEnv: Record<string, string>;
|
||||
scopeType: RuntimeServiceRef["scopeType"];
|
||||
scopeId: string | null;
|
||||
}): {
|
||||
serviceName: string;
|
||||
lifecycle: RuntimeServiceRef["lifecycle"];
|
||||
command: string;
|
||||
serviceCwd: string;
|
||||
envConfig: Record<string, unknown>;
|
||||
envFingerprint: string;
|
||||
explicitPort: number;
|
||||
identityPort: number | null;
|
||||
reuseKey: string | null;
|
||||
} {
|
||||
const serviceName = asString(input.service.name, "service");
|
||||
const lifecycle = asString(input.service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared";
|
||||
const command = asString(input.service.command, "");
|
||||
const serviceCwdTemplate = asString(input.service.cwd, ".");
|
||||
const portConfig = parseObject(input.service.port);
|
||||
const envConfig = parseObject(input.service.env);
|
||||
const explicitPort = asNumber(portConfig.value, asNumber(input.service.port, 0));
|
||||
const identityPort = explicitPort > 0 ? explicitPort : null;
|
||||
const templateData = buildTemplateData({
|
||||
workspace: input.workspace,
|
||||
agent: input.agent,
|
||||
issue: input.issue,
|
||||
adapterEnv: input.adapterEnv,
|
||||
port: identityPort,
|
||||
});
|
||||
const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd);
|
||||
const renderedEnv = renderRuntimeServiceEnv({
|
||||
envConfig,
|
||||
templateData,
|
||||
});
|
||||
const envFingerprint = createHash("sha256").update(stableStringify(renderedEnv)).digest("hex");
|
||||
const reuseKey =
|
||||
lifecycle === "shared"
|
||||
? createHash("sha256")
|
||||
.update(
|
||||
stableStringify({
|
||||
scopeType: input.scopeType,
|
||||
scopeId: input.scopeId,
|
||||
serviceName,
|
||||
command,
|
||||
cwd: serviceCwd,
|
||||
port: identityPort,
|
||||
env: renderedEnv,
|
||||
}),
|
||||
)
|
||||
.digest("hex")
|
||||
: null;
|
||||
|
||||
return {
|
||||
serviceName,
|
||||
lifecycle,
|
||||
command,
|
||||
serviceCwd,
|
||||
envConfig,
|
||||
envFingerprint,
|
||||
explicitPort,
|
||||
identityPort,
|
||||
reuseKey,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveServiceScopeId(input: {
|
||||
service: Record<string, unknown>;
|
||||
workspace: RealizedExecutionWorkspace;
|
||||
|
|
@ -1067,7 +1177,7 @@ export function normalizeAdapterManagedRuntimeServices(input: {
|
|||
url: report.url ?? null,
|
||||
provider: "adapter_managed",
|
||||
providerRef: report.providerRef ?? null,
|
||||
ownerAgentId: report.ownerAgentId ?? input.agent.id,
|
||||
ownerAgentId: report.ownerAgentId ?? input.agent.id ?? null,
|
||||
startedByRunId: input.runId,
|
||||
lastUsedAt: nowIso,
|
||||
startedAt: nowIso,
|
||||
|
|
@ -1093,14 +1203,31 @@ async function startLocalRuntimeService(input: {
|
|||
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||
scopeId: string | null;
|
||||
}): Promise<RuntimeServiceRecord> {
|
||||
const serviceName = asString(input.service.name, "service");
|
||||
const lifecycle = asString(input.service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared";
|
||||
const command = asString(input.service.command, "");
|
||||
const identity = resolveRuntimeServiceReuseIdentity({
|
||||
service: input.service,
|
||||
workspace: input.workspace,
|
||||
agent: input.agent,
|
||||
issue: input.issue,
|
||||
adapterEnv: input.adapterEnv,
|
||||
scopeType: input.scopeType,
|
||||
scopeId: input.scopeId,
|
||||
});
|
||||
const serviceName = identity.serviceName;
|
||||
const lifecycle = identity.lifecycle;
|
||||
const command = identity.command;
|
||||
if (!command) throw new Error(`Runtime service "${serviceName}" is missing command`);
|
||||
const serviceCwdTemplate = asString(input.service.cwd, ".");
|
||||
const portConfig = parseObject(input.service.port);
|
||||
const port = asString(portConfig.type, "") === "auto" ? await allocatePort() : null;
|
||||
const envConfig = parseObject(input.service.env);
|
||||
const envConfig = identity.envConfig;
|
||||
const envFingerprint = identity.envFingerprint;
|
||||
const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint;
|
||||
const explicitPort = identity.explicitPort;
|
||||
const identityPort = identity.identityPort;
|
||||
const port =
|
||||
asString(portConfig.type, "") === "auto"
|
||||
? await allocatePort()
|
||||
: explicitPort > 0
|
||||
? explicitPort
|
||||
: null;
|
||||
const templateData = buildTemplateData({
|
||||
workspace: input.workspace,
|
||||
agent: input.agent,
|
||||
|
|
@ -1108,20 +1235,95 @@ async function startLocalRuntimeService(input: {
|
|||
adapterEnv: input.adapterEnv,
|
||||
port,
|
||||
});
|
||||
const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd);
|
||||
const serviceCwd =
|
||||
port === identityPort
|
||||
? identity.serviceCwd
|
||||
: resolveConfiguredPath(renderTemplate(asString(input.service.cwd, "."), templateData), input.workspace.cwd);
|
||||
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);
|
||||
}
|
||||
for (const [key, value] of Object.entries(renderRuntimeServiceEnv({ envConfig, templateData }))) {
|
||||
env[key] = value;
|
||||
}
|
||||
if (port) {
|
||||
const portEnvKey = asString(portConfig.envKey, "PORT");
|
||||
env[portEnvKey] = String(port);
|
||||
}
|
||||
const expose = parseObject(input.service.expose);
|
||||
const readiness = parseObject(input.service.readiness);
|
||||
const urlTemplate =
|
||||
asString(expose.urlTemplate, "") ||
|
||||
asString(readiness.urlTemplate, "");
|
||||
const url = urlTemplate ? renderTemplate(urlTemplate, templateData) : null;
|
||||
const stopPolicy = parseObject(input.service.stopPolicy);
|
||||
const serviceKey = createLocalServiceKey({
|
||||
profileKind: "workspace-runtime",
|
||||
serviceName,
|
||||
cwd: serviceCwd,
|
||||
command,
|
||||
envFingerprint: serviceIdentityFingerprint,
|
||||
port: identityPort,
|
||||
scope: {
|
||||
scopeType: input.scopeType,
|
||||
scopeId: input.scopeId,
|
||||
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||
reuseKey: input.reuseKey,
|
||||
},
|
||||
});
|
||||
const adoptedRecord = await findAdoptableLocalService({
|
||||
serviceKey,
|
||||
command,
|
||||
cwd: serviceCwd,
|
||||
envFingerprint: serviceIdentityFingerprint,
|
||||
port: identityPort,
|
||||
});
|
||||
if (adoptedRecord) {
|
||||
return {
|
||||
id: adoptedRecord.runtimeServiceId ?? randomUUID(),
|
||||
companyId: input.agent.companyId,
|
||||
projectId: input.workspace.projectId,
|
||||
projectWorkspaceId: input.workspace.workspaceId,
|
||||
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||
issueId: input.issue?.id ?? null,
|
||||
serviceName,
|
||||
status: "running",
|
||||
lifecycle,
|
||||
scopeType: input.scopeType,
|
||||
scopeId: input.scopeId,
|
||||
reuseKey: input.reuseKey,
|
||||
command,
|
||||
cwd: serviceCwd,
|
||||
port: adoptedRecord.port ?? port,
|
||||
url: adoptedRecord.url ?? url,
|
||||
provider: "local_process",
|
||||
providerRef: String(adoptedRecord.pid),
|
||||
ownerAgentId: input.agent.id ?? null,
|
||||
startedByRunId: input.runId,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
startedAt: adoptedRecord.startedAt,
|
||||
stoppedAt: null,
|
||||
stopPolicy,
|
||||
healthStatus: "healthy",
|
||||
reused: true,
|
||||
db: input.db,
|
||||
child: null,
|
||||
leaseRunIds: new Set([input.runId]),
|
||||
idleTimer: null,
|
||||
envFingerprint,
|
||||
serviceKey,
|
||||
profileKind: "workspace-runtime",
|
||||
processGroupId: adoptedRecord.processGroupId ?? null,
|
||||
};
|
||||
}
|
||||
if (identityPort) {
|
||||
const ownerPid = await readLocalServicePortOwner(identityPort);
|
||||
if (ownerPid) {
|
||||
throw new Error(
|
||||
`Runtime service "${serviceName}" could not start because port ${identityPort} is already in use by pid ${ownerPid}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const shell = process.env.SHELL?.trim() || "/bin/sh";
|
||||
const child = spawn(shell, ["-lc", command], {
|
||||
cwd: serviceCwd,
|
||||
|
|
@ -1142,13 +1344,6 @@ async function startLocalRuntimeService(input: {
|
|||
if (input.onLog) await input.onLog("stderr", `[service:${serviceName}] ${text}`);
|
||||
});
|
||||
|
||||
const expose = parseObject(input.service.expose);
|
||||
const readiness = parseObject(input.service.readiness);
|
||||
const urlTemplate =
|
||||
asString(expose.urlTemplate, "") ||
|
||||
asString(readiness.urlTemplate, "");
|
||||
const url = urlTemplate ? renderTemplate(urlTemplate, templateData) : null;
|
||||
|
||||
try {
|
||||
await waitForReadiness({ service: input.service, url });
|
||||
} catch (err) {
|
||||
|
|
@ -1158,8 +1353,7 @@ async function startLocalRuntimeService(input: {
|
|||
);
|
||||
}
|
||||
|
||||
const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex");
|
||||
return {
|
||||
const record: RuntimeServiceRecord = {
|
||||
id: randomUUID(),
|
||||
companyId: input.agent.companyId,
|
||||
projectId: input.workspace.projectId,
|
||||
|
|
@ -1178,12 +1372,12 @@ async function startLocalRuntimeService(input: {
|
|||
url,
|
||||
provider: "local_process",
|
||||
providerRef: child.pid ? String(child.pid) : null,
|
||||
ownerAgentId: input.agent.id,
|
||||
ownerAgentId: input.agent.id ?? null,
|
||||
startedByRunId: input.runId,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
startedAt: new Date().toISOString(),
|
||||
stoppedAt: null,
|
||||
stopPolicy: parseObject(input.service.stopPolicy),
|
||||
stopPolicy,
|
||||
healthStatus: "healthy",
|
||||
reused: false,
|
||||
db: input.db,
|
||||
|
|
@ -1191,7 +1385,41 @@ async function startLocalRuntimeService(input: {
|
|||
leaseRunIds: new Set([input.runId]),
|
||||
idleTimer: null,
|
||||
envFingerprint,
|
||||
serviceKey,
|
||||
profileKind: "workspace-runtime",
|
||||
processGroupId: child.pid ?? null,
|
||||
};
|
||||
|
||||
if (child.pid) {
|
||||
await writeLocalServiceRegistryRecord({
|
||||
version: 1,
|
||||
serviceKey,
|
||||
profileKind: "workspace-runtime",
|
||||
serviceName,
|
||||
command,
|
||||
cwd: serviceCwd,
|
||||
envFingerprint: serviceIdentityFingerprint,
|
||||
port,
|
||||
url,
|
||||
pid: child.pid,
|
||||
processGroupId: child.pid,
|
||||
provider: "local_process",
|
||||
runtimeServiceId: record.id,
|
||||
reuseKey: input.reuseKey,
|
||||
startedAt: record.startedAt,
|
||||
lastSeenAt: record.lastUsedAt,
|
||||
metadata: {
|
||||
projectId: record.projectId,
|
||||
projectWorkspaceId: record.projectWorkspaceId,
|
||||
executionWorkspaceId: record.executionWorkspaceId,
|
||||
issueId: record.issueId,
|
||||
scopeType: record.scopeType,
|
||||
scopeId: record.scopeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
function scheduleIdleStop(record: RuntimeServiceRecord) {
|
||||
|
|
@ -1209,15 +1437,28 @@ async function stopRuntimeService(serviceId: string) {
|
|||
if (!record) return;
|
||||
clearIdleTimer(record);
|
||||
record.status = "stopped";
|
||||
record.healthStatus = "unknown";
|
||||
record.lastUsedAt = new Date().toISOString();
|
||||
record.stoppedAt = new Date().toISOString();
|
||||
if (record.child && record.child.pid) {
|
||||
terminateChildProcess(record.child);
|
||||
}
|
||||
runtimeServicesById.delete(serviceId);
|
||||
if (record.reuseKey) {
|
||||
if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) {
|
||||
runtimeServicesByReuseKey.delete(record.reuseKey);
|
||||
}
|
||||
if (record.child && record.child.pid) {
|
||||
await terminateLocalService({
|
||||
pid: record.child.pid,
|
||||
processGroupId: record.processGroupId ?? record.child.pid,
|
||||
});
|
||||
} else if (record.providerRef) {
|
||||
const pid = Number.parseInt(record.providerRef, 10);
|
||||
if (Number.isInteger(pid) && pid > 0) {
|
||||
await terminateLocalService({
|
||||
pid,
|
||||
processGroupId: record.processGroupId,
|
||||
});
|
||||
}
|
||||
}
|
||||
await removeLocalServiceRegistryRecord(record.serviceKey);
|
||||
await persistRuntimeServiceRecord(record.db, record);
|
||||
}
|
||||
|
||||
|
|
@ -1262,10 +1503,18 @@ function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord
|
|||
if (current.reuseKey && runtimeServicesByReuseKey.get(current.reuseKey) === current.id) {
|
||||
runtimeServicesByReuseKey.delete(current.reuseKey);
|
||||
}
|
||||
void removeLocalServiceRegistryRecord(current.serviceKey);
|
||||
void persistRuntimeServiceRecord(db, current);
|
||||
});
|
||||
}
|
||||
|
||||
function readRuntimeServiceEntries(config: Record<string, unknown>) {
|
||||
const runtime = parseObject(config.workspaceRuntime);
|
||||
return Array.isArray(runtime.services)
|
||||
? runtime.services.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null)
|
||||
: [];
|
||||
}
|
||||
|
||||
export async function ensureRuntimeServicesForRun(input: {
|
||||
db?: Db;
|
||||
runId: string;
|
||||
|
|
@ -1277,17 +1526,13 @@ export async function ensureRuntimeServicesForRun(input: {
|
|||
adapterEnv: Record<string, string>;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
}): Promise<RuntimeServiceRef[]> {
|
||||
const runtime = parseObject(input.config.workspaceRuntime);
|
||||
const rawServices = Array.isArray(runtime.services)
|
||||
? runtime.services.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null)
|
||||
: [];
|
||||
const rawServices = readRuntimeServiceEntries(input.config);
|
||||
const acquiredServiceIds: string[] = [];
|
||||
const refs: RuntimeServiceRef[] = [];
|
||||
runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds);
|
||||
|
||||
try {
|
||||
for (const service of rawServices) {
|
||||
const lifecycle = asString(service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared";
|
||||
const { scopeType, scopeId } = resolveServiceScopeId({
|
||||
service,
|
||||
workspace: input.workspace,
|
||||
|
|
@ -1296,13 +1541,15 @@ export async function ensureRuntimeServicesForRun(input: {
|
|||
runId: input.runId,
|
||||
agent: input.agent,
|
||||
});
|
||||
const envConfig = parseObject(service.env);
|
||||
const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex");
|
||||
const serviceName = asString(service.name, "service");
|
||||
const reuseKey =
|
||||
lifecycle === "shared"
|
||||
? [scopeType, scopeId ?? "", serviceName, envFingerprint].join(":")
|
||||
: null;
|
||||
const reuseKey = resolveRuntimeServiceReuseIdentity({
|
||||
service,
|
||||
workspace: input.workspace,
|
||||
agent: input.agent,
|
||||
issue: input.issue,
|
||||
adapterEnv: input.adapterEnv,
|
||||
scopeType,
|
||||
scopeId,
|
||||
}).reuseKey;
|
||||
|
||||
if (reuseKey) {
|
||||
const existingId = runtimeServicesByReuseKey.get(reuseKey);
|
||||
|
|
@ -1312,6 +1559,10 @@ export async function ensureRuntimeServicesForRun(input: {
|
|||
existing.lastUsedAt = new Date().toISOString();
|
||||
existing.stoppedAt = null;
|
||||
clearIdleTimer(existing);
|
||||
void touchLocalServiceRegistryRecord(existing.serviceKey, {
|
||||
runtimeServiceId: existing.id,
|
||||
lastSeenAt: existing.lastUsedAt,
|
||||
});
|
||||
await persistRuntimeServiceRecord(input.db, existing);
|
||||
acquiredServiceIds.push(existing.id);
|
||||
refs.push(toRuntimeServiceRef(existing, { reused: true }));
|
||||
|
|
@ -1346,6 +1597,80 @@ export async function ensureRuntimeServicesForRun(input: {
|
|||
return refs;
|
||||
}
|
||||
|
||||
export async function startRuntimeServicesForWorkspaceControl(input: {
|
||||
db?: Db;
|
||||
invocationId?: string;
|
||||
actor: 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>;
|
||||
}): Promise<RuntimeServiceRef[]> {
|
||||
const rawServices = readRuntimeServiceEntries(input.config);
|
||||
const refs: RuntimeServiceRef[] = [];
|
||||
const invocationId = input.invocationId ?? randomUUID();
|
||||
|
||||
for (const service of rawServices) {
|
||||
const { scopeType, scopeId } = resolveServiceScopeId({
|
||||
service,
|
||||
workspace: input.workspace,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
issue: input.issue,
|
||||
runId: invocationId,
|
||||
agent: input.actor,
|
||||
});
|
||||
const reuseKey = resolveRuntimeServiceReuseIdentity({
|
||||
service,
|
||||
workspace: input.workspace,
|
||||
agent: input.actor,
|
||||
issue: input.issue,
|
||||
adapterEnv: input.adapterEnv,
|
||||
scopeType,
|
||||
scopeId,
|
||||
}).reuseKey;
|
||||
|
||||
if (reuseKey) {
|
||||
const existingId = runtimeServicesByReuseKey.get(reuseKey);
|
||||
const existing = existingId ? runtimeServicesById.get(existingId) : null;
|
||||
if (existing && existing.status === "running") {
|
||||
existing.lastUsedAt = new Date().toISOString();
|
||||
existing.stoppedAt = null;
|
||||
clearIdleTimer(existing);
|
||||
void touchLocalServiceRegistryRecord(existing.serviceKey, {
|
||||
runtimeServiceId: existing.id,
|
||||
lastSeenAt: existing.lastUsedAt,
|
||||
});
|
||||
await persistRuntimeServiceRecord(input.db, existing);
|
||||
refs.push(toRuntimeServiceRef(existing, { reused: true }));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const record = await startLocalRuntimeService({
|
||||
db: input.db,
|
||||
runId: invocationId,
|
||||
agent: input.actor,
|
||||
issue: input.issue,
|
||||
workspace: input.workspace,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
adapterEnv: input.adapterEnv,
|
||||
service,
|
||||
onLog: input.onLog,
|
||||
reuseKey,
|
||||
scopeType,
|
||||
scopeId,
|
||||
});
|
||||
record.startedByRunId = null;
|
||||
registerRuntimeService(input.db, record);
|
||||
await persistRuntimeServiceRecord(input.db, record);
|
||||
refs.push(toRuntimeServiceRef(record));
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
export async function releaseRuntimeServicesForRun(runId: string) {
|
||||
const acquired = runtimeServiceLeasesByRun.get(runId) ?? [];
|
||||
runtimeServiceLeasesByRun.delete(runId);
|
||||
|
|
@ -1396,6 +1721,39 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: {
|
|||
}
|
||||
}
|
||||
|
||||
export async function stopRuntimeServicesForProjectWorkspace(input: {
|
||||
db?: Db;
|
||||
projectWorkspaceId: string;
|
||||
}) {
|
||||
const matchingServiceIds = Array.from(runtimeServicesById.values())
|
||||
.filter((record) => record.projectWorkspaceId === input.projectWorkspaceId && record.scopeType === "project_workspace")
|
||||
.map((record) => record.id);
|
||||
|
||||
for (const serviceId of matchingServiceIds) {
|
||||
await stopRuntimeService(serviceId);
|
||||
}
|
||||
|
||||
if (input.db) {
|
||||
const now = new Date();
|
||||
await input.db
|
||||
.update(workspaceRuntimeServices)
|
||||
.set({
|
||||
status: "stopped",
|
||||
healthStatus: "unknown",
|
||||
stoppedAt: now,
|
||||
lastUsedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId),
|
||||
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
|
||||
inArray(workspaceRuntimeServices.status, ["starting", "running"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listWorkspaceRuntimeServicesForProjectWorkspaces(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
|
|
@ -1409,6 +1767,7 @@ export async function listWorkspaceRuntimeServicesForProjectWorkspaces(
|
|||
and(
|
||||
eq(workspaceRuntimeServices.companyId, companyId),
|
||||
inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds),
|
||||
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
||||
|
|
@ -1424,8 +1783,8 @@ export async function listWorkspaceRuntimeServicesForProjectWorkspaces(
|
|||
}
|
||||
|
||||
export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
|
||||
const staleRows = await db
|
||||
.select({ id: workspaceRuntimeServices.id })
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(
|
||||
and(
|
||||
|
|
@ -1434,7 +1793,61 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
|
|||
),
|
||||
);
|
||||
|
||||
if (staleRows.length === 0) return { reconciled: 0 };
|
||||
if (rows.length === 0) return { reconciled: 0, adopted: 0, stopped: 0 };
|
||||
|
||||
let adopted = 0;
|
||||
let stopped = 0;
|
||||
for (const row of rows) {
|
||||
const adoptedRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({
|
||||
runtimeServiceId: row.id,
|
||||
profileKind: "workspace-runtime",
|
||||
});
|
||||
if (adoptedRecord) {
|
||||
const record: RuntimeServiceRecord = {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId ?? null,
|
||||
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||
issueId: row.issueId ?? null,
|
||||
serviceName: row.serviceName,
|
||||
status: "running",
|
||||
lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"],
|
||||
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
|
||||
scopeId: row.scopeId ?? null,
|
||||
reuseKey: row.reuseKey ?? null,
|
||||
command: row.command ?? null,
|
||||
cwd: row.cwd ?? null,
|
||||
port: adoptedRecord.port ?? row.port ?? null,
|
||||
url: adoptedRecord.url ?? row.url ?? null,
|
||||
provider: "local_process",
|
||||
providerRef: String(adoptedRecord.pid),
|
||||
ownerAgentId: row.ownerAgentId ?? null,
|
||||
startedByRunId: row.startedByRunId ?? null,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
startedAt: row.startedAt.toISOString(),
|
||||
stoppedAt: null,
|
||||
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
|
||||
healthStatus: "healthy",
|
||||
reused: true,
|
||||
db,
|
||||
child: null,
|
||||
leaseRunIds: new Set(),
|
||||
idleTimer: null,
|
||||
envFingerprint: row.reuseKey ?? "",
|
||||
serviceKey: adoptedRecord.serviceKey,
|
||||
profileKind: "workspace-runtime",
|
||||
processGroupId: adoptedRecord.processGroupId ?? null,
|
||||
};
|
||||
registerRuntimeService(db, record);
|
||||
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
|
||||
runtimeServiceId: row.id,
|
||||
lastSeenAt: record.lastUsedAt,
|
||||
});
|
||||
await persistRuntimeServiceRecord(db, record);
|
||||
adopted += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await db
|
||||
|
|
@ -1446,14 +1859,105 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
|
|||
lastUsedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceRuntimeServices.provider, "local_process"),
|
||||
inArray(workspaceRuntimeServices.status, ["starting", "running"]),
|
||||
),
|
||||
);
|
||||
.where(eq(workspaceRuntimeServices.id, row.id));
|
||||
const registryRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({
|
||||
runtimeServiceId: row.id,
|
||||
profileKind: "workspace-runtime",
|
||||
});
|
||||
if (registryRecord) {
|
||||
await removeLocalServiceRegistryRecord(registryRecord.serviceKey);
|
||||
}
|
||||
stopped += 1;
|
||||
}
|
||||
|
||||
return { reconciled: staleRows.length };
|
||||
return { reconciled: rows.length, adopted, stopped };
|
||||
}
|
||||
|
||||
export async function restartDesiredRuntimeServicesOnStartup(db: Db) {
|
||||
let restarted = 0;
|
||||
let failed = 0;
|
||||
|
||||
const projectWorkspaceRows = await db
|
||||
.select()
|
||||
.from(projectWorkspaces);
|
||||
|
||||
for (const row of projectWorkspaceRows) {
|
||||
const runtimeConfig = readProjectWorkspaceRuntimeConfig((row.metadata as Record<string, unknown> | null) ?? null);
|
||||
if (runtimeConfig?.desiredState !== "running" || !runtimeConfig.workspaceRuntime || !row.cwd) continue;
|
||||
|
||||
try {
|
||||
const refs = await startRuntimeServicesForWorkspaceControl({
|
||||
db,
|
||||
actor: { id: null, name: "Paperclip", companyId: row.companyId },
|
||||
issue: null,
|
||||
workspace: {
|
||||
baseCwd: row.cwd,
|
||||
source: "project_primary",
|
||||
projectId: row.projectId,
|
||||
workspaceId: row.id,
|
||||
repoUrl: row.repoUrl ?? null,
|
||||
repoRef: row.repoRef ?? null,
|
||||
strategy: "project_primary",
|
||||
cwd: row.cwd,
|
||||
branchName: row.defaultRef ?? row.repoRef ?? null,
|
||||
worktreePath: null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
},
|
||||
config: { workspaceRuntime: runtimeConfig.workspaceRuntime },
|
||||
adapterEnv: {},
|
||||
});
|
||||
if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const executionWorkspaceRows = await db
|
||||
.select()
|
||||
.from(executionWorkspaces)
|
||||
.where(inArray(executionWorkspaces.status, ["active", "idle", "in_review", "cleanup_failed"]));
|
||||
|
||||
for (const row of executionWorkspaceRows) {
|
||||
const config = readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null);
|
||||
if (config?.desiredState !== "running" || !config.workspaceRuntime || !row.cwd) continue;
|
||||
|
||||
try {
|
||||
const refs = await startRuntimeServicesForWorkspaceControl({
|
||||
db,
|
||||
actor: { id: null, name: "Paperclip", companyId: row.companyId },
|
||||
issue: row.sourceIssueId
|
||||
? {
|
||||
id: row.sourceIssueId,
|
||||
identifier: null,
|
||||
title: row.name,
|
||||
}
|
||||
: null,
|
||||
workspace: {
|
||||
baseCwd: row.cwd,
|
||||
source: row.mode === "shared_workspace" ? "project_primary" : "task_session",
|
||||
projectId: row.projectId,
|
||||
workspaceId: row.projectWorkspaceId ?? null,
|
||||
repoUrl: row.repoUrl ?? null,
|
||||
repoRef: row.baseRef ?? null,
|
||||
strategy: row.strategyType === "git_worktree" ? "git_worktree" : "project_primary",
|
||||
cwd: row.cwd,
|
||||
branchName: row.branchName ?? null,
|
||||
worktreePath: row.strategyType === "git_worktree" ? row.cwd : null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
},
|
||||
executionWorkspaceId: row.id,
|
||||
config: { workspaceRuntime: config.workspaceRuntime },
|
||||
adapterEnv: {},
|
||||
});
|
||||
if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { restarted, failed };
|
||||
}
|
||||
|
||||
export async function persistAdapterManagedRuntimeServices(input: {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"hermes-paperclip-adapter": "^0.2.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Agents } from "./pages/Agents";
|
|||
import { AgentDetail } from "./pages/AgentDetail";
|
||||
import { Projects } from "./pages/Projects";
|
||||
import { ProjectDetail } from "./pages/ProjectDetail";
|
||||
import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail";
|
||||
import { Issues } from "./pages/Issues";
|
||||
import { IssueDetail } from "./pages/IssueDetail";
|
||||
import { Routines } from "./pages/Routines";
|
||||
|
|
@ -144,6 +145,8 @@ function boardRoutes() {
|
|||
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/workspaces/:workspaceId" element={<ProjectWorkspaceDetail />} />
|
||||
<Route path="projects/:projectId/workspaces" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
|
||||
<Route path="issues" element={<Issues />} />
|
||||
|
|
@ -337,7 +340,10 @@ export function App() {
|
|||
<Route path="projects/:projectId/overview" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/workspaces" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path=":companyPrefix" element={<Layout />}>
|
||||
{boardRoutes()}
|
||||
|
|
|
|||
49
ui/src/adapters/hermes-local/config-fields.tsx
Normal file
49
ui/src/adapters/hermes-local/config-fields.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
DraftInput,
|
||||
} from "../../components/agent-config-primitives";
|
||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
const instructionsFileHint =
|
||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
|
||||
|
||||
export function HermesLocalConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
hideInstructionsFile,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
if (hideInstructionsFile) return null;
|
||||
return (
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.instructionsFilePath ?? ""
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"instructionsFilePath",
|
||||
String(config.instructionsFilePath ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ instructionsFilePath: v })
|
||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="/absolute/path/to/AGENTS.md"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
12
ui/src/adapters/hermes-local/index.ts
Normal file
12
ui/src/adapters/hermes-local/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { UIAdapterModule } from "../types";
|
||||
import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui";
|
||||
import { HermesLocalConfigFields } from "./config-fields";
|
||||
import { buildHermesConfig } from "hermes-paperclip-adapter/ui";
|
||||
|
||||
export const hermesLocalUIAdapter: UIAdapterModule = {
|
||||
type: "hermes_local",
|
||||
label: "Hermes Agent",
|
||||
parseStdoutLine: parseHermesStdoutLine,
|
||||
ConfigFields: HermesLocalConfigFields,
|
||||
buildAdapterConfig: buildHermesConfig,
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import { claudeLocalUIAdapter } from "./claude-local";
|
|||
import { codexLocalUIAdapter } from "./codex-local";
|
||||
import { cursorLocalUIAdapter } from "./cursor";
|
||||
import { geminiLocalUIAdapter } from "./gemini-local";
|
||||
import { hermesLocalUIAdapter } from "./hermes-local";
|
||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||
import { piLocalUIAdapter } from "./pi-local";
|
||||
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||
|
|
@ -13,6 +14,7 @@ const uiAdapters: UIAdapterModule[] = [
|
|||
claudeLocalUIAdapter,
|
||||
codexLocalUIAdapter,
|
||||
geminiLocalUIAdapter,
|
||||
hermesLocalUIAdapter,
|
||||
openCodeLocalUIAdapter,
|
||||
piLocalUIAdapter,
|
||||
cursorLocalUIAdapter,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ export interface AdapterModel {
|
|||
label: string;
|
||||
}
|
||||
|
||||
export interface DetectedAdapterModel {
|
||||
model: string;
|
||||
provider: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface ClaudeLoginResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
|
|
@ -159,6 +165,10 @@ export const agentsApi = {
|
|||
api.get<AdapterModel[]>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
|
||||
),
|
||||
detectModel: (companyId: string, type: string) =>
|
||||
api.get<DetectedAdapterModel | null>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
|
||||
),
|
||||
testEnvironment: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import type { ExecutionWorkspace, ExecutionWorkspaceCloseReadiness, WorkspaceOperation } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const executionWorkspacesApi = {
|
||||
|
|
@ -22,5 +22,14 @@ export const executionWorkspacesApi = {
|
|||
return api.get<ExecutionWorkspace[]>(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
get: (id: string) => api.get<ExecutionWorkspace>(`/execution-workspaces/${id}`),
|
||||
getCloseReadiness: (id: string) =>
|
||||
api.get<ExecutionWorkspaceCloseReadiness>(`/execution-workspaces/${id}/close-readiness`),
|
||||
listWorkspaceOperations: (id: string) =>
|
||||
api.get<WorkspaceOperation[]>(`/execution-workspaces/${id}/workspace-operations`),
|
||||
controlRuntimeServices: (id: string, action: "start" | "stop" | "restart") =>
|
||||
api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>(
|
||||
`/execution-workspaces/${id}/runtime-services/${action}`,
|
||||
{},
|
||||
),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const issuesApi = {
|
|||
inboxArchivedByUserId?: string;
|
||||
unreadForUserId?: string;
|
||||
labelId?: string;
|
||||
executionWorkspaceId?: string;
|
||||
originKind?: string;
|
||||
originId?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
|
|
@ -40,6 +41,7 @@ export const issuesApi = {
|
|||
if (filters?.inboxArchivedByUserId) params.set("inboxArchivedByUserId", filters.inboxArchivedByUserId);
|
||||
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
|
||||
if (filters?.labelId) params.set("labelId", filters.labelId);
|
||||
if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId);
|
||||
if (filters?.originKind) params.set("originKind", filters.originKind);
|
||||
if (filters?.originId) params.set("originId", filters.originId);
|
||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Project, ProjectWorkspace } from "@paperclipai/shared";
|
||||
import type { Project, ProjectWorkspace, WorkspaceOperation } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
function withCompanyScope(path: string, companyId?: string) {
|
||||
|
|
@ -27,6 +27,16 @@ export const projectsApi = {
|
|||
projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`),
|
||||
data,
|
||||
),
|
||||
controlWorkspaceRuntimeServices: (
|
||||
projectId: string,
|
||||
workspaceId: string,
|
||||
action: "start" | "stop" | "restart",
|
||||
companyId?: string,
|
||||
) =>
|
||||
api.post<{ workspace: ProjectWorkspace; operation: WorkspaceOperation }>(
|
||||
projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}/runtime-services/${action}`),
|
||||
{},
|
||||
),
|
||||
removeWorkspace: (projectId: string, workspaceId: string, companyId?: string) =>
|
||||
api.delete<ProjectWorkspace>(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)),
|
||||
remove: (id: string, companyId?: string) => api.delete<Project>(projectPath(id, companyId)),
|
||||
|
|
|
|||
|
|
@ -248,9 +248,26 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}
|
||||
if (overlay.adapterType !== undefined) {
|
||||
patch.adapterType = overlay.adapterType;
|
||||
// When adapter type changes, send only the new config — don't merge
|
||||
// with old config since old adapter fields are meaningless for the new type
|
||||
patch.adapterConfig = overlay.adapterConfig;
|
||||
// When adapter type changes, replace adapter-specific fields but preserve
|
||||
// adapter-agnostic fields (env, promptTemplate, etc.) that are shared
|
||||
// across all adapter types.
|
||||
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
const adapterAgnosticKeys = [
|
||||
"env",
|
||||
"promptTemplate",
|
||||
"instructionsFilePath",
|
||||
"cwd",
|
||||
"timeoutSec",
|
||||
"graceSec",
|
||||
"bootstrapPromptTemplate",
|
||||
];
|
||||
const preserved: Record<string, unknown> = {};
|
||||
for (const key of adapterAgnosticKeys) {
|
||||
if (key in existing) {
|
||||
preserved[key] = existing[key];
|
||||
}
|
||||
}
|
||||
patch.adapterConfig = { ...preserved, ...overlay.adapterConfig };
|
||||
} else if (Object.keys(overlay.adapterConfig).length > 0) {
|
||||
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
|
||||
|
|
@ -296,9 +313,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor";
|
||||
const isHermesLocal = adapterType === "hermes_local";
|
||||
const showLegacyWorkingDirectoryField =
|
||||
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
|
||||
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||
|
|
@ -315,6 +334,22 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const models = fetchedModels ?? externalModels ?? [];
|
||||
const {
|
||||
data: detectedModelData,
|
||||
refetch: refetchDetectedModel,
|
||||
} = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.agents.detectModel(selectedCompanyId, adapterType)
|
||||
: ["agents", "none", "detect-model", adapterType],
|
||||
queryFn: () => {
|
||||
if (!selectedCompanyId) {
|
||||
throw new Error("Select a company to detect the Hermes model");
|
||||
}
|
||||
return agentsApi.detectModel(selectedCompanyId, adapterType);
|
||||
},
|
||||
enabled: Boolean(selectedCompanyId && isHermesLocal),
|
||||
});
|
||||
const detectedModel = detectedModelData?.model ?? null;
|
||||
|
||||
const { data: companyAgents = [] } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
|
||||
|
|
@ -688,6 +723,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
? "codex"
|
||||
: adapterType === "gemini_local"
|
||||
? "gemini"
|
||||
: adapterType === "hermes_local"
|
||||
? "hermes"
|
||||
: adapterType === "pi_local"
|
||||
? "pi"
|
||||
: adapterType === "cursor"
|
||||
|
|
@ -709,9 +746,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}
|
||||
open={modelOpen}
|
||||
onOpenChange={setModelOpen}
|
||||
allowDefault={adapterType !== "opencode_local"}
|
||||
required={adapterType === "opencode_local"}
|
||||
allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"}
|
||||
required={adapterType === "opencode_local" || adapterType === "hermes_local"}
|
||||
groupByProvider={adapterType === "opencode_local"}
|
||||
creatable={adapterType === "hermes_local"}
|
||||
detectedModel={adapterType === "hermes_local" ? detectedModel : null}
|
||||
onDetectModel={adapterType === "hermes_local"
|
||||
? async () => {
|
||||
const result = await refetchDetectedModel();
|
||||
return result.data?.model ?? null;
|
||||
}
|
||||
: undefined}
|
||||
detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined}
|
||||
/>
|
||||
{fetchedModelsError && (
|
||||
<p className="text-xs text-destructive">
|
||||
|
|
@ -976,7 +1022,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
|||
|
||||
/* ---- Internal sub-components ---- */
|
||||
|
||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
|
||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
|
||||
|
||||
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
||||
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
||||
|
|
@ -1293,6 +1339,10 @@ function ModelDropdown({
|
|||
allowDefault,
|
||||
required,
|
||||
groupByProvider,
|
||||
creatable,
|
||||
detectedModel,
|
||||
onDetectModel,
|
||||
detectModelLabel,
|
||||
}: {
|
||||
models: AdapterModel[];
|
||||
value: string;
|
||||
|
|
@ -1302,9 +1352,20 @@ function ModelDropdown({
|
|||
allowDefault: boolean;
|
||||
required: boolean;
|
||||
groupByProvider: boolean;
|
||||
creatable?: boolean;
|
||||
detectedModel?: string | null;
|
||||
onDetectModel?: () => Promise<string | null>;
|
||||
detectModelLabel?: string;
|
||||
}) {
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
const [detectingModel, setDetectingModel] = useState(false);
|
||||
const selected = models.find((m) => m.id === value);
|
||||
const manualModel = modelSearch.trim();
|
||||
const canCreateManualModel = Boolean(
|
||||
creatable &&
|
||||
manualModel &&
|
||||
!models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()),
|
||||
);
|
||||
const filteredModels = useMemo(() => {
|
||||
return models.filter((m) => {
|
||||
if (!modelSearch.trim()) return true;
|
||||
|
|
@ -1341,6 +1402,21 @@ function ModelDropdown({
|
|||
}));
|
||||
}, [filteredModels, groupByProvider]);
|
||||
|
||||
async function handleDetectModel() {
|
||||
if (!onDetectModel) return;
|
||||
setDetectingModel(true);
|
||||
try {
|
||||
const nextModel = await onDetectModel();
|
||||
if (nextModel) {
|
||||
onChange(nextModel);
|
||||
onOpenChange(false);
|
||||
setModelSearch("");
|
||||
}
|
||||
} finally {
|
||||
setDetectingModel(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Field label="Model" hint={help.model}>
|
||||
<Popover
|
||||
|
|
@ -1351,7 +1427,7 @@ function ModelDropdown({
|
|||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<span className={cn(!value && "text-muted-foreground")}>
|
||||
{selected
|
||||
? selected.label
|
||||
|
|
@ -1361,16 +1437,84 @@ function ModelDropdown({
|
|||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
||||
<div className="relative mb-1">
|
||||
<input
|
||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||
placeholder="Search models..."
|
||||
className="w-full px-2 py-1.5 pr-6 text-xs bg-transparent outline-none border-b border-border placeholder:text-muted-foreground/50"
|
||||
placeholder={creatable ? "Search models... (type to create)" : "Search models..."}
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{modelSearch && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setModelSearch("")}
|
||||
>
|
||||
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onDetectModel && !detectedModel && !modelSearch.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
|
||||
onClick={() => {
|
||||
void handleDetectModel();
|
||||
}}
|
||||
disabled={detectingModel}
|
||||
>
|
||||
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
{detectingModel ? "Detecting..." : (detectModelLabel ?? "Detect from config")}
|
||||
</button>
|
||||
)}
|
||||
{value && !models.some((m) => m.id === value) && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center w-full px-2 py-1.5 text-sm rounded bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 border border-green-500/20">
|
||||
current
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{detectedModel && detectedModel !== value && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(detectedModel);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={detectedModel}>
|
||||
{detectedModel}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/20">
|
||||
detected
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
{allowDefault && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
!value && "bg-accent",
|
||||
|
|
@ -1383,6 +1527,20 @@ function ModelDropdown({
|
|||
Default
|
||||
</button>
|
||||
)}
|
||||
{canCreateManualModel && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
onChange(manualModel);
|
||||
onOpenChange(false);
|
||||
setModelSearch("");
|
||||
}}
|
||||
>
|
||||
<span>Use manual model</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{manualModel}</span>
|
||||
</button>
|
||||
)}
|
||||
{groupedModels.map((group) => (
|
||||
<div key={group.provider} className="mb-1 last:mb-0">
|
||||
{groupByProvider && (
|
||||
|
|
@ -1392,6 +1550,7 @@ function ModelDropdown({
|
|||
)}
|
||||
{group.entries.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
|
|
@ -1409,8 +1568,14 @@ function ModelDropdown({
|
|||
))}
|
||||
</div>
|
||||
))}
|
||||
{filteredModels.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
||||
{filteredModels.length === 0 && !canCreateManualModel && (
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{onDetectModel
|
||||
? "No Hermes model detected yet. Configure Hermes or enter a provider/model manually."
|
||||
: "No models found."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
|
|
|||
314
ui/src/components/ExecutionWorkspaceCloseDialog.tsx
Normal file
314
ui/src/components/ExecutionWorkspaceCloseDialog.tsx
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { formatDateTime, issueUrl } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
|
||||
type ExecutionWorkspaceCloseDialogProps = {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
currentStatus: ExecutionWorkspace["status"];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onClosed?: (workspace: ExecutionWorkspace) => void;
|
||||
};
|
||||
|
||||
function readinessTone(state: "ready" | "ready_with_warnings" | "blocked") {
|
||||
if (state === "blocked") {
|
||||
return "border-destructive/30 bg-destructive/5 text-destructive";
|
||||
}
|
||||
if (state === "ready_with_warnings") {
|
||||
return "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-300";
|
||||
}
|
||||
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
}
|
||||
|
||||
export function ExecutionWorkspaceCloseDialog({
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
currentStatus,
|
||||
open,
|
||||
onOpenChange,
|
||||
onClosed,
|
||||
}: ExecutionWorkspaceCloseDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const actionLabel = currentStatus === "cleanup_failed" ? "Retry close" : "Close workspace";
|
||||
|
||||
const readinessQuery = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.closeReadiness(workspaceId),
|
||||
queryFn: () => executionWorkspacesApi.getCloseReadiness(workspaceId),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const closeWorkspace = useMutation({
|
||||
mutationFn: () => executionWorkspacesApi.update(workspaceId, { status: "archived" }),
|
||||
onSuccess: (workspace) => {
|
||||
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(workspace.id), workspace);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(workspace.id) });
|
||||
pushToast({
|
||||
title: currentStatus === "cleanup_failed" ? "Workspace close retried" : "Workspace closed",
|
||||
tone: "success",
|
||||
});
|
||||
onOpenChange(false);
|
||||
onClosed?.(workspace);
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Failed to close workspace",
|
||||
body: error instanceof Error ? error.message : "Unknown error",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const readiness = readinessQuery.data ?? null;
|
||||
const blockingIssues = readiness?.linkedIssues.filter((issue) => !issue.isTerminal) ?? [];
|
||||
const otherLinkedIssues = readiness?.linkedIssues.filter((issue) => issue.isTerminal) ?? [];
|
||||
const confirmDisabled =
|
||||
currentStatus === "archived" ||
|
||||
closeWorkspace.isPending ||
|
||||
readinessQuery.isLoading ||
|
||||
readiness == null ||
|
||||
readiness.state === "blocked";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||
if (!closeWorkspace.isPending) onOpenChange(nextOpen);
|
||||
}}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{actionLabel}</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
Archive <span className="font-medium text-foreground">{workspaceName}</span> and clean up any owned workspace
|
||||
artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{readinessQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Checking whether this workspace is safe to close...
|
||||
</div>
|
||||
) : readinessQuery.error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
|
||||
</div>
|
||||
) : readiness ? (
|
||||
<div className="space-y-4">
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${readinessTone(readiness.state)}`}>
|
||||
<div className="font-medium">
|
||||
{readiness.state === "blocked"
|
||||
? "Close is blocked"
|
||||
: readiness.state === "ready_with_warnings"
|
||||
? "Close is allowed with warnings"
|
||||
: "Close is ready"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs opacity-80">
|
||||
{readiness.isSharedWorkspace
|
||||
? "This is a shared workspace session. Archiving it removes this session record but keeps the underlying project workspace."
|
||||
: readiness.git?.workspacePath && readiness.git.repoRoot && readiness.git.workspacePath !== readiness.git.repoRoot
|
||||
? "This execution workspace has its own checkout path and can be archived independently."
|
||||
: readiness.isProjectPrimaryWorkspace
|
||||
? "This execution workspace currently points at the project's primary workspace path."
|
||||
: "This workspace is disposable and can be archived."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{blockingIssues.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Blocking issues</h3>
|
||||
<div className="space-y-2">
|
||||
{blockingIssues.map((issue) => (
|
||||
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||
{issue.identifier ?? issue.id} · {issue.title}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">{issue.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{readiness.blockingReasons.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Blocking reasons</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{readiness.blockingReasons.map((reason) => (
|
||||
<li key={reason} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive">
|
||||
{reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{readiness.warnings.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Warnings</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{readiness.warnings.map((warning) => (
|
||||
<li key={warning} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2">
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{readiness.git ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Git status</h3>
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
|
||||
<div className="font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Base ref</div>
|
||||
<div className="font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Merged into base</div>
|
||||
<div>{readiness.git.isMergedIntoBase == null ? "Unknown" : readiness.git.isMergedIntoBase ? "Yes" : "No"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Ahead / behind</div>
|
||||
<div>
|
||||
{(readiness.git.aheadCount ?? 0).toString()} / {(readiness.git.behindCount ?? 0).toString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Dirty tracked files</div>
|
||||
<div>{readiness.git.dirtyEntryCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Untracked files</div>
|
||||
<div>{readiness.git.untrackedEntryCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{otherLinkedIssues.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Other linked issues</h3>
|
||||
<div className="space-y-2">
|
||||
{otherLinkedIssues.map((issue) => (
|
||||
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||
{issue.identifier ?? issue.id} · {issue.title}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">{issue.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{readiness.runtimeServices.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Attached runtime services</h3>
|
||||
<div className="space-y-2">
|
||||
{readiness.runtimeServices.map((service) => (
|
||||
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium">{service.serviceName}</span>
|
||||
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
|
||||
</div>
|
||||
<div className="mt-1 break-words text-xs text-muted-foreground">
|
||||
{service.url ?? service.command ?? service.cwd ?? "No additional details"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Cleanup actions</h3>
|
||||
<div className="space-y-2">
|
||||
{readiness.plannedActions.map((action, index) => (
|
||||
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="mt-1 break-words text-muted-foreground">{action.description}</div>
|
||||
{action.command ? (
|
||||
<pre className="mt-2 whitespace-pre-wrap break-all rounded-lg bg-background px-3 py-2 font-mono text-xs text-foreground">
|
||||
{action.command}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{currentStatus === "cleanup_failed" ? (
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3 text-sm text-muted-foreground">
|
||||
Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the
|
||||
workspace status if it succeeds.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{currentStatus === "archived" ? (
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
This workspace is already archived.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{readiness.git?.repoRoot ? (
|
||||
<div className="break-words text-xs text-muted-foreground">
|
||||
Repo root: <span className="font-mono break-all">{readiness.git.repoRoot}</span>
|
||||
{readiness.git.workspacePath ? (
|
||||
<>
|
||||
{" · "}Workspace path: <span className="font-mono break-all">{readiness.git.workspacePath}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Last checked {formatDateTime(new Date())}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={closeWorkspace.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentStatus === "cleanup_failed" ? "default" : "destructive"}
|
||||
onClick={() => closeWorkspace.mutate()}
|
||||
disabled={confirmDisabled}
|
||||
>
|
||||
{closeWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
43
ui/src/components/HermesIcon.tsx
Normal file
43
ui/src/components/HermesIcon.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { cn } from "../lib/utils";
|
||||
|
||||
interface HermesIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hermes caduceus icon — winged staff with two intertwined serpents.
|
||||
* Replaces the generic Zap icon for the hermes_local adapter type.
|
||||
*
|
||||
* ⚕️ inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings.
|
||||
*/
|
||||
export function HermesIcon({ className }: HermesIconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn(className)}
|
||||
>
|
||||
{/* Central staff */}
|
||||
<line x1="12" y1="6" x2="12" y2="23" />
|
||||
{/* Left serpent curves */}
|
||||
<path d="M12 8 C10 9 9.5 11 10.5 13 C11.5 15 10 17 12 18" />
|
||||
{/* Right serpent curves */}
|
||||
<path d="M12 8 C14 9 14.5 11 13.5 13 C12.5 15 14 17 12 18" />
|
||||
{/* Snake heads facing outward */}
|
||||
<circle cx="10" cy="8" r="0.8" fill="currentColor" stroke="none" />
|
||||
<circle cx="14" cy="8" r="0.8" fill="currentColor" stroke="none" />
|
||||
{/* Wings at top of staff */}
|
||||
<path d="M12 6 L8 3 L6 5 L9 6" strokeWidth="1.2" />
|
||||
<path d="M12 6 L16 3 L18 5 L15 6" strokeWidth="1.2" />
|
||||
{/* Wing feather details */}
|
||||
<line x1="7.5" y1="4" x2="7" y2="5.2" strokeWidth="1" />
|
||||
<line x1="16.5" y1="4" x2="17" y2="5.2" strokeWidth="1" />
|
||||
{/* Staff sphere at top */}
|
||||
<circle cx="12" cy="6.5" r="1.2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { executionWorkspacesApi } from "../api/execution-workspaces";
|
|||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn, projectWorkspaceUrl } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
|
||||
|
||||
|
|
@ -114,6 +114,29 @@ function configuredWorkspaceLabel(
|
|||
}
|
||||
}
|
||||
|
||||
function projectWorkspaceDetailLink(input: {
|
||||
projectId: string | null | undefined;
|
||||
projectWorkspaceId: string | null | undefined;
|
||||
}) {
|
||||
if (!input.projectId || !input.projectWorkspaceId) return null;
|
||||
return projectWorkspaceUrl({ id: input.projectId, urlKey: input.projectId }, input.projectWorkspaceId);
|
||||
}
|
||||
|
||||
function workspaceDetailLink(input: {
|
||||
projectId: string | null | undefined;
|
||||
issueProjectWorkspaceId: string | null | undefined;
|
||||
workspace: ExecutionWorkspace | null | undefined;
|
||||
}) {
|
||||
const linkedProjectWorkspaceId = input.workspace?.projectWorkspaceId ?? input.issueProjectWorkspaceId ?? null;
|
||||
if (input.workspace?.mode === "shared_workspace") {
|
||||
return projectWorkspaceDetailLink({
|
||||
projectId: input.projectId,
|
||||
projectWorkspaceId: linkedProjectWorkspaceId,
|
||||
});
|
||||
}
|
||||
return input.workspace ? `/execution-workspaces/${input.workspace.id}` : null;
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const colors: Record<string, string> = {
|
||||
active: "bg-green-500/15 text-green-700 dark:text-green-400",
|
||||
|
|
@ -209,6 +232,17 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||
deduplicatedReusableWorkspaces.find((w) => w.id === draftExecutionWorkspaceId)
|
||||
?? (draftExecutionWorkspaceId === issue.executionWorkspaceId ? selectedReusableExecutionWorkspace : null);
|
||||
|
||||
const selectedReusableWorkspaceLink = workspaceDetailLink({
|
||||
projectId: project?.id,
|
||||
issueProjectWorkspaceId: issue.projectWorkspaceId,
|
||||
workspace: selectedReusableExecutionWorkspace,
|
||||
});
|
||||
const currentWorkspaceLink = workspaceDetailLink({
|
||||
projectId: project?.id,
|
||||
issueProjectWorkspaceId: issue.projectWorkspaceId,
|
||||
workspace,
|
||||
});
|
||||
|
||||
const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0;
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
|
|
@ -317,18 +351,22 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||
{currentSelection === "reuse_existing" && selectedReusableExecutionWorkspace && (
|
||||
<div className="text-muted-foreground" style={{ overflowWrap: "anywhere" }}>
|
||||
Reusing:{" "}
|
||||
{selectedReusableWorkspaceLink ? (
|
||||
<Link
|
||||
to={`/execution-workspaces/${selectedReusableExecutionWorkspace.id}`}
|
||||
to={selectedReusableWorkspaceLink}
|
||||
className="hover:text-foreground hover:underline"
|
||||
>
|
||||
<BreakablePath text={selectedReusableExecutionWorkspace.name} />
|
||||
</Link>
|
||||
) : (
|
||||
<BreakablePath text={selectedReusableExecutionWorkspace.name} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{workspace && (
|
||||
{workspace && currentWorkspaceLink && (
|
||||
<div className="pt-0.5">
|
||||
<Link
|
||||
to={`/execution-workspaces/${workspace.id}`}
|
||||
to={currentWorkspaceLink}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
View workspace details →
|
||||
|
|
@ -385,12 +423,16 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||
<div className="text-[11px] text-muted-foreground space-y-0.5 pt-1 border-t border-border/50">
|
||||
<div style={{ overflowWrap: "anywhere" }}>
|
||||
Current:{" "}
|
||||
{currentWorkspaceLink ? (
|
||||
<Link
|
||||
to={`/execution-workspaces/${workspace.id}`}
|
||||
to={currentWorkspaceLink}
|
||||
className="hover:text-foreground hover:underline"
|
||||
>
|
||||
<BreakablePath text={workspace.name} />
|
||||
</Link>
|
||||
) : (
|
||||
<BreakablePath text={workspace.name} />
|
||||
)}
|
||||
{" · "}
|
||||
{workspace.status}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
import { HermesIcon } from "./HermesIcon";
|
||||
|
||||
type AdvancedAdapterType =
|
||||
| "claude_local"
|
||||
|
|
@ -29,7 +30,8 @@ type AdvancedAdapterType =
|
|||
| "opencode_local"
|
||||
| "pi_local"
|
||||
| "cursor"
|
||||
| "openclaw_gateway";
|
||||
| "openclaw_gateway"
|
||||
| "hermes_local";
|
||||
|
||||
const ADVANCED_ADAPTER_OPTIONS: Array<{
|
||||
value: AdvancedAdapterType;
|
||||
|
|
@ -64,6 +66,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
|
|||
icon: OpenCodeLogoIcon,
|
||||
desc: "Local multi-provider agent",
|
||||
},
|
||||
{
|
||||
value: "hermes_local",
|
||||
label: "Hermes Agent",
|
||||
icon: HermesIcon,
|
||||
desc: "Local multi-provider agent",
|
||||
},
|
||||
{
|
||||
value: "pi_local",
|
||||
label: "Pi",
|
||||
|
|
|
|||
|
|
@ -56,12 +56,14 @@ import {
|
|||
ChevronDown,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { HermesIcon } from "./HermesIcon";
|
||||
|
||||
type Step = 1 | 2 | 3 | 4;
|
||||
type AdapterType =
|
||||
| "claude_local"
|
||||
| "codex_local"
|
||||
| "gemini_local"
|
||||
| "hermes_local"
|
||||
| "opencode_local"
|
||||
| "pi_local"
|
||||
| "cursor"
|
||||
|
|
@ -208,6 +210,7 @@ export function OnboardingWizard() {
|
|||
adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor";
|
||||
|
|
@ -217,6 +220,8 @@ export function OnboardingWizard() {
|
|||
? "codex"
|
||||
: adapterType === "gemini_local"
|
||||
? "gemini"
|
||||
: adapterType === "hermes_local"
|
||||
? "hermes"
|
||||
: adapterType === "pi_local"
|
||||
? "pi"
|
||||
: adapterType === "cursor"
|
||||
|
|
@ -843,6 +848,12 @@ export function OnboardingWizard() {
|
|||
icon: MousePointer2,
|
||||
desc: "Local Cursor agent"
|
||||
},
|
||||
{
|
||||
value: "hermes_local" as const,
|
||||
label: "Hermes Agent",
|
||||
icon: HermesIcon,
|
||||
desc: "Local multi-provider agent"
|
||||
},
|
||||
{
|
||||
value: "openclaw_gateway" as const,
|
||||
label: "OpenClaw Gateway",
|
||||
|
|
@ -902,6 +913,7 @@ export function OnboardingWizard() {
|
|||
{(adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor") && (
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export const adapterLabels: Record<string, string> = {
|
|||
opencode_local: "OpenCode (local)",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
hermes_local: "Hermes Agent",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -72,6 +72,26 @@ type TranscriptBlock =
|
|||
status: "running" | "completed" | "error";
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "tool_group";
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
items: Array<{
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
status: "running" | "completed" | "error";
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "stderr_group";
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
lines: Array<{ ts: string; text: string }>;
|
||||
}
|
||||
| {
|
||||
type: "stdout";
|
||||
ts: string;
|
||||
|
|
@ -325,6 +345,48 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
|||
return grouped;
|
||||
}
|
||||
|
||||
/** Group consecutive non-command tool blocks into a single tool_group accordion. */
|
||||
function groupToolBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
||||
const grouped: TranscriptBlock[] = [];
|
||||
let pending: Array<Extract<TranscriptBlock, { type: "tool_group" }>["items"][number]> = [];
|
||||
let groupTs: string | null = null;
|
||||
let groupEndTs: string | undefined;
|
||||
|
||||
const flush = () => {
|
||||
if (pending.length === 0 || !groupTs) return;
|
||||
grouped.push({
|
||||
type: "tool_group",
|
||||
ts: groupTs,
|
||||
endTs: groupEndTs,
|
||||
items: pending,
|
||||
});
|
||||
pending = [];
|
||||
groupTs = null;
|
||||
groupEndTs = undefined;
|
||||
};
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === "tool" && !isCommandTool(block.name, block.input)) {
|
||||
if (!groupTs) groupTs = block.ts;
|
||||
groupEndTs = block.endTs ?? block.ts;
|
||||
pending.push({
|
||||
ts: block.ts,
|
||||
endTs: block.endTs,
|
||||
name: block.name,
|
||||
input: block.input,
|
||||
result: block.result,
|
||||
isError: block.isError,
|
||||
status: block.status,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
grouped.push(block);
|
||||
}
|
||||
flush();
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
|
||||
const blocks: TranscriptBlock[] = [];
|
||||
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
|
||||
|
|
@ -437,13 +499,19 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
|||
if (shouldHideNiceModeStderr(entry.text)) {
|
||||
continue;
|
||||
}
|
||||
// Batch consecutive stderr entries into a single group
|
||||
const prev = blocks[blocks.length - 1];
|
||||
if (prev && prev.type === "stderr_group") {
|
||||
prev.lines.push({ ts: entry.ts, text: entry.text });
|
||||
prev.endTs = entry.ts;
|
||||
} else {
|
||||
blocks.push({
|
||||
type: "event",
|
||||
type: "stderr_group",
|
||||
ts: entry.ts,
|
||||
label: "stderr",
|
||||
tone: "error",
|
||||
text: entry.text,
|
||||
endTs: entry.ts,
|
||||
lines: [{ ts: entry.ts, text: entry.text }],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -508,7 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
|||
}
|
||||
}
|
||||
|
||||
return groupCommandBlocks(blocks);
|
||||
return groupToolBlocks(groupCommandBlocks(blocks));
|
||||
}
|
||||
|
||||
function TranscriptMessageBlock({
|
||||
|
|
@ -805,6 +873,139 @@ function TranscriptCommandGroup({
|
|||
);
|
||||
}
|
||||
|
||||
function TranscriptToolGroup({
|
||||
block,
|
||||
density,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "tool_group" }>;
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const compact = density === "compact";
|
||||
const runningItem = [...block.items].reverse().find((item) => item.status === "running");
|
||||
const hasError = block.items.some((item) => item.status === "error");
|
||||
const isRunning = Boolean(runningItem);
|
||||
const uniqueNames = [...new Set(block.items.map((item) => item.name))];
|
||||
const toolLabel =
|
||||
uniqueNames.length === 1
|
||||
? humanizeLabel(uniqueNames[0])
|
||||
: `${uniqueNames.length} tools`;
|
||||
const title = isRunning
|
||||
? `Using ${toolLabel}`
|
||||
: block.items.length === 1
|
||||
? `Used ${toolLabel}`
|
||||
: `Used ${toolLabel} (${block.items.length} calls)`;
|
||||
const subtitle = runningItem
|
||||
? summarizeToolInput(runningItem.name, runningItem.input, density)
|
||||
: null;
|
||||
const statusTone = isRunning
|
||||
? "text-cyan-700 dark:text-cyan-300"
|
||||
: "text-foreground/70";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/40 bg-muted/[0.25]">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn("flex cursor-pointer gap-2 px-3 py-2.5", subtitle ? "items-start" : "items-center")}
|
||||
onClick={() => { if (hasSelectedText()) return; setOpen((v) => !v); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
|
||||
>
|
||||
<div className={cn("flex shrink-0 items-center", subtitle && "mt-0.5")}>
|
||||
{block.items.slice(0, Math.min(block.items.length, 3)).map((item, index) => {
|
||||
const isItemRunning = item.status === "running";
|
||||
const isItemError = item.status === "error";
|
||||
return (
|
||||
<span
|
||||
key={`${item.ts}-${index}`}
|
||||
className={cn(
|
||||
"inline-flex h-6 w-6 items-center justify-center rounded-full border shadow-sm",
|
||||
index > 0 && "-ml-1.5",
|
||||
isItemRunning
|
||||
? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300"
|
||||
: isItemError
|
||||
? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300"
|
||||
: "border-border/70 bg-background text-foreground/55",
|
||||
isItemRunning && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn("font-semibold uppercase leading-none tracking-[0.1em]", compact ? "text-[10px]" : "text-[11px]", "text-muted-foreground/70")}>
|
||||
{title}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("inline-flex h-5 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground", subtitle && "mt-0.5")}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
|
||||
aria-label={open ? "Collapse tool details" : "Expand tool details"}
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className={cn("space-y-2 border-t border-border/30 px-3 py-3", hasError && "rounded-b-xl")}>
|
||||
{block.items.map((item, index) => (
|
||||
<div key={`${item.ts}-${index}`} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border",
|
||||
item.status === "error"
|
||||
? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300"
|
||||
: item.status === "running"
|
||||
? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300"
|
||||
: "border-border/70 bg-background text-foreground/55",
|
||||
)}>
|
||||
<Wrench className="h-3 w-3" />
|
||||
</span>
|
||||
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground")}>
|
||||
{humanizeLabel(item.name)}
|
||||
</span>
|
||||
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]",
|
||||
item.status === "running" ? "text-cyan-700 dark:text-cyan-300"
|
||||
: item.status === "error" ? "text-red-700 dark:text-red-300"
|
||||
: "text-emerald-700 dark:text-emerald-300"
|
||||
)}>
|
||||
{item.status === "running" ? "Running" : item.status === "error" ? "Errored" : "Completed"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn("grid gap-2 pl-7", compact ? "grid-cols-1" : "lg:grid-cols-2")}>
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Input</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-foreground/80">
|
||||
{formatToolPayload(item.input) || "<empty>"}
|
||||
</pre>
|
||||
</div>
|
||||
{item.result && (
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Result</div>
|
||||
<pre className={cn(
|
||||
"overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px]",
|
||||
item.status === "error" ? "text-red-700 dark:text-red-300" : "text-foreground/80",
|
||||
)}>
|
||||
{formatToolPayload(item.result)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptActivityRow({
|
||||
block,
|
||||
density,
|
||||
|
|
@ -883,6 +1084,43 @@ function TranscriptEventRow({
|
|||
);
|
||||
}
|
||||
|
||||
function TranscriptStderrGroup({
|
||||
block,
|
||||
density,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "stderr_group" }>;
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const compact = density === "compact";
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/[0.06] p-2 text-amber-700 dark:text-amber-300">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
|
||||
>
|
||||
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]")}>
|
||||
{block.lines.length} log {block.lines.length === 1 ? "line" : "lines"}
|
||||
</span>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
{open && (
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-amber-700/80 dark:text-amber-300/80 pl-5">
|
||||
{block.lines.map((line, i) => (
|
||||
<span key={`${line.ts}-${i}`}>
|
||||
<span className="select-none text-amber-500/50 dark:text-amber-400/40">{i > 0 ? "\n" : ""}</span>
|
||||
{line.text}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptStdoutRow({
|
||||
block,
|
||||
density,
|
||||
|
|
@ -1003,6 +1241,8 @@ export function RunTranscriptView({
|
|||
)}
|
||||
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
|
||||
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
|
||||
{block.type === "tool_group" && <TranscriptToolGroup block={block} density={density} />}
|
||||
{block.type === "stderr_group" && <TranscriptStderrGroup block={block} density={density} />}
|
||||
{block.type === "stdout" && (
|
||||
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -117,3 +117,77 @@ describe("LiveUpdatesProvider visible issue toast suppression", () => {
|
|||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LiveUpdatesProvider run lifecycle toasts", () => {
|
||||
it("does not build start or success toasts for agent runs", () => {
|
||||
const queryClient = {
|
||||
getQueryData: () => [],
|
||||
};
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.buildAgentStatusToast(
|
||||
{
|
||||
agentId: "agent-1",
|
||||
status: "running",
|
||||
},
|
||||
() => "CodexCoder",
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.buildRunStatusToast(
|
||||
{
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
status: "succeeded",
|
||||
},
|
||||
() => "CodexCoder",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("still builds failure toasts for agent errors and failed runs", () => {
|
||||
const queryClient = {
|
||||
getQueryData: () => [
|
||||
{
|
||||
id: "agent-1",
|
||||
title: "Software Engineer",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.buildAgentStatusToast(
|
||||
{
|
||||
agentId: "agent-1",
|
||||
status: "error",
|
||||
},
|
||||
() => "CodexCoder",
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
),
|
||||
).toMatchObject({
|
||||
title: "CodexCoder errored",
|
||||
body: "Software Engineer",
|
||||
tone: "error",
|
||||
});
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.buildRunStatusToast(
|
||||
{
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
status: "failed",
|
||||
error: "boom",
|
||||
},
|
||||
() => "CodexCoder",
|
||||
),
|
||||
).toMatchObject({
|
||||
title: "CodexCoder run failed",
|
||||
body: "boom",
|
||||
tone: "error",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -244,8 +244,8 @@ function shouldSuppressAgentStatusToastForVisibleIssue(
|
|||
}
|
||||
|
||||
const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
|
||||
const AGENT_TOAST_STATUSES = new Set(["running", "error"]);
|
||||
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]);
|
||||
const AGENT_TOAST_STATUSES = new Set(["error"]);
|
||||
const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]);
|
||||
|
||||
function describeIssueUpdate(details: Record<string, unknown> | null): string | null {
|
||||
if (!details) return null;
|
||||
|
|
@ -416,7 +416,7 @@ function buildRunStatusToast(
|
|||
const runId = readString(payload.runId);
|
||||
const agentId = readString(payload.agentId);
|
||||
const status = readString(payload.status);
|
||||
if (!runId || !agentId || !status || !TERMINAL_RUN_STATUSES.has(status)) return null;
|
||||
if (!runId || !agentId || !status || !RUN_TOAST_STATUSES.has(status)) return null;
|
||||
|
||||
const error = readString(payload.error);
|
||||
const triggerDetail = readString(payload.triggerDetail);
|
||||
|
|
@ -653,6 +653,8 @@ function handleLiveEvent(
|
|||
}
|
||||
|
||||
export const __liveUpdatesTestUtils = {
|
||||
buildAgentStatusToast,
|
||||
buildRunStatusToast,
|
||||
invalidateActivityQueries,
|
||||
shouldSuppressActivityToastForVisibleIssue,
|
||||
shouldSuppressRunStatusToastForVisibleIssue,
|
||||
|
|
|
|||
23
ui/src/lib/company-routes.test.ts
Normal file
23
ui/src/lib/company-routes.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyCompanyPrefix,
|
||||
extractCompanyPrefixFromPath,
|
||||
isBoardPathWithoutPrefix,
|
||||
toCompanyRelativePath,
|
||||
} from "./company-routes";
|
||||
|
||||
describe("company routes", () => {
|
||||
it("treats execution workspace paths as board routes that need a company prefix", () => {
|
||||
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true);
|
||||
expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull();
|
||||
expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe(
|
||||
"/PAP/execution-workspaces/workspace-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes prefixed execution workspace paths back to company-relative paths", () => {
|
||||
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe(
|
||||
"/execution-workspaces/workspace-123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
|||
"org",
|
||||
"agents",
|
||||
"projects",
|
||||
"execution-workspaces",
|
||||
"issues",
|
||||
"routines",
|
||||
"goals",
|
||||
|
|
|
|||
231
ui/src/lib/project-workspaces-tab.test.ts
Normal file
231
ui/src/lib/project-workspaces-tab.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared";
|
||||
import { buildProjectWorkspaceSummaries } from "./project-workspaces-tab";
|
||||
|
||||
function createProjectWorkspace(overrides: Partial<ProjectWorkspace>): ProjectWorkspace {
|
||||
return {
|
||||
id: overrides.id ?? "workspace-default",
|
||||
companyId: overrides.companyId ?? "company-1",
|
||||
projectId: overrides.projectId ?? "project-1",
|
||||
name: overrides.name ?? "paperclip",
|
||||
sourceType: overrides.sourceType ?? "local_path",
|
||||
cwd: overrides.cwd ?? "/repo",
|
||||
repoUrl: overrides.repoUrl ?? null,
|
||||
repoRef: overrides.repoRef ?? null,
|
||||
defaultRef: overrides.defaultRef ?? null,
|
||||
visibility: overrides.visibility ?? "default",
|
||||
setupCommand: overrides.setupCommand ?? null,
|
||||
cleanupCommand: overrides.cleanupCommand ?? null,
|
||||
remoteProvider: overrides.remoteProvider ?? null,
|
||||
remoteWorkspaceRef: overrides.remoteWorkspaceRef ?? null,
|
||||
sharedWorkspaceKey: overrides.sharedWorkspaceKey ?? null,
|
||||
metadata: overrides.metadata ?? null,
|
||||
runtimeConfig: overrides.runtimeConfig ?? null,
|
||||
isPrimary: overrides.isPrimary ?? false,
|
||||
runtimeServices: overrides.runtimeServices ?? [],
|
||||
createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"),
|
||||
updatedAt: overrides.updatedAt ?? new Date("2026-03-20T00:00:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function createIssue(overrides: Partial<Issue>): Issue {
|
||||
return {
|
||||
id: overrides.id ?? "issue-1",
|
||||
companyId: overrides.companyId ?? "company-1",
|
||||
projectId: overrides.projectId ?? "project-1",
|
||||
projectWorkspaceId: overrides.projectWorkspaceId ?? null,
|
||||
goalId: overrides.goalId ?? null,
|
||||
parentId: overrides.parentId ?? null,
|
||||
title: overrides.title ?? "Issue",
|
||||
description: overrides.description ?? null,
|
||||
status: overrides.status ?? "todo",
|
||||
priority: overrides.priority ?? "medium",
|
||||
assigneeAgentId: overrides.assigneeAgentId ?? null,
|
||||
assigneeUserId: overrides.assigneeUserId ?? null,
|
||||
checkoutRunId: overrides.checkoutRunId ?? null,
|
||||
executionRunId: overrides.executionRunId ?? null,
|
||||
executionAgentNameKey: overrides.executionAgentNameKey ?? null,
|
||||
executionLockedAt: overrides.executionLockedAt ?? null,
|
||||
createdByAgentId: overrides.createdByAgentId ?? null,
|
||||
createdByUserId: overrides.createdByUserId ?? null,
|
||||
issueNumber: overrides.issueNumber ?? null,
|
||||
identifier: overrides.identifier ?? null,
|
||||
requestDepth: overrides.requestDepth ?? 0,
|
||||
billingCode: overrides.billingCode ?? null,
|
||||
assigneeAdapterOverrides: overrides.assigneeAdapterOverrides ?? null,
|
||||
executionWorkspaceId: overrides.executionWorkspaceId ?? null,
|
||||
executionWorkspacePreference: overrides.executionWorkspacePreference ?? null,
|
||||
executionWorkspaceSettings: overrides.executionWorkspaceSettings ?? null,
|
||||
startedAt: overrides.startedAt ?? null,
|
||||
completedAt: overrides.completedAt ?? null,
|
||||
cancelledAt: overrides.cancelledAt ?? null,
|
||||
hiddenAt: overrides.hiddenAt ?? null,
|
||||
createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"),
|
||||
updatedAt: overrides.updatedAt ?? new Date("2026-03-20T00:00:00Z"),
|
||||
} as Issue;
|
||||
}
|
||||
|
||||
function createExecutionWorkspace(overrides: Partial<ExecutionWorkspace>): ExecutionWorkspace {
|
||||
return {
|
||||
id: overrides.id ?? "exec-1",
|
||||
companyId: overrides.companyId ?? "company-1",
|
||||
projectId: overrides.projectId ?? "project-1",
|
||||
projectWorkspaceId: overrides.projectWorkspaceId ?? "workspace-default",
|
||||
sourceIssueId: overrides.sourceIssueId ?? null,
|
||||
mode: overrides.mode ?? "isolated_workspace",
|
||||
strategyType: overrides.strategyType ?? "git_worktree",
|
||||
name: overrides.name ?? "PAP-893",
|
||||
status: overrides.status ?? "active",
|
||||
cwd: overrides.cwd ?? "/repo/.worktrees/PAP-893",
|
||||
repoUrl: overrides.repoUrl ?? null,
|
||||
baseRef: overrides.baseRef ?? "public-gh/master",
|
||||
branchName: overrides.branchName ?? "PAP-893-workspaces-tab",
|
||||
providerType: overrides.providerType ?? "git_worktree",
|
||||
providerRef: overrides.providerRef ?? null,
|
||||
derivedFromExecutionWorkspaceId: overrides.derivedFromExecutionWorkspaceId ?? null,
|
||||
lastUsedAt: overrides.lastUsedAt ?? new Date("2026-03-26T10:00:00Z"),
|
||||
openedAt: overrides.openedAt ?? new Date("2026-03-26T09:00:00Z"),
|
||||
closedAt: overrides.closedAt ?? null,
|
||||
cleanupEligibleAt: overrides.cleanupEligibleAt ?? null,
|
||||
cleanupReason: overrides.cleanupReason ?? null,
|
||||
config: overrides.config ?? null,
|
||||
metadata: overrides.metadata ?? null,
|
||||
runtimeServices: overrides.runtimeServices ?? [],
|
||||
createdAt: overrides.createdAt ?? new Date("2026-03-26T09:00:00Z"),
|
||||
updatedAt: overrides.updatedAt ?? new Date("2026-03-26T09:30:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildProjectWorkspaceSummaries", () => {
|
||||
const primaryWorkspace = createProjectWorkspace({
|
||||
id: "workspace-default",
|
||||
isPrimary: true,
|
||||
name: "paperclip",
|
||||
});
|
||||
const featureWorkspace = createProjectWorkspace({
|
||||
id: "workspace-feature",
|
||||
name: "feature-checkout",
|
||||
repoRef: "feature/workspaces",
|
||||
updatedAt: new Date("2026-03-25T09:00:00Z"),
|
||||
});
|
||||
const project = {
|
||||
workspaces: [primaryWorkspace, featureWorkspace],
|
||||
primaryWorkspace,
|
||||
} satisfies Pick<Project, "workspaces" | "primaryWorkspace">;
|
||||
|
||||
it("groups isolated execution workspace issues ahead of shared non-primary workspace issues", () => {
|
||||
const summaries = buildProjectWorkspaceSummaries({
|
||||
project,
|
||||
issues: [
|
||||
createIssue({
|
||||
id: "issue-primary",
|
||||
projectWorkspaceId: primaryWorkspace.id,
|
||||
updatedAt: new Date("2026-03-26T08:00:00Z"),
|
||||
}),
|
||||
createIssue({
|
||||
id: "issue-feature-older",
|
||||
projectWorkspaceId: featureWorkspace.id,
|
||||
identifier: "PAP-800",
|
||||
updatedAt: new Date("2026-03-25T10:00:00Z"),
|
||||
}),
|
||||
createIssue({
|
||||
id: "issue-feature-newer",
|
||||
projectWorkspaceId: featureWorkspace.id,
|
||||
identifier: "PAP-801",
|
||||
updatedAt: new Date("2026-03-25T11:00:00Z"),
|
||||
}),
|
||||
createIssue({
|
||||
id: "issue-exec",
|
||||
projectWorkspaceId: primaryWorkspace.id,
|
||||
executionWorkspaceId: "exec-1",
|
||||
identifier: "PAP-893",
|
||||
updatedAt: new Date("2026-03-26T11:00:00Z"),
|
||||
}),
|
||||
],
|
||||
executionWorkspaces: [
|
||||
createExecutionWorkspace({
|
||||
id: "exec-1",
|
||||
name: "PAP-893",
|
||||
branchName: "PAP-893-workspaces-tab",
|
||||
lastUsedAt: new Date("2026-03-26T10:30:00Z"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(summaries).toHaveLength(3);
|
||||
expect(summaries[0]).toMatchObject({
|
||||
key: "execution:exec-1",
|
||||
kind: "execution_workspace",
|
||||
workspaceName: "PAP-893",
|
||||
branchName: "PAP-893-workspaces-tab",
|
||||
executionWorkspaceId: "exec-1",
|
||||
});
|
||||
expect(summaries[0]?.issues.map((issue) => issue.id)).toEqual(["issue-exec"]);
|
||||
|
||||
expect(summaries[1]).toMatchObject({
|
||||
key: "project:workspace-feature",
|
||||
kind: "project_workspace",
|
||||
workspaceName: "feature-checkout",
|
||||
branchName: "feature/workspaces",
|
||||
projectWorkspaceId: "workspace-feature",
|
||||
});
|
||||
expect(summaries[1]?.issues.map((issue) => issue.id)).toEqual([
|
||||
"issue-feature-newer",
|
||||
"issue-feature-older",
|
||||
]);
|
||||
expect(summaries[2]?.key).toBe("project:workspace-default");
|
||||
});
|
||||
|
||||
it("does not duplicate non-primary workspace issues when an execution workspace owns them", () => {
|
||||
const summaries = buildProjectWorkspaceSummaries({
|
||||
project,
|
||||
issues: [
|
||||
createIssue({
|
||||
id: "issue-exec-derived",
|
||||
projectWorkspaceId: featureWorkspace.id,
|
||||
executionWorkspaceId: "exec-2",
|
||||
updatedAt: new Date("2026-03-26T12:00:00Z"),
|
||||
}),
|
||||
],
|
||||
executionWorkspaces: [
|
||||
createExecutionWorkspace({
|
||||
id: "exec-2",
|
||||
projectWorkspaceId: featureWorkspace.id,
|
||||
name: "feature-branch run",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(summaries).toHaveLength(2);
|
||||
expect(summaries[0]?.key).toBe("execution:exec-2");
|
||||
expect(summaries[1]?.key).toBe("project:workspace-default");
|
||||
});
|
||||
|
||||
it("excludes issues that only use the default shared workspace", () => {
|
||||
const summaries = buildProjectWorkspaceSummaries({
|
||||
project,
|
||||
issues: [
|
||||
createIssue({
|
||||
id: "issue-default-shared",
|
||||
projectWorkspaceId: primaryWorkspace.id,
|
||||
executionWorkspaceId: "exec-shared-default",
|
||||
updatedAt: new Date("2026-03-26T12:00:00Z"),
|
||||
}),
|
||||
],
|
||||
executionWorkspaces: [
|
||||
createExecutionWorkspace({
|
||||
id: "exec-shared-default",
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
projectWorkspaceId: primaryWorkspace.id,
|
||||
branchName: null,
|
||||
baseRef: null,
|
||||
providerType: "local_fs",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(summaries).toHaveLength(1);
|
||||
expect(summaries[0]?.key).toBe("project:workspace-default");
|
||||
});
|
||||
});
|
||||
172
ui/src/lib/project-workspaces-tab.ts
Normal file
172
ui/src/lib/project-workspaces-tab.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import type { ExecutionWorkspace, Issue, Project } from "@paperclipai/shared";
|
||||
|
||||
type ProjectWorkspaceLike = Pick<Project, "workspaces" | "primaryWorkspace">;
|
||||
|
||||
export interface ProjectWorkspaceSummary {
|
||||
key: string;
|
||||
kind: "execution_workspace" | "project_workspace";
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
cwd: string | null;
|
||||
branchName: string | null;
|
||||
lastUpdatedAt: Date;
|
||||
projectWorkspaceId: string | null;
|
||||
executionWorkspaceId: string | null;
|
||||
executionWorkspaceStatus: ExecutionWorkspace["status"] | null;
|
||||
serviceCount: number;
|
||||
runningServiceCount: number;
|
||||
primaryServiceUrl: string | null;
|
||||
hasRuntimeConfig: boolean;
|
||||
issues: Issue[];
|
||||
}
|
||||
|
||||
function toDate(value: Date | string | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function maxDate(...values: Array<Date | string | null | undefined>): Date {
|
||||
let latest = new Date(0);
|
||||
for (const value of values) {
|
||||
const date = toDate(value);
|
||||
if (date && date.getTime() > latest.getTime()) latest = date;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function primaryWorkspaceId(project: ProjectWorkspaceLike): string | null {
|
||||
return project.primaryWorkspace?.id
|
||||
?? project.workspaces.find((workspace) => workspace.isPrimary)?.id
|
||||
?? project.workspaces[0]?.id
|
||||
?? null;
|
||||
}
|
||||
|
||||
function isDefaultSharedExecutionWorkspace(input: {
|
||||
executionWorkspace: ExecutionWorkspace;
|
||||
issue: Issue;
|
||||
primaryWorkspaceId: string | null;
|
||||
}) {
|
||||
const linkedProjectWorkspaceId =
|
||||
input.executionWorkspace.projectWorkspaceId ?? input.issue.projectWorkspaceId ?? null;
|
||||
return input.executionWorkspace.mode === "shared_workspace" && linkedProjectWorkspaceId === input.primaryWorkspaceId;
|
||||
}
|
||||
|
||||
export function buildProjectWorkspaceSummaries(input: {
|
||||
project: ProjectWorkspaceLike;
|
||||
issues: Issue[];
|
||||
executionWorkspaces: ExecutionWorkspace[];
|
||||
}): ProjectWorkspaceSummary[] {
|
||||
const primaryId = primaryWorkspaceId(input.project);
|
||||
const executionWorkspacesById = new Map(
|
||||
input.executionWorkspaces.map((workspace) => [workspace.id, workspace] as const),
|
||||
);
|
||||
const projectWorkspacesById = new Map(
|
||||
input.project.workspaces.map((workspace) => [workspace.id, workspace] as const),
|
||||
);
|
||||
const summaries = new Map<string, ProjectWorkspaceSummary>();
|
||||
|
||||
for (const issue of input.issues) {
|
||||
if (issue.executionWorkspaceId) {
|
||||
const executionWorkspace = executionWorkspacesById.get(issue.executionWorkspaceId);
|
||||
if (!executionWorkspace) continue;
|
||||
if (executionWorkspace.status === "archived") continue;
|
||||
if (isDefaultSharedExecutionWorkspace({
|
||||
executionWorkspace,
|
||||
issue,
|
||||
primaryWorkspaceId: primaryId,
|
||||
})) continue;
|
||||
|
||||
const existing = summaries.get(`execution:${executionWorkspace.id}`);
|
||||
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
|
||||
summaries.set(`execution:${executionWorkspace.id}`, {
|
||||
key: `execution:${executionWorkspace.id}`,
|
||||
kind: "execution_workspace",
|
||||
workspaceId: executionWorkspace.id,
|
||||
workspaceName: executionWorkspace.name,
|
||||
cwd: executionWorkspace.cwd ?? null,
|
||||
branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null,
|
||||
lastUpdatedAt: maxDate(
|
||||
existing?.lastUpdatedAt,
|
||||
executionWorkspace.lastUsedAt,
|
||||
executionWorkspace.updatedAt,
|
||||
issue.updatedAt,
|
||||
),
|
||||
projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: executionWorkspace.id,
|
||||
executionWorkspaceStatus: executionWorkspace.status,
|
||||
serviceCount: executionWorkspace.runtimeServices?.length ?? 0,
|
||||
runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||
primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||
hasRuntimeConfig: Boolean(
|
||||
executionWorkspace.config?.workspaceRuntime
|
||||
?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime,
|
||||
),
|
||||
issues: nextIssues,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!issue.projectWorkspaceId || issue.projectWorkspaceId === primaryId) continue;
|
||||
const projectWorkspace = projectWorkspacesById.get(issue.projectWorkspaceId);
|
||||
if (!projectWorkspace) continue;
|
||||
|
||||
const existing = summaries.get(`project:${projectWorkspace.id}`);
|
||||
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
|
||||
summaries.set(`project:${projectWorkspace.id}`, {
|
||||
key: `project:${projectWorkspace.id}`,
|
||||
kind: "project_workspace",
|
||||
workspaceId: projectWorkspace.id,
|
||||
workspaceName: projectWorkspace.name,
|
||||
cwd: projectWorkspace.cwd ?? null,
|
||||
branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null,
|
||||
lastUpdatedAt: maxDate(existing?.lastUpdatedAt, projectWorkspace.updatedAt, issue.updatedAt),
|
||||
projectWorkspaceId: projectWorkspace.id,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspaceStatus: null,
|
||||
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
|
||||
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
||||
issues: nextIssues,
|
||||
});
|
||||
}
|
||||
|
||||
for (const projectWorkspace of input.project.workspaces) {
|
||||
const key = `project:${projectWorkspace.id}`;
|
||||
if (summaries.has(key)) continue;
|
||||
const shouldSurfaceWorkspace =
|
||||
projectWorkspace.isPrimary
|
||||
|| Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime)
|
||||
|| (projectWorkspace.runtimeServices?.length ?? 0) > 0;
|
||||
if (!shouldSurfaceWorkspace) continue;
|
||||
summaries.set(key, {
|
||||
key,
|
||||
kind: "project_workspace",
|
||||
workspaceId: projectWorkspace.id,
|
||||
workspaceName: projectWorkspace.name,
|
||||
cwd: projectWorkspace.cwd ?? null,
|
||||
branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null,
|
||||
lastUpdatedAt: maxDate(projectWorkspace.updatedAt),
|
||||
projectWorkspaceId: projectWorkspace.id,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspaceStatus: null,
|
||||
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
|
||||
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
||||
issues: [],
|
||||
});
|
||||
}
|
||||
|
||||
return [...summaries.values()].sort((a, b) => {
|
||||
const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
|
||||
return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName);
|
||||
});
|
||||
}
|
||||
|
|
@ -25,6 +25,8 @@ export const queryKeys = {
|
|||
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
||||
adapterModels: (companyId: string, adapterType: string) =>
|
||||
["agents", companyId, "adapter-models", adapterType] as const,
|
||||
detectModel: (companyId: string, adapterType: string) =>
|
||||
["agents", companyId, "detect-model", adapterType] as const,
|
||||
},
|
||||
issues: {
|
||||
list: (companyId: string) => ["issues", companyId] as const,
|
||||
|
|
@ -37,6 +39,8 @@ export const queryKeys = {
|
|||
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
|
||||
listByProject: (companyId: string, projectId: string) =>
|
||||
["issues", companyId, "project", projectId] as const,
|
||||
listByExecutionWorkspace: (companyId: string, executionWorkspaceId: string) =>
|
||||
["issues", companyId, "execution-workspace", executionWorkspaceId] as const,
|
||||
detail: (id: string) => ["issues", "detail", id] as const,
|
||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||
|
|
@ -59,6 +63,8 @@ export const queryKeys = {
|
|||
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
|
||||
["execution-workspaces", companyId, filters ?? {}] as const,
|
||||
detail: (id: string) => ["execution-workspaces", "detail", id] as const,
|
||||
closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const,
|
||||
workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const,
|
||||
},
|
||||
projects: {
|
||||
list: (companyId: string) => ["projects", companyId] as const,
|
||||
|
|
|
|||
|
|
@ -165,3 +165,11 @@ export function projectRouteRef(project: { id: string; urlKey?: string | null; n
|
|||
export function projectUrl(project: { id: string; urlKey?: string | null; name?: string | null }): string {
|
||||
return `/projects/${projectRouteRef(project)}`;
|
||||
}
|
||||
|
||||
/** Build a project workspace URL scoped under its project. */
|
||||
export function projectWorkspaceUrl(
|
||||
project: { id: string; urlKey?: string | null; name?: string | null },
|
||||
workspaceId: string,
|
||||
): string {
|
||||
return `${projectUrl(project)}/workspaces/${workspaceId}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1075,10 +1075,28 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
|||
const isLive = run.status === "running" || run.status === "queued";
|
||||
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
||||
const StatusIcon = statusInfo.icon;
|
||||
const summary = run.resultJson
|
||||
const summaryRaw = run.resultJson
|
||||
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
||||
: run.error ?? "";
|
||||
|
||||
// Extract a clean 2-3 line excerpt: first non-empty, non-header, non-list-mark lines
|
||||
const summary = useMemo(() => {
|
||||
if (!summaryRaw) return "";
|
||||
const lines = summaryRaw
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0 && !l.startsWith("---") && !l.startsWith("|") && !l.startsWith("```") && !/^[-*>]/.test(l) && !/^\d+\./.test(l));
|
||||
const excerpt: string[] = [];
|
||||
let chars = 0;
|
||||
for (const line of lines) {
|
||||
if (excerpt.length >= 3 || chars + line.length > 280) break;
|
||||
excerpt.push(line);
|
||||
chars += line.length;
|
||||
}
|
||||
return excerpt.join(" ");
|
||||
}, [summaryRaw]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
|
|
@ -2351,6 +2369,7 @@ function AgentSkillsTab({
|
|||
const queryClient = useQueryClient();
|
||||
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
||||
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
|
||||
const [unmanagedOpen, setUnmanagedOpen] = useState(false);
|
||||
const lastSavedSkillsRef = useRef<string[]>([]);
|
||||
const hasHydratedSkillSnapshotRef = useRef(false);
|
||||
const skipNextSkillAutosaveRef = useRef(true);
|
||||
|
|
@ -2680,12 +2699,19 @@ function AgentSkillsTab({
|
|||
|
||||
{unmanagedSkillRows.length > 0 && (
|
||||
<section className="border-y border-border">
|
||||
<div className="border-b border-border bg-muted/40 px-3 py-2">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 select-none"
|
||||
onClick={() => setUnmanagedOpen((v) => !v)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setUnmanagedOpen((v) => !v); } }}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
User-installed skills, not managed by Paperclip
|
||||
({unmanagedSkillRows.length}) User-installed skills, not managed by Paperclip
|
||||
</span>
|
||||
{unmanagedOpen ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
</div>
|
||||
{unmanagedSkillRows.map(renderSkillRow)}
|
||||
{unmanagedOpen && unmanagedSkillRows.map(renderSkillRow)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const adapterLabels: Record<string, string> = {
|
|||
gemini_local: "Gemini",
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
hermes_local: "Hermes",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,33 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ExecutionWorkspace, Project, ProjectWorkspace } from "@paperclipai/shared";
|
||||
import { ArrowLeft, Check, Copy, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CopyText } from "../components/CopyText";
|
||||
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
|
||||
|
||||
type WorkspaceFormState = {
|
||||
name: string;
|
||||
cwd: string;
|
||||
repoUrl: string;
|
||||
baseRef: string;
|
||||
branchName: string;
|
||||
providerRef: string;
|
||||
provisionCommand: string;
|
||||
teardownCommand: string;
|
||||
cleanupCommand: string;
|
||||
inheritRuntime: boolean;
|
||||
workspaceRuntime: string;
|
||||
};
|
||||
|
||||
function isSafeExternalUrl(value: string | null | undefined) {
|
||||
if (!value) return false;
|
||||
|
|
@ -14,69 +39,829 @@ function isSafeExternalUrl(value: string | null | undefined) {
|
|||
}
|
||||
}
|
||||
|
||||
function readText(value: string | null | undefined) {
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
function formatJson(value: Record<string, unknown> | null | undefined) {
|
||||
if (!value || Object.keys(value).length === 0) return "";
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function normalizeText(value: string) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseWorkspaceRuntimeJson(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return { ok: true as const, value: null as Record<string, unknown> | null };
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: "Workspace runtime JSON must be a JSON object.",
|
||||
};
|
||||
}
|
||||
return { ok: true as const, value: parsed as Record<string, unknown> };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: error instanceof Error ? error.message : "Invalid JSON.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formStateFromWorkspace(workspace: ExecutionWorkspace): WorkspaceFormState {
|
||||
return {
|
||||
name: workspace.name,
|
||||
cwd: readText(workspace.cwd),
|
||||
repoUrl: readText(workspace.repoUrl),
|
||||
baseRef: readText(workspace.baseRef),
|
||||
branchName: readText(workspace.branchName),
|
||||
providerRef: readText(workspace.providerRef),
|
||||
provisionCommand: readText(workspace.config?.provisionCommand),
|
||||
teardownCommand: readText(workspace.config?.teardownCommand),
|
||||
cleanupCommand: readText(workspace.config?.cleanupCommand),
|
||||
inheritRuntime: !workspace.config?.workspaceRuntime,
|
||||
workspaceRuntime: formatJson(workspace.config?.workspaceRuntime),
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: WorkspaceFormState) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const configPatch: Record<string, unknown> = {};
|
||||
|
||||
const maybeAssign = (
|
||||
key: keyof Pick<WorkspaceFormState, "name" | "cwd" | "repoUrl" | "baseRef" | "branchName" | "providerRef">,
|
||||
) => {
|
||||
if (initialState[key] === nextState[key]) return;
|
||||
patch[key] = key === "name" ? (normalizeText(nextState[key]) ?? initialState.name) : normalizeText(nextState[key]);
|
||||
};
|
||||
|
||||
maybeAssign("name");
|
||||
maybeAssign("cwd");
|
||||
maybeAssign("repoUrl");
|
||||
maybeAssign("baseRef");
|
||||
maybeAssign("branchName");
|
||||
maybeAssign("providerRef");
|
||||
|
||||
const maybeAssignConfigText = (key: keyof Pick<WorkspaceFormState, "provisionCommand" | "teardownCommand" | "cleanupCommand">) => {
|
||||
if (initialState[key] === nextState[key]) return;
|
||||
configPatch[key] = normalizeText(nextState[key]);
|
||||
};
|
||||
|
||||
maybeAssignConfigText("provisionCommand");
|
||||
maybeAssignConfigText("teardownCommand");
|
||||
maybeAssignConfigText("cleanupCommand");
|
||||
|
||||
if (initialState.inheritRuntime !== nextState.inheritRuntime || initialState.workspaceRuntime !== nextState.workspaceRuntime) {
|
||||
const parsed = parseWorkspaceRuntimeJson(nextState.workspaceRuntime);
|
||||
if (!parsed.ok) throw new Error(parsed.error);
|
||||
configPatch.workspaceRuntime = nextState.inheritRuntime ? null : parsed.value;
|
||||
}
|
||||
|
||||
if (Object.keys(configPatch).length > 0) {
|
||||
patch.config = configPatch;
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
function validateForm(form: WorkspaceFormState) {
|
||||
const repoUrl = normalizeText(form.repoUrl);
|
||||
if (repoUrl) {
|
||||
try {
|
||||
new URL(repoUrl);
|
||||
} catch {
|
||||
return "Repo URL must be a valid URL.";
|
||||
}
|
||||
}
|
||||
|
||||
if (!form.inheritRuntime) {
|
||||
const runtimeJson = parseWorkspaceRuntimeJson(form.workspaceRuntime);
|
||||
if (!runtimeJson.ok) {
|
||||
return runtimeJson.error;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1.5">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<span className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{label}</span>
|
||||
{hint ? <span className="text-[11px] leading-relaxed text-muted-foreground sm:text-right">{hint}</span> : null}
|
||||
</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
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="flex flex-col gap-1.5 py-1.5 sm:flex-row sm:items-start sm:gap-3">
|
||||
<div className="shrink-0 text-xs text-muted-foreground sm:w-32">{label}</div>
|
||||
<div className="min-w-0 flex-1 text-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonoValue({ value, copy }: { value: string; copy?: boolean }) {
|
||||
return (
|
||||
<div className="inline-flex max-w-full items-start gap-2">
|
||||
<span className="break-all font-mono text-xs">{value}</span>
|
||||
{copy ? (
|
||||
<CopyText text={value} className="shrink-0 text-muted-foreground hover:text-foreground" copiedLabel="Copied">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</CopyText>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceLink({
|
||||
project,
|
||||
workspace,
|
||||
}: {
|
||||
project: Project;
|
||||
workspace: ProjectWorkspace;
|
||||
}) {
|
||||
return <Link to={projectWorkspaceUrl(project, workspace.id)} className="hover:underline">{workspace.name}</Link>;
|
||||
}
|
||||
|
||||
export function ExecutionWorkspaceDetail() {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const [form, setForm] = useState<WorkspaceFormState | null>(null);
|
||||
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
|
||||
|
||||
const { data: workspace, isLoading, error } = useQuery({
|
||||
const workspaceQuery = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.detail(workspaceId!),
|
||||
queryFn: () => executionWorkspacesApi.get(workspaceId!),
|
||||
enabled: Boolean(workspaceId),
|
||||
});
|
||||
const workspace = workspaceQuery.data ?? null;
|
||||
|
||||
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;
|
||||
const projectQuery = useQuery({
|
||||
queryKey: workspace ? [...queryKeys.projects.detail(workspace.projectId), workspace.companyId] : ["projects", "detail", "__pending__"],
|
||||
queryFn: () => projectsApi.get(workspace!.projectId, workspace!.companyId),
|
||||
enabled: Boolean(workspace?.projectId),
|
||||
});
|
||||
const project = projectQuery.data ?? null;
|
||||
|
||||
const sourceIssueQuery = useQuery({
|
||||
queryKey: workspace?.sourceIssueId ? queryKeys.issues.detail(workspace.sourceIssueId) : ["issues", "detail", "__none__"],
|
||||
queryFn: () => issuesApi.get(workspace!.sourceIssueId!),
|
||||
enabled: Boolean(workspace?.sourceIssueId),
|
||||
});
|
||||
const sourceIssue = sourceIssueQuery.data ?? null;
|
||||
|
||||
const derivedWorkspaceQuery = useQuery({
|
||||
queryKey: workspace?.derivedFromExecutionWorkspaceId
|
||||
? queryKeys.executionWorkspaces.detail(workspace.derivedFromExecutionWorkspaceId)
|
||||
: ["execution-workspaces", "detail", "__none__"],
|
||||
queryFn: () => executionWorkspacesApi.get(workspace!.derivedFromExecutionWorkspaceId!),
|
||||
enabled: Boolean(workspace?.derivedFromExecutionWorkspaceId),
|
||||
});
|
||||
const derivedWorkspace = derivedWorkspaceQuery.data ?? null;
|
||||
const linkedIssuesQuery = useQuery({
|
||||
queryKey: workspace
|
||||
? queryKeys.issues.listByExecutionWorkspace(workspace.companyId, workspace.id)
|
||||
: ["issues", "__execution-workspace__", "__none__"],
|
||||
queryFn: () => issuesApi.list(workspace!.companyId, { executionWorkspaceId: workspace!.id }),
|
||||
enabled: Boolean(workspace?.companyId),
|
||||
});
|
||||
const linkedIssues = linkedIssuesQuery.data ?? [];
|
||||
|
||||
const linkedProjectWorkspace = useMemo(
|
||||
() => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null,
|
||||
[project, workspace?.projectWorkspaceId],
|
||||
);
|
||||
const inheritedRuntimeConfig = linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime ?? null;
|
||||
const effectiveRuntimeConfig = workspace?.config?.workspaceRuntime ?? inheritedRuntimeConfig;
|
||||
const runtimeConfigSource =
|
||||
workspace?.config?.workspaceRuntime
|
||||
? "execution_workspace"
|
||||
: inheritedRuntimeConfig
|
||||
? "project_workspace"
|
||||
: "none";
|
||||
|
||||
const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]);
|
||||
const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState));
|
||||
const projectRef = project ? projectRouteRef(project) : workspace?.projectId ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace?.companyId || workspace.companyId === selectedCompanyId) return;
|
||||
setSelectedCompanyId(workspace.companyId, { source: "route_sync" });
|
||||
}, [workspace?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) return;
|
||||
setForm(formStateFromWorkspace(workspace));
|
||||
setErrorMessage(null);
|
||||
}, [workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) return;
|
||||
const crumbs = [
|
||||
{ label: "Projects", href: "/projects" },
|
||||
...(project ? [{ label: project.name, href: `/projects/${projectRef}` }] : []),
|
||||
...(project ? [{ label: "Workspaces", href: `/projects/${projectRef}/workspaces` }] : []),
|
||||
{ label: workspace.name },
|
||||
];
|
||||
setBreadcrumbs(crumbs);
|
||||
}, [setBreadcrumbs, workspace, project, projectRef]);
|
||||
|
||||
const updateWorkspace = useMutation({
|
||||
mutationFn: (patch: Record<string, unknown>) => executionWorkspacesApi.update(workspace!.id, patch),
|
||||
onSuccess: (nextWorkspace) => {
|
||||
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(nextWorkspace.id), nextWorkspace);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(nextWorkspace.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(nextWorkspace.id) });
|
||||
if (project) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) });
|
||||
}
|
||||
if (sourceIssue) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(sourceIssue.id) });
|
||||
}
|
||||
setErrorMessage(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to save execution workspace.");
|
||||
},
|
||||
});
|
||||
const workspaceOperationsQuery = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.workspaceOperations(workspaceId!),
|
||||
queryFn: () => executionWorkspacesApi.listWorkspaceOperations(workspaceId!),
|
||||
enabled: Boolean(workspaceId),
|
||||
});
|
||||
const controlRuntimeServices = useMutation({
|
||||
mutationFn: (action: "start" | "stop" | "restart") =>
|
||||
executionWorkspacesApi.controlRuntimeServices(workspace!.id, action),
|
||||
onSuccess: (result, action) => {
|
||||
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) });
|
||||
setErrorMessage(null);
|
||||
setRuntimeActionMessage(
|
||||
action === "stop"
|
||||
? "Runtime services stopped."
|
||||
: action === "restart"
|
||||
? "Runtime services restarted."
|
||||
: "Runtime services started.",
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
setRuntimeActionMessage(null);
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to control runtime services.");
|
||||
},
|
||||
});
|
||||
|
||||
if (workspaceQuery.isLoading) return <p className="text-sm text-muted-foreground">Loading workspace…</p>;
|
||||
if (workspaceQuery.error) {
|
||||
return (
|
||||
<p className="text-sm text-destructive">
|
||||
{workspaceQuery.error instanceof Error ? workspaceQuery.error.message : "Failed to load workspace"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!workspace || !form || !initialState) return null;
|
||||
|
||||
const saveChanges = () => {
|
||||
const validationError = validateForm(form);
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
let patch: Record<string, unknown>;
|
||||
try {
|
||||
patch = buildWorkspacePatch(initialState, form);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to build workspace update.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
updateWorkspace.mutate(patch);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">Execution workspace</div>
|
||||
<>
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Back to all workspaces
|
||||
</Link>
|
||||
</Button>
|
||||
<StatusPill>{workspace.mode}</StatusPill>
|
||||
<StatusPill>{workspace.providerType}</StatusPill>
|
||||
<StatusPill className={workspace.status === "active" ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : undefined}>
|
||||
{workspace.status}
|
||||
</StatusPill>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] 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}
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Configure the concrete runtime workspace that Paperclip reuses for this issue flow. These settings stay
|
||||
attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown,
|
||||
and runtime-service behavior in sync with the actual workspace being reused.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setCloseDialogOpen(true)}
|
||||
disabled={workspace.status === "archived"}
|
||||
>
|
||||
{workspace.status === "cleanup_failed" ? "Retry close" : "Close workspace"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<Separator className="my-5" />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Workspace name">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
|
||||
placeholder="Execution workspace name"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Branch name" hint="Useful for isolated worktrees">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.branchName}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, branchName: event.target.value } : current)}
|
||||
placeholder="PAP-946-workspace"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Working directory">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.cwd}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
|
||||
placeholder="/absolute/path/to/workspace"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Provider path / ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.providerRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, providerRef: event.target.value } : current)}
|
||||
placeholder="/path/to/worktree or provider ref"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Repo URL">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.repoUrl}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)}
|
||||
placeholder="https://github.com/org/repo"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Base ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.baseRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, baseRef: event.target.value } : current)}
|
||||
placeholder="origin/main"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
|
||||
<textarea
|
||||
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.provisionCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
|
||||
placeholder="bash ./scripts/provision-worktree.sh"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
|
||||
<textarea
|
||||
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.teardownCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
|
||||
placeholder="bash ./scripts/teardown-worktree.sh"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
|
||||
<textarea
|
||||
className="min-h-24 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.cleanupCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
||||
placeholder="pkill -f vite || true"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Runtime config source
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{runtimeConfigSource === "execution_workspace"
|
||||
? "This execution workspace currently overrides the project workspace runtime config."
|
||||
: runtimeConfigSource === "project_workspace"
|
||||
? "This execution workspace is inheriting the project workspace runtime config."
|
||||
: "No runtime config is currently defined on this execution workspace or its project workspace."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
size="sm"
|
||||
disabled={!linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime}
|
||||
onClick={() =>
|
||||
setForm((current) => current ? {
|
||||
...current,
|
||||
inheritRuntime: true,
|
||||
workspaceRuntime: "",
|
||||
} : current)
|
||||
}
|
||||
>
|
||||
Reset to inherit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Runtime services JSON" hint="Concrete workspace runtime settings for this execution workspace. Leave this inheriting unless you need a one-off override. If you are missing the right commands, ask your CEO to set them up for you.">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
id="inherit-runtime-config"
|
||||
type="checkbox"
|
||||
checked={form.inheritRuntime}
|
||||
onChange={(event) =>
|
||||
setForm((current) => current ? { ...current, inheritRuntime: event.target.checked } : current)
|
||||
}
|
||||
/>
|
||||
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
|
||||
</div>
|
||||
<textarea
|
||||
className="min-h-48 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={form.workspaceRuntime}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
|
||||
disabled={form.inheritRuntime}
|
||||
placeholder={'{\n "services": [\n {\n "name": "web",\n "command": "pnpm dev",\n "port": 3100\n }\n ]\n}'}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Button className="w-full sm:w-auto" disabled={!isDirty || updateWorkspace.isPending} onClick={saveChanges}>
|
||||
{updateWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Save changes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={!isDirty || updateWorkspace.isPending}
|
||||
onClick={() => {
|
||||
setForm(initialState);
|
||||
setErrorMessage(null);
|
||||
setRuntimeActionMessage(null);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
|
||||
{!errorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
|
||||
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked objects</div>
|
||||
<h2 className="text-lg font-semibold">Workspace context</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<DetailRow label="Project">
|
||||
{workspace.projectId ? <Link to={`/projects/${workspace.projectId}`} className="hover:underline">{workspace.projectId}</Link> : "None"}
|
||||
{project ? <Link to={`/projects/${projectRef}`} className="hover:underline">{project.name}</Link> : <MonoValue value={workspace.projectId} />}
|
||||
</DetailRow>
|
||||
<DetailRow label="Project workspace">
|
||||
{project && linkedProjectWorkspace ? (
|
||||
<WorkspaceLink project={project} workspace={linkedProjectWorkspace} />
|
||||
) : workspace.projectWorkspaceId ? (
|
||||
<MonoValue value={workspace.projectWorkspaceId} />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Source issue">
|
||||
{workspace.sourceIssueId ? <Link to={`/issues/${workspace.sourceIssueId}`} className="hover:underline">{workspace.sourceIssueId}</Link> : "None"}
|
||||
{sourceIssue ? (
|
||||
<Link to={issueUrl(sourceIssue)} className="hover:underline">
|
||||
{sourceIssue.identifier ?? sourceIssue.id} · {sourceIssue.title}
|
||||
</Link>
|
||||
) : workspace.sourceIssueId ? (
|
||||
<MonoValue value={workspace.sourceIssueId} />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Branch">{workspace.branchName ?? "None"}</DetailRow>
|
||||
<DetailRow label="Base ref">{workspace.baseRef ?? "None"}</DetailRow>
|
||||
<DetailRow label="Derived from">
|
||||
{derivedWorkspace ? (
|
||||
<Link to={`/execution-workspaces/${derivedWorkspace.id}`} className="hover:underline">
|
||||
{derivedWorkspace.name}
|
||||
</Link>
|
||||
) : workspace.derivedFromExecutionWorkspaceId ? (
|
||||
<MonoValue value={workspace.derivedFromExecutionWorkspaceId} />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Workspace ID">
|
||||
<MonoValue value={workspace.id} />
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Paths and refs</div>
|
||||
<h2 className="text-lg font-semibold">Concrete location</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<DetailRow label="Working dir">
|
||||
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span>
|
||||
{workspace.cwd ? <MonoValue value={workspace.cwd} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Provider ref">
|
||||
<span className="break-all font-mono text-xs">{workspace.providerRef ?? "None"}</span>
|
||||
{workspace.providerRef ? <MonoValue value={workspace.providerRef} copy /> : "None"}
|
||||
</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">
|
||||
<div className="inline-flex max-w-full items-start gap-2">
|
||||
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex min-w-0 items-center gap-1 break-all hover:underline">
|
||||
{workspace.repoUrl}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||
</a>
|
||||
<CopyText text={workspace.repoUrl} className="shrink-0 text-muted-foreground hover:text-foreground" copiedLabel="Copied">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</CopyText>
|
||||
</div>
|
||||
) : workspace.repoUrl ? (
|
||||
<span className="break-all font-mono text-xs">{workspace.repoUrl}</span>
|
||||
) : "None"}
|
||||
<MonoValue value={workspace.repoUrl} copy />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Opened">{new Date(workspace.openedAt).toLocaleString()}</DetailRow>
|
||||
<DetailRow label="Last used">{new Date(workspace.lastUsedAt).toLocaleString()}</DetailRow>
|
||||
<DetailRow label="Base ref">
|
||||
{workspace.baseRef ? <MonoValue value={workspace.baseRef} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Branch">
|
||||
{workspace.branchName ? <MonoValue value={workspace.branchName} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Opened">{formatDateTime(workspace.openedAt)}</DetailRow>
|
||||
<DetailRow label="Last used">{formatDateTime(workspace.lastUsedAt)}</DetailRow>
|
||||
<DetailRow label="Cleanup">
|
||||
{workspace.cleanupEligibleAt ? `${new Date(workspace.cleanupEligibleAt).toLocaleString()}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}` : "Not scheduled"}
|
||||
{workspace.cleanupEligibleAt
|
||||
? `${formatDateTime(workspace.cleanupEligibleAt)}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}`
|
||||
: "Not scheduled"}
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
|
||||
<h2 className="text-lg font-semibold">Attached services</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Source: {runtimeConfigSource === "execution_workspace"
|
||||
? "execution workspace override"
|
||||
: runtimeConfigSource === "project_workspace"
|
||||
? "project workspace default"
|
||||
: "none"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || !effectiveRuntimeConfig || !workspace.cwd}
|
||||
onClick={() => controlRuntimeServices.mutate("start")}
|
||||
>
|
||||
{controlRuntimeServices.isPending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || !effectiveRuntimeConfig || !workspace.cwd}
|
||||
onClick={() => controlRuntimeServices.mutate("restart")}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
|
||||
onClick={() => controlRuntimeServices.mutate("stop")}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{workspace.runtimeServices.map((service) => (
|
||||
<div key={service.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{service.serviceName}</div>
|
||||
<div className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{service.url ? (
|
||||
<a href={service.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
|
||||
{service.url}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
) : null}
|
||||
{service.port ? <div>Port {service.port}</div> : null}
|
||||
{service.command ? <MonoValue value={service.command} copy /> : null}
|
||||
{service.cwd ? <MonoValue value={service.cwd} copy /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill className="self-start">{service.healthStatus}</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{effectiveRuntimeConfig
|
||||
? "No runtime services are currently running for this execution workspace."
|
||||
: "No runtime config is defined for this execution workspace yet."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Recent operations</div>
|
||||
<h2 className="text-lg font-semibold">Runtime and cleanup logs</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
{workspaceOperationsQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading workspace operations…</p>
|
||||
) : workspaceOperationsQuery.error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{workspaceOperationsQuery.error instanceof Error
|
||||
? workspaceOperationsQuery.error.message
|
||||
: "Failed to load workspace operations."}
|
||||
</p>
|
||||
) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{workspaceOperationsQuery.data.slice(0, 6).map((operation) => (
|
||||
<div key={operation.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDateTime(operation.startedAt)}
|
||||
{operation.finishedAt ? ` → ${formatDateTime(operation.finishedAt)}` : ""}
|
||||
</div>
|
||||
{operation.stderrExcerpt ? (
|
||||
<div className="whitespace-pre-wrap break-words text-xs text-destructive">{operation.stderrExcerpt}</div>
|
||||
) : operation.stdoutExcerpt ? (
|
||||
<div className="whitespace-pre-wrap break-words text-xs text-muted-foreground">{operation.stdoutExcerpt}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<StatusPill className="self-start">{operation.status}</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No workspace operations have been recorded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked issues</div>
|
||||
<h2 className="text-lg font-semibold">Issues using this workspace</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Any issue attached to this execution workspace appears here so you can review the full session context before reusing or closing it.
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill>{linkedIssues.length} linked</StatusPill>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
{linkedIssuesQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading linked issues…</p>
|
||||
) : linkedIssuesQuery.error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{linkedIssuesQuery.error instanceof Error
|
||||
? linkedIssuesQuery.error.message
|
||||
: "Failed to load linked issues."}
|
||||
</p>
|
||||
) : linkedIssues.length > 0 ? (
|
||||
<div className="-mx-1 flex gap-3 overflow-x-auto px-1 pb-1">
|
||||
{linkedIssues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={issueUrl(issue)}
|
||||
className="min-w-72 rounded-xl border border-border/80 bg-background px-4 py-3 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</div>
|
||||
<div className="line-clamp-2 text-sm font-medium">{issue.title}</div>
|
||||
</div>
|
||||
<StatusPill className="shrink-0">{issue.status}</StatusPill>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span className="uppercase tracking-[0.16em]">{issue.priority}</span>
|
||||
<span>{formatDateTime(issue.updatedAt)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No issues are currently linked to this execution workspace.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ExecutionWorkspaceCloseDialog
|
||||
workspaceId={workspace.id}
|
||||
workspaceName={workspace.name}
|
||||
currentStatus={workspace.status}
|
||||
open={closeDialogOpen}
|
||||
onOpenChange={setCloseDialogOpen}
|
||||
onClosed={(nextWorkspace) => {
|
||||
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(nextWorkspace.id), nextWorkspace);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(nextWorkspace.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(nextWorkspace.id) });
|
||||
if (project) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(project.companyId, { projectId: project.id }) });
|
||||
}
|
||||
if (sourceIssue) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(sourceIssue.id) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,12 @@ const adapterLabels: Record<string, string> = {
|
|||
pi_local: "Pi (local)",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
hermes_local: "Hermes Agent",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
|
||||
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
|
||||
|
||||
function dateTime(value: string) {
|
||||
return new Date(value).toLocaleString();
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType
|
|||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
"hermes_local",
|
||||
"openclaw_gateway",
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ const adapterLabels: Record<string, string> = {
|
|||
gemini_local: "Gemini",
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
hermes_local: "Hermes",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
|
||||
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary, type ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import { budgetsApi } from "../api/budgets";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
|
|
@ -14,20 +16,26 @@ import { useToast } from "../context/ToastContext";
|
|||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
|
||||
import { CopyText } from "../components/CopyText";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { projectRouteRef, cn } from "../lib/utils";
|
||||
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
|
||||
import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
import { Clock3, Copy, GitBranch, Loader2 } from "lucide-react";
|
||||
|
||||
/* ── Top-level tab types ── */
|
||||
|
||||
type ProjectBaseTab = "overview" | "list" | "configuration" | "budget";
|
||||
type ProjectBaseTab = "overview" | "list" | "workspaces" | "configuration" | "budget";
|
||||
type ProjectPluginTab = `plugin:${string}`;
|
||||
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
||||
|
||||
|
|
@ -44,6 +52,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
|
|||
if (tab === "configuration") return "configuration";
|
||||
if (tab === "budget") return "budget";
|
||||
if (tab === "issues") return "list";
|
||||
if (tab === "workspaces") return "workspaces";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -200,6 +209,241 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
|
|||
);
|
||||
}
|
||||
|
||||
function ProjectWorkspacesContent({
|
||||
companyId,
|
||||
projectId,
|
||||
projectRef,
|
||||
summaries,
|
||||
}: {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
projectRef: string;
|
||||
summaries: ReturnType<typeof buildProjectWorkspaceSummaries>;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [runtimeActionKey, setRuntimeActionKey] = useState<string | null>(null);
|
||||
const [closingWorkspace, setClosingWorkspace] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: ExecutionWorkspace["status"];
|
||||
} | null>(null);
|
||||
const controlWorkspaceRuntime = useMutation({
|
||||
mutationFn: async (input: {
|
||||
key: string;
|
||||
kind: "project_workspace" | "execution_workspace";
|
||||
workspaceId: string;
|
||||
action: "start" | "stop" | "restart";
|
||||
}) => {
|
||||
setRuntimeActionKey(`${input.key}:${input.action}`);
|
||||
if (input.kind === "project_workspace") {
|
||||
return await projectsApi.controlWorkspaceRuntimeServices(projectId, input.workspaceId, input.action, companyId);
|
||||
}
|
||||
return await executionWorkspacesApi.controlRuntimeServices(input.workspaceId, input.action);
|
||||
},
|
||||
onSettled: () => {
|
||||
setRuntimeActionKey(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||||
},
|
||||
});
|
||||
|
||||
if (summaries.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No non-default workspace activity yet.</p>;
|
||||
}
|
||||
|
||||
const activeSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus !== "cleanup_failed");
|
||||
const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed");
|
||||
|
||||
const renderSummaryRow = (summary: ReturnType<typeof buildProjectWorkspaceSummaries>[number]) => {
|
||||
const visibleIssues = summary.issues.slice(0, 3);
|
||||
const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0);
|
||||
const workspaceHref =
|
||||
summary.kind === "project_workspace"
|
||||
? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId)
|
||||
: `/execution-workspaces/${summary.workspaceId}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={summary.key}
|
||||
className="border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,18rem)_minmax(0,1fr)_auto] md:items-start">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={workspaceHref}
|
||||
className="block truncate text-sm font-medium hover:underline"
|
||||
>
|
||||
{summary.workspaceName}
|
||||
</Link>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
<span className="font-mono">{summary.branchName ?? "No branch info"}</span>
|
||||
</span>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-[11px]">
|
||||
{summary.runningServiceCount}/{summary.serviceCount} services running
|
||||
</span>
|
||||
{summary.executionWorkspaceStatus ? (
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-[11px]">
|
||||
{summary.executionWorkspaceStatus}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{summary.primaryServiceUrl ? (
|
||||
<a
|
||||
href={summary.primaryServiceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
{summary.primaryServiceUrl}
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{summary.cwd ? (
|
||||
<div className="mt-2 flex min-w-0 items-start gap-2 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate font-mono leading-tight" title={summary.cwd}>
|
||||
{summary.cwd}
|
||||
</span>
|
||||
<CopyText text={summary.cwd} className="shrink-0" copiedLabel="Path copied">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</CopyText>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Issues ({summary.issues.length})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visibleIssues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-md border border-border bg-background px-2.5 py-1.5 text-left text-xs leading-none transition-colors hover:bg-accent"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="truncate leading-tight">{issue.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
{hiddenIssueCount > 0 ? (
|
||||
<span className="inline-flex items-center rounded-md border border-dashed border-border px-2.5 py-1.5 text-xs text-muted-foreground">
|
||||
... and {hiddenIssueCount} more
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-start gap-2 md:items-end">
|
||||
<Link
|
||||
to={workspaceHref}
|
||||
className="text-xs font-medium text-foreground hover:underline"
|
||||
>
|
||||
{summary.kind === "project_workspace" ? "Configure workspace" : "View workspace"}
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={
|
||||
controlWorkspaceRuntime.isPending
|
||||
|| !summary.hasRuntimeConfig
|
||||
|| runtimeActionKey !== null && runtimeActionKey !== `${summary.key}:start`
|
||||
}
|
||||
onClick={() =>
|
||||
controlWorkspaceRuntime.mutate({
|
||||
key: summary.key,
|
||||
kind: summary.kind,
|
||||
workspaceId: summary.workspaceId,
|
||||
action: "start",
|
||||
})
|
||||
}
|
||||
>
|
||||
{runtimeActionKey === `${summary.key}:start` ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={controlWorkspaceRuntime.isPending || summary.serviceCount === 0}
|
||||
onClick={() =>
|
||||
controlWorkspaceRuntime.mutate({
|
||||
key: summary.key,
|
||||
kind: summary.kind,
|
||||
workspaceId: summary.workspaceId,
|
||||
action: "stop",
|
||||
})
|
||||
}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
{summary.kind === "execution_workspace" && summary.executionWorkspaceId && summary.executionWorkspaceStatus ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setClosingWorkspace({
|
||||
id: summary.executionWorkspaceId!,
|
||||
name: summary.workspaceName,
|
||||
status: summary.executionWorkspaceStatus!,
|
||||
})}
|
||||
>
|
||||
{summary.executionWorkspaceStatus === "cleanup_failed" ? "Retry close" : "Close workspace"}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
{timeAgo(summary.lastUpdatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{activeSummaries.map(renderSummaryRow)}
|
||||
</div>
|
||||
{cleanupFailedSummaries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Cleanup attention needed
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-amber-500/20 bg-amber-500/5">
|
||||
{cleanupFailedSummaries.map(renderSummaryRow)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{closingWorkspace ? (
|
||||
<ExecutionWorkspaceCloseDialog
|
||||
workspaceId={closingWorkspace.id}
|
||||
workspaceName={closingWorkspace.name}
|
||||
currentStatus={closingWorkspace.status}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setClosingWorkspace(null);
|
||||
}}
|
||||
onClosed={() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||||
setClosingWorkspace(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main project page ── */
|
||||
|
||||
export function ProjectDetail() {
|
||||
|
|
@ -241,6 +485,10 @@ export function ProjectDetail() {
|
|||
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
||||
const projectLookupRef = project?.id ?? routeProjectRef;
|
||||
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
|
||||
const experimentalSettingsQuery = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
const {
|
||||
slots: pluginDetailSlots,
|
||||
isLoading: pluginDetailSlotsLoading,
|
||||
|
|
@ -259,6 +507,39 @@ export function ProjectDetail() {
|
|||
[pluginDetailSlots],
|
||||
);
|
||||
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
|
||||
const isolatedWorkspacesEnabled = experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true;
|
||||
const workspaceTabProjectId = project?.id ?? null;
|
||||
const { data: workspaceTabIssues = [], isLoading: isWorkspaceTabIssuesLoading, error: workspaceTabIssuesError } = useQuery({
|
||||
queryKey: workspaceTabProjectId && resolvedCompanyId
|
||||
? queryKeys.issues.listByProject(resolvedCompanyId, workspaceTabProjectId)
|
||||
: ["issues", "__workspace-tab__", "disabled"],
|
||||
queryFn: () => issuesApi.list(resolvedCompanyId!, { projectId: workspaceTabProjectId! }),
|
||||
enabled: Boolean(resolvedCompanyId && workspaceTabProjectId && isolatedWorkspacesEnabled),
|
||||
});
|
||||
const {
|
||||
data: workspaceTabExecutionWorkspaces = [],
|
||||
isLoading: isWorkspaceTabExecutionWorkspacesLoading,
|
||||
error: workspaceTabExecutionWorkspacesError,
|
||||
} = useQuery({
|
||||
queryKey: workspaceTabProjectId && resolvedCompanyId
|
||||
? queryKeys.executionWorkspaces.list(resolvedCompanyId, { projectId: workspaceTabProjectId })
|
||||
: ["execution-workspaces", "__workspace-tab__", "disabled"],
|
||||
queryFn: () => executionWorkspacesApi.list(resolvedCompanyId!, { projectId: workspaceTabProjectId! }),
|
||||
enabled: Boolean(resolvedCompanyId && workspaceTabProjectId && isolatedWorkspacesEnabled),
|
||||
});
|
||||
const workspaceSummaries = useMemo(() => {
|
||||
if (!project || !isolatedWorkspacesEnabled) return [];
|
||||
return buildProjectWorkspaceSummaries({
|
||||
project,
|
||||
issues: workspaceTabIssues,
|
||||
executionWorkspaces: workspaceTabExecutionWorkspaces,
|
||||
});
|
||||
}, [project, isolatedWorkspacesEnabled, workspaceTabIssues, workspaceTabExecutionWorkspaces]);
|
||||
const showWorkspacesTab = isolatedWorkspacesEnabled && workspaceSummaries.length > 0;
|
||||
const workspaceTabDecisionLoaded =
|
||||
experimentalSettingsQuery.isFetched &&
|
||||
(!isolatedWorkspacesEnabled || (!isWorkspaceTabIssuesLoading && !isWorkspaceTabExecutionWorkspacesLoading));
|
||||
const workspaceTabError = (workspaceTabIssuesError ?? workspaceTabExecutionWorkspacesError) as Error | null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
||||
|
|
@ -345,6 +626,10 @@ export function ProjectDetail() {
|
|||
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "workspaces") {
|
||||
navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "list") {
|
||||
if (filter) {
|
||||
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
|
||||
|
|
@ -455,6 +740,10 @@ export function ProjectDetail() {
|
|||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||
}
|
||||
|
||||
if (activeTab === "workspaces" && workspaceTabDecisionLoaded && !showWorkspacesTab) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||
}
|
||||
|
||||
// Redirect bare /projects/:id to cached tab or default /issues
|
||||
if (routeProjectRef && activeTab === null) {
|
||||
let cachedTab: string | null = null;
|
||||
|
|
@ -470,6 +759,12 @@ export function ProjectDetail() {
|
|||
if (cachedTab === "budget") {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
|
||||
}
|
||||
if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/workspaces`} replace />;
|
||||
}
|
||||
if (cachedTab === "workspaces" && !workspaceTabDecisionLoaded) {
|
||||
return <PageSkeleton variant="detail" />;
|
||||
}
|
||||
if (isProjectPluginTab(cachedTab)) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(cachedTab)}`} replace />;
|
||||
}
|
||||
|
|
@ -491,6 +786,8 @@ export function ProjectDetail() {
|
|||
}
|
||||
if (tab === "overview") {
|
||||
navigate(`/projects/${canonicalProjectRef}/overview`);
|
||||
} else if (tab === "workspaces") {
|
||||
navigate(`/projects/${canonicalProjectRef}/workspaces`);
|
||||
} else if (tab === "budget") {
|
||||
navigate(`/projects/${canonicalProjectRef}/budget`);
|
||||
} else if (tab === "configuration") {
|
||||
|
|
@ -561,6 +858,7 @@ export function ProjectDetail() {
|
|||
items={[
|
||||
{ value: "list", label: "Issues" },
|
||||
{ value: "overview", label: "Overview" },
|
||||
...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []),
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "budget", label: "Budget" },
|
||||
...pluginTabItems.map((item) => ({
|
||||
|
|
@ -589,6 +887,23 @@ export function ProjectDetail() {
|
|||
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
|
||||
)}
|
||||
|
||||
{activeTab === "workspaces" ? (
|
||||
workspaceTabDecisionLoaded ? (
|
||||
workspaceTabError ? (
|
||||
<p className="text-sm text-destructive">{workspaceTabError.message}</p>
|
||||
) : (
|
||||
<ProjectWorkspacesContent
|
||||
companyId={resolvedCompanyId!}
|
||||
projectId={project.id}
|
||||
projectRef={canonicalProjectRef}
|
||||
summaries={workspaceSummaries}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Loading workspaces...</p>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{activeTab === "configuration" && (
|
||||
<div className="max-w-4xl">
|
||||
<ProjectProperties
|
||||
|
|
|
|||
673
ui/src/pages/ProjectWorkspaceDetail.tsx
Normal file
673
ui/src/pages/ProjectWorkspaceDetail.tsx
Normal file
|
|
@ -0,0 +1,673 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "@/lib/router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isUuidLike, type ProjectWorkspace } from "@paperclipai/shared";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ChoosePathButton } from "../components/PathInstructionsModal";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
|
||||
|
||||
type WorkspaceFormState = {
|
||||
name: string;
|
||||
sourceType: ProjectWorkspaceSourceType;
|
||||
cwd: string;
|
||||
repoUrl: string;
|
||||
repoRef: string;
|
||||
defaultRef: string;
|
||||
visibility: ProjectWorkspaceVisibility;
|
||||
setupCommand: string;
|
||||
cleanupCommand: string;
|
||||
remoteProvider: string;
|
||||
remoteWorkspaceRef: string;
|
||||
sharedWorkspaceKey: string;
|
||||
runtimeConfig: string;
|
||||
};
|
||||
|
||||
type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"];
|
||||
type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"];
|
||||
|
||||
const SOURCE_TYPE_OPTIONS: Array<{ value: ProjectWorkspaceSourceType; label: string; description: string }> = [
|
||||
{ value: "local_path", label: "Local git checkout", description: "A local path Paperclip can use directly." },
|
||||
{ value: "non_git_path", label: "Local non-git path", description: "A local folder without git semantics." },
|
||||
{ value: "git_repo", label: "Remote git repo", description: "A repo URL with optional refs and local checkout." },
|
||||
{ value: "remote_managed", label: "Remote-managed workspace", description: "A hosted workspace tracked by external reference." },
|
||||
];
|
||||
|
||||
const VISIBILITY_OPTIONS: Array<{ value: ProjectWorkspaceVisibility; label: string }> = [
|
||||
{ value: "default", label: "Default" },
|
||||
{ value: "advanced", label: "Advanced" },
|
||||
];
|
||||
|
||||
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 isAbsolutePath(value: string) {
|
||||
return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
function readText(value: string | null | undefined) {
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
function formatJson(value: Record<string, unknown> | null | undefined) {
|
||||
if (!value || Object.keys(value).length === 0) return "";
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function formStateFromWorkspace(workspace: ProjectWorkspace): WorkspaceFormState {
|
||||
return {
|
||||
name: workspace.name,
|
||||
sourceType: workspace.sourceType,
|
||||
cwd: readText(workspace.cwd),
|
||||
repoUrl: readText(workspace.repoUrl),
|
||||
repoRef: readText(workspace.repoRef),
|
||||
defaultRef: readText(workspace.defaultRef),
|
||||
visibility: workspace.visibility,
|
||||
setupCommand: readText(workspace.setupCommand),
|
||||
cleanupCommand: readText(workspace.cleanupCommand),
|
||||
remoteProvider: readText(workspace.remoteProvider),
|
||||
remoteWorkspaceRef: readText(workspace.remoteWorkspaceRef),
|
||||
sharedWorkspaceKey: readText(workspace.sharedWorkspaceKey),
|
||||
runtimeConfig: formatJson(workspace.runtimeConfig?.workspaceRuntime),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeText(value: string) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseRuntimeConfigJson(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return { ok: true as const, value: null as Record<string, unknown> | null };
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: "Runtime services JSON must be a JSON object.",
|
||||
};
|
||||
}
|
||||
return { ok: true as const, value: parsed as Record<string, unknown> };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: error instanceof Error ? error.message : "Invalid JSON.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: WorkspaceFormState) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const maybeAssign = (key: keyof WorkspaceFormState, transform?: (value: string) => unknown) => {
|
||||
const initialValue = initialState[key];
|
||||
const nextValue = nextState[key];
|
||||
if (initialValue === nextValue) return;
|
||||
patch[key] = transform ? transform(nextValue) : nextValue;
|
||||
};
|
||||
|
||||
maybeAssign("name", normalizeText);
|
||||
maybeAssign("sourceType");
|
||||
maybeAssign("cwd", normalizeText);
|
||||
maybeAssign("repoUrl", normalizeText);
|
||||
maybeAssign("repoRef", normalizeText);
|
||||
maybeAssign("defaultRef", normalizeText);
|
||||
maybeAssign("visibility");
|
||||
maybeAssign("setupCommand", normalizeText);
|
||||
maybeAssign("cleanupCommand", normalizeText);
|
||||
maybeAssign("remoteProvider", normalizeText);
|
||||
maybeAssign("remoteWorkspaceRef", normalizeText);
|
||||
maybeAssign("sharedWorkspaceKey", normalizeText);
|
||||
if (initialState.runtimeConfig !== nextState.runtimeConfig) {
|
||||
const parsed = parseRuntimeConfigJson(nextState.runtimeConfig);
|
||||
if (!parsed.ok) throw new Error(parsed.error);
|
||||
patch.runtimeConfig = {
|
||||
workspaceRuntime: parsed.value,
|
||||
};
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
function validateWorkspaceForm(form: WorkspaceFormState) {
|
||||
const cwd = normalizeText(form.cwd);
|
||||
const repoUrl = normalizeText(form.repoUrl);
|
||||
const remoteWorkspaceRef = normalizeText(form.remoteWorkspaceRef);
|
||||
|
||||
if (form.sourceType === "remote_managed") {
|
||||
if (!remoteWorkspaceRef && !repoUrl) {
|
||||
return "Remote-managed workspaces require a remote workspace ref or repo URL.";
|
||||
}
|
||||
} else if (!cwd && !repoUrl) {
|
||||
return "Workspace requires at least one local path or repo URL.";
|
||||
}
|
||||
|
||||
if (cwd && (form.sourceType === "local_path" || form.sourceType === "non_git_path") && !isAbsolutePath(cwd)) {
|
||||
return "Local workspace path must be absolute.";
|
||||
}
|
||||
|
||||
if (repoUrl) {
|
||||
try {
|
||||
new URL(repoUrl);
|
||||
} catch {
|
||||
return "Repo URL must be a valid URL.";
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeConfig = parseRuntimeConfigJson(form.runtimeConfig);
|
||||
if (!runtimeConfig.ok) {
|
||||
return runtimeConfig.error;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1.5">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<span className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{label}</span>
|
||||
{hint ? <span className="text-[11px] leading-relaxed text-muted-foreground sm:text-right">{hint}</span> : null}
|
||||
</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-1.5 sm:flex-row sm:items-start sm:gap-3">
|
||||
<div className="shrink-0 text-xs text-muted-foreground sm:w-28">{label}</div>
|
||||
<div className="min-w-0 flex-1 text-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectWorkspaceDetail() {
|
||||
const { companyPrefix, projectId, workspaceId } = useParams<{
|
||||
companyPrefix?: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
}>();
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState<WorkspaceFormState | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
|
||||
const routeProjectRef = projectId ?? "";
|
||||
const routeWorkspaceId = workspaceId ?? "";
|
||||
|
||||
const routeCompanyId = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
const requestedPrefix = companyPrefix.toUpperCase();
|
||||
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null;
|
||||
}, [companies, companyPrefix]);
|
||||
|
||||
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
|
||||
const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId));
|
||||
const projectQuery = useQuery({
|
||||
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
|
||||
queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId),
|
||||
enabled: canFetchProject,
|
||||
});
|
||||
|
||||
const project = projectQuery.data ?? null;
|
||||
const workspace = useMemo(
|
||||
() => project?.workspaces.find((item) => item.id === routeWorkspaceId) ?? null,
|
||||
[project, routeWorkspaceId],
|
||||
);
|
||||
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
||||
const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]);
|
||||
const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState));
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
||||
setSelectedCompanyId(project.companyId, { source: "route_sync" });
|
||||
}, [project?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) return;
|
||||
setForm(formStateFromWorkspace(workspace));
|
||||
setErrorMessage(null);
|
||||
}, [workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
setBreadcrumbs([
|
||||
{ label: "Projects", href: "/projects" },
|
||||
{ label: project.name, href: `/projects/${canonicalProjectRef}` },
|
||||
{ label: "Workspaces", href: `/projects/${canonicalProjectRef}/workspaces` },
|
||||
{ label: workspace?.name ?? routeWorkspaceId },
|
||||
]);
|
||||
}, [setBreadcrumbs, project, canonicalProjectRef, workspace?.name, routeWorkspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
if (routeProjectRef === canonicalProjectRef) return;
|
||||
navigate(projectWorkspaceUrl(project, routeWorkspaceId), { replace: true });
|
||||
}, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, navigate]);
|
||||
|
||||
const invalidateProject = () => {
|
||||
if (!project) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) });
|
||||
if (lookupCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(lookupCompanyId) });
|
||||
}
|
||||
};
|
||||
|
||||
const updateWorkspace = useMutation({
|
||||
mutationFn: (patch: Record<string, unknown>) =>
|
||||
projectsApi.updateWorkspace(project!.id, routeWorkspaceId, patch, lookupCompanyId),
|
||||
onSuccess: () => {
|
||||
invalidateProject();
|
||||
setErrorMessage(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to save workspace.");
|
||||
},
|
||||
});
|
||||
|
||||
const setPrimaryWorkspace = useMutation({
|
||||
mutationFn: () => projectsApi.updateWorkspace(project!.id, routeWorkspaceId, { isPrimary: true }, lookupCompanyId),
|
||||
onSuccess: () => {
|
||||
invalidateProject();
|
||||
setErrorMessage(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to update workspace.");
|
||||
},
|
||||
});
|
||||
|
||||
const controlRuntimeServices = useMutation({
|
||||
mutationFn: (action: "start" | "stop" | "restart") =>
|
||||
projectsApi.controlWorkspaceRuntimeServices(project!.id, routeWorkspaceId, action, lookupCompanyId),
|
||||
onSuccess: (result, action) => {
|
||||
invalidateProject();
|
||||
setErrorMessage(null);
|
||||
setRuntimeActionMessage(
|
||||
action === "stop"
|
||||
? "Runtime services stopped."
|
||||
: action === "restart"
|
||||
? "Runtime services restarted."
|
||||
: "Runtime services started.",
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
setRuntimeActionMessage(null);
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to control runtime services.");
|
||||
},
|
||||
});
|
||||
|
||||
if (projectQuery.isLoading) return <p className="text-sm text-muted-foreground">Loading workspace…</p>;
|
||||
if (projectQuery.error) {
|
||||
return (
|
||||
<p className="text-sm text-destructive">
|
||||
{projectQuery.error instanceof Error ? projectQuery.error.message : "Failed to load workspace"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!project || !workspace || !form || !initialState) {
|
||||
return <p className="text-sm text-muted-foreground">Workspace not found for this project.</p>;
|
||||
}
|
||||
|
||||
const saveChanges = () => {
|
||||
const validationError = validateWorkspaceForm(form);
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError);
|
||||
return;
|
||||
}
|
||||
const patch = buildWorkspacePatch(initialState, form);
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
updateWorkspace.mutate(patch);
|
||||
};
|
||||
|
||||
const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/projects/${canonicalProjectRef}/workspaces`}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Back to workspaces
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground">
|
||||
{workspace.isPrimary ? "Primary workspace" : "Secondary workspace"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.9fr)]">
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Project workspace
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace
|
||||
checkout behavior, default runtime services for child execution workspaces, and let you override setup
|
||||
or cleanup commands when one workspace needs special handling.
|
||||
</p>
|
||||
</div>
|
||||
{!workspace.isPrimary ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={setPrimaryWorkspace.isPending}
|
||||
onClick={() => setPrimaryWorkspace.mutate()}
|
||||
>
|
||||
{setPrimaryWorkspace.isPending
|
||||
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
: <Check className="mr-2 h-4 w-4" />}
|
||||
Make primary
|
||||
</Button>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-2 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300 sm:max-w-sm">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
This is the project’s primary codebase workspace.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-5" />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Workspace name">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
|
||||
placeholder="Workspace name"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Visibility">
|
||||
<select
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.visibility}
|
||||
onChange={(event) =>
|
||||
setForm((current) => current ? { ...current, visibility: event.target.value as ProjectWorkspaceVisibility } : current)
|
||||
}
|
||||
>
|
||||
{VISIBILITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
<Field label="Source type" hint={sourceTypeDescription ?? undefined}>
|
||||
<select
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.sourceType}
|
||||
onChange={(event) =>
|
||||
setForm((current) => current ? { ...current, sourceType: event.target.value as ProjectWorkspaceSourceType } : current)
|
||||
}
|
||||
>
|
||||
{SOURCE_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Field label="Local path">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.cwd}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
|
||||
placeholder="/absolute/path/to/workspace"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-end">
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Repo URL">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.repoUrl}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)}
|
||||
placeholder="https://github.com/org/repo"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Repo ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.repoRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, repoRef: event.target.value } : current)}
|
||||
placeholder="origin/main"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Default ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.defaultRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, defaultRef: event.target.value } : current)}
|
||||
placeholder="origin/main"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Shared workspace key">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.sharedWorkspaceKey}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, sharedWorkspaceKey: event.target.value } : current)}
|
||||
placeholder="frontend"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Remote provider">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.remoteProvider}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, remoteProvider: event.target.value } : current)}
|
||||
placeholder="codespaces"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Remote workspace ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.remoteWorkspaceRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, remoteWorkspaceRef: event.target.value } : current)}
|
||||
placeholder="workspace-123"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Setup command" hint="Runs when this workspace needs custom bootstrap">
|
||||
<textarea
|
||||
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.setupCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, setupCommand: event.target.value } : current)}
|
||||
placeholder="pnpm install && pnpm dev"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Cleanup command" hint="Runs before project-level execution workspace teardown">
|
||||
<textarea
|
||||
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.cleanupCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
||||
placeholder="pkill -f vite || true"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Runtime services JSON" hint="Default runtime services for this workspace. Execution workspaces inherit this config unless they set an override. If you do not know the commands yet, ask your CEO to configure them for you.">
|
||||
<textarea
|
||||
className="min-h-36 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.runtimeConfig}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, runtimeConfig: event.target.value } : current)}
|
||||
placeholder={"{\n \"services\": [\n {\n \"name\": \"web\",\n \"command\": \"pnpm dev\",\n \"cwd\": \".\",\n \"port\": { \"type\": \"auto\" },\n \"readiness\": {\n \"type\": \"http\",\n \"urlTemplate\": \"http://127.0.0.1:${port}\"\n },\n \"expose\": {\n \"type\": \"url\",\n \"urlTemplate\": \"http://127.0.0.1:${port}\"\n },\n \"lifecycle\": \"shared\",\n \"reuseScope\": \"project_workspace\"\n }\n ]\n}"}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Button className="w-full sm:w-auto" disabled={!isDirty || updateWorkspace.isPending} onClick={saveChanges}>
|
||||
{updateWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Save changes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={!isDirty || updateWorkspace.isPending}
|
||||
onClick={() => {
|
||||
setForm(initialState);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
|
||||
{!errorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
|
||||
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Workspace facts</div>
|
||||
<h2 className="text-lg font-semibold">Current state</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<DetailRow label="Project">
|
||||
<Link to={`/projects/${canonicalProjectRef}`} className="hover:underline">{project.name}</Link>
|
||||
</DetailRow>
|
||||
<DetailRow label="Workspace ID">
|
||||
<span className="break-all font-mono text-xs">{workspace.id}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Local path">
|
||||
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Repo">
|
||||
{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="Default ref">{workspace.defaultRef ?? "None"}</DetailRow>
|
||||
<DetailRow label="Updated">{new Date(workspace.updatedAt).toLocaleString()}</DetailRow>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
|
||||
<h2 className="text-lg font-semibold">Attached services</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shared services for this project workspace. Execution workspaces inherit this config unless they override it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || !workspace.runtimeConfig?.workspaceRuntime || !workspace.cwd}
|
||||
onClick={() => controlRuntimeServices.mutate("start")}
|
||||
>
|
||||
{controlRuntimeServices.isPending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || !workspace.cwd}
|
||||
onClick={() => controlRuntimeServices.mutate("restart")}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
|
||||
onClick={() => controlRuntimeServices.mutate("stop")}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{workspace.runtimeServices.map((service) => (
|
||||
<div key={service.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{service.serviceName}</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{service.url ? (
|
||||
<a href={service.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
|
||||
{service.url}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : null}
|
||||
{service.port ? <div>Port {service.port}</div> : null}
|
||||
<div>{service.command ?? "No command recorded"}</div>
|
||||
{service.cwd ? <div className="break-all font-mono">{service.cwd}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground sm:text-right">
|
||||
{service.status} · {service.healthStatus}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{workspace.runtimeConfig?.workspaceRuntime
|
||||
? "No runtime services are currently running for this workspace."
|
||||
: "No runtime-service default is configured for this workspace yet."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue