mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aecd36194 | ||
|
|
539441b3d5 | ||
|
|
4b9eff1759 | ||
|
|
48bc2f3166 | ||
|
|
14af053c66 | ||
|
|
4f3116d28a | ||
|
|
5666b3014a | ||
|
|
07ace19b69 | ||
|
|
f1a20a9fe9 | ||
|
|
eac5d93f78 | ||
|
|
541e4c04cf | ||
|
|
ce8ad8c68f | ||
|
|
1ede1bccf7 | ||
|
|
a5c2f39f65 | ||
|
|
a807eb0eb2 | ||
|
|
d406c29e16 | ||
|
|
8c6abcb41f | ||
|
|
b8ccd543c4 | ||
|
|
ae5dfc0b3c | ||
|
|
539d2969f1 | ||
|
|
7fb3dda20c | ||
|
|
89ec358d00 |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.89 - 2026-06-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Open workspace services through public service proxy links** ([#1280](https://github.com/getpaseo/paseo/pull/1280) by [@mcowger](https://github.com/mcowger))
|
||||
- **Choose where new worktrees are created** ([#1230](https://github.com/getpaseo/paseo/pull/1230) by [@mcowger](https://github.com/mcowger))
|
||||
- **Desktop windows reopen at the same size and position** ([#1224](https://github.com/getpaseo/paseo/pull/1224) by [@everton-dgn](https://github.com/everton-dgn))
|
||||
- **Delegated agents can run independently and send recurring heartbeat updates**
|
||||
|
||||
### Improved
|
||||
|
||||
- Composer controls fit better in narrow panes
|
||||
- Fork pull request badges stay visible in worktrees
|
||||
- Cline in the ACP catalog is updated to v3
|
||||
|
||||
### Fixed
|
||||
|
||||
- Archiving a worktree finishes even if teardown hits an error ([#1260](https://github.com/getpaseo/paseo/pull/1260) by [@mcowger](https://github.com/mcowger))
|
||||
- iOS chat messages render bold, italics, strikethrough, and line breaks correctly ([#1254](https://github.com/getpaseo/paseo/pull/1254) by [@outofrange-consulting](https://github.com/outofrange-consulting))
|
||||
- Right-edge split pane resizing no longer clips ([#1261](https://github.com/getpaseo/paseo/pull/1261) by [@everton-dgn](https://github.com/everton-dgn))
|
||||
- Pi extension command output no longer hangs
|
||||
- Delegated agents no longer appear in workspace alert counts
|
||||
|
||||
## 0.1.88 - 2026-06-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -36,6 +36,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
|
||||
@@ -14,14 +14,14 @@ Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `
|
||||
|
||||
## Relationships
|
||||
|
||||
Agents can launch other agents via the `create_agent` MCP tool. When they do, the daemon stamps the new agent with a label `paseo.parent-agent-id` pointing back at the caller (`packages/server/src/server/agent/mcp-server.ts:804`). The client surfaces that as `agent.parentAgentId`.
|
||||
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label `paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`.
|
||||
|
||||
There is exactly one relationship type today: `parentAgentId`. The daemon does not distinguish between:
|
||||
Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`.
|
||||
|
||||
- **Subagents** — children that exist as part of the parent's work (e.g. orchestration tasks the parent delegates and waits on)
|
||||
- **Detached agents** — children launched to take over from the parent (e.g. handoffs, fire-and-forget delegations)
|
||||
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
|
||||
|
||||
Both look the same in storage. This is an accepted limitation — see [Limitations](#limitations).
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
|
||||
|
||||
## Archive
|
||||
|
||||
@@ -77,12 +77,6 @@ We considered universal decoupling (no tab close ever archives, archive is alway
|
||||
|
||||
## Limitations
|
||||
|
||||
### Detached agents are cascade-archived
|
||||
|
||||
The daemon can't tell a "subagent" apart from a "detached agent" — both carry `paseo.parent-agent-id`. So when you archive an agent that previously launched a detached child (e.g. via `/paseo-handoff`), cascade will archive the detached child too, even though semantically it should outlive the originator.
|
||||
|
||||
Until a richer relation model lands (e.g. a `relation: "subagent" | "detached"` field on creation, or a separate channel for handoff launches), this trade-off stands. Workaround: don't archive an agent whose work was handed off, or unarchive the detached child afterward.
|
||||
|
||||
### Subagent accumulation under long-lived parents
|
||||
|
||||
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
|
||||
@@ -99,11 +93,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` MCP tool |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by agent-scoped `create_agent` unless `detached: true` |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
|
||||
@@ -147,6 +147,9 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
app: {
|
||||
baseUrl: string
|
||||
},
|
||||
worktrees?: {
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
},
|
||||
providers: {
|
||||
openai: { apiKey: string },
|
||||
local: { modelsDir: string }
|
||||
|
||||
@@ -158,12 +158,14 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
|
||||
|
||||
| Variable | Value |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
|
||||
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
|
||||
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
|
||||
| `HOST` | Bind host for the service process. |
|
||||
|
||||
Service proxy hostnames use the double-dash shape: `web--feature-auth--project.localhost` or, on the default branch, `web--project.localhost`. Optional public aliases use the same leftmost label under the configured public base host.
|
||||
|
||||
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
|
||||
|
||||
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
|
||||
|
||||
@@ -18,7 +18,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/protocol/src/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
|
||||
|
||||
@@ -232,18 +232,16 @@ To confirm the submission landed, inspect the EAS workflow with `npx eas workflo
|
||||
|
||||
The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS `Release Mobile` workflow for the release tag. If any build is `ERRORED`/`CANCELED`, any workflow is `FAILURE`, or any required submit/review job fails, surface it immediately. If all builds are `FINISHED` and all required submit/review jobs are `SUCCESS`, confirm and stop.
|
||||
|
||||
**Use a heartbeat schedule, never a new-agent schedule.** Babysitting fires back into the current conversation as a wake-up prompt — `target: "self"` in `mcp__paseo__create_schedule`. Never use `target: "new-agent"`. A new agent spawns a fresh conversation the user has to find and read; a heartbeat surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `new-agent` for a release babysit, you are about to ship a status report into a void.
|
||||
**Use `create_heartbeat`, never `create_schedule`, for release babysitting.** Babysitting fires back into the current conversation as a wake-up prompt. `create_schedule` starts a fresh agent the user has to find and read; `create_heartbeat` surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `create_schedule` for a release babysit, you are about to ship a status report into a void.
|
||||
|
||||
Pattern:
|
||||
|
||||
```jsonc
|
||||
// mcp__paseo__create_schedule arguments
|
||||
// mcp__paseo__create_heartbeat arguments
|
||||
{
|
||||
"name": "vX.Y.Z release babysit heartbeat",
|
||||
"every": "15m",
|
||||
"cron": "*/15 * * * *",
|
||||
"maxRuns": 8, // covers ~2h of build + store-submission window
|
||||
"target": "self", // heartbeat, NOT "new-agent"
|
||||
"cwd": "/path/to/paseo",
|
||||
"prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
|
||||
}
|
||||
```
|
||||
|
||||
92
docs/service-proxy.md
Normal file
92
docs/service-proxy.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Service Proxy
|
||||
|
||||
Paseo proxies HTTP traffic to services running inside your workspaces. Localhost service URLs are always enabled; optional public aliases and a separate service-only listener can be layered on through config.
|
||||
|
||||
## How it works
|
||||
|
||||
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
|
||||
|
||||
The generated hostname is built from the script name, branch, and project:
|
||||
|
||||
```
|
||||
<script>--<branch>--<project>.localhost
|
||||
```
|
||||
|
||||
If the branch is `main` or `master`, the branch segment is omitted:
|
||||
|
||||
```
|
||||
<script>--<project>.localhost
|
||||
```
|
||||
|
||||
**Example:** a script named `dev` in the `miniweb` project on branch `feature/auth` would be reachable at:
|
||||
|
||||
```
|
||||
dev--feature-auth--miniweb.localhost
|
||||
```
|
||||
|
||||
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"daemon": {
|
||||
"serviceProxy": {
|
||||
"listen": "0.0.0.0:8080",
|
||||
"publicBaseUrl": "https://paseoapps.my.domain.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `listen` | No | Starts a separate service-only listener at this address. If omitted, services are still reachable on the daemon listener via localhost hosts. |
|
||||
| `publicBaseUrl` | No | Adds public service host aliases and public service links. If omitted, links use localhost addresses only. |
|
||||
|
||||
`enabled` is accepted for old configs but no longer enables a mode. `enabled: false` suppresses optional `listen`/`publicBaseUrl` layers only; localhost service proxying remains always enabled.
|
||||
|
||||
## DNS and reverse proxy setup
|
||||
|
||||
For generated URLs to be reachable, you need wildcard DNS pointing to the machine running the Paseo daemon.
|
||||
|
||||
**Example:** to expose services at `https://dev--miniweb.paseoapps.my.domain.com` where the daemon host is `10.1.1.1`:
|
||||
|
||||
1. Configure a wildcard DNS record:
|
||||
|
||||
```
|
||||
*.paseoapps.my.domain.com → 10.1.1.1
|
||||
```
|
||||
|
||||
2. Set `publicBaseUrl` to `https://paseoapps.my.domain.com` in your config.
|
||||
|
||||
3. If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of Paseo, point it at either the daemon listener or the optional service-only listener and ensure it forwards the `Host` header unchanged. The proxy uses the `Host` header to route requests to the correct service — rewriting it will break routing.
|
||||
|
||||
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
|
||||
|
||||
Nginx example:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name *.paseoapps.my.domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://10.1.1.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `PASEO_SERVICE_PROXY_ENABLED` | Compatibility shim; `false` suppresses optional public/listen layers only |
|
||||
| `PASEO_SERVICE_PROXY_LISTEN` | Starts the optional service-only listener, e.g. `0.0.0.0:8080` |
|
||||
| `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Adds public service aliases and links |
|
||||
@@ -1 +1 @@
|
||||
sha256-vz+c4jwakEd0nBtsR4mmBk8eq9WYVqqQxXOx2tHKSUA=
|
||||
sha256-R2EUXh8wV0bjdeDNWMhFzd3iDV/dpkLUikbX7nzoMXQ=
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -36953,7 +36953,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -37178,12 +37178,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.88",
|
||||
"@getpaseo/protocol": "0.1.88",
|
||||
"@getpaseo/server": "0.1.88",
|
||||
"@getpaseo/client": "0.1.89",
|
||||
"@getpaseo/protocol": "0.1.89",
|
||||
"@getpaseo/server": "0.1.89",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37429,10 +37429,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.88",
|
||||
"@getpaseo/relay": "0.1.88",
|
||||
"@getpaseo/protocol": "0.1.89",
|
||||
"@getpaseo/relay": "0.1.89",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37452,7 +37452,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37704,7 +37704,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -37740,7 +37740,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37971,7 +37971,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -37992,7 +37992,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38210,14 +38210,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/client": "0.1.88",
|
||||
"@getpaseo/highlight": "0.1.88",
|
||||
"@getpaseo/protocol": "0.1.88",
|
||||
"@getpaseo/relay": "0.1.88",
|
||||
"@getpaseo/client": "0.1.89",
|
||||
"@getpaseo/highlight": "0.1.89",
|
||||
"@getpaseo/protocol": "0.1.89",
|
||||
"@getpaseo/relay": "0.1.89",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38989,7 +38989,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -17,14 +17,18 @@
|
||||
<title>%WEB_TITLE%</title>
|
||||
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
|
||||
<style id="expo-reset">
|
||||
/* These styles make the body full-height */
|
||||
/* Keep the app shell fixed to the viewport. */
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* These styles disable body scrolling if you are using <ScrollView> */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
/* These styles smooth text rendering in the app shell. */
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -38,8 +42,12 @@
|
||||
/* These styles make the root element full-height */
|
||||
#root {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
|
||||
@@ -1622,6 +1622,67 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
// strong/em/s have no custom rule in react-native-markdown-display's
|
||||
// defaults beyond wrapping children in a plain RN <Text>. On iOS the
|
||||
// paragraph/textgroup are native UITextViews (see markdown-text.ios.tsx),
|
||||
// and a plain <Text> nested inside one is not hoisted into a
|
||||
// UITextViewChild, so its content renders invisibly. Route these inline
|
||||
// marks through MarkdownTextSpan (same path as text/textgroup) so the
|
||||
// styled content composes and stays visible + selectable on iOS.
|
||||
strong: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.strong}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
em: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.em}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
s: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.s}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
// hardbreak/softbreak fall back to react-native-markdown-display's
|
||||
// default, a plain RN <Text>{"\n"}. Inside the paragraph UITextView that
|
||||
// plain <Text> is not hoisted into a UITextViewChild and is dropped (same
|
||||
// root cause as strong/em/s) — so on iOS a hard line break vanished, and
|
||||
// a softbreak between words jammed them together ("one\ntwo" -> "onetwo").
|
||||
// Emit the break through MarkdownTextSpan so it composes on iOS; web and
|
||||
// Android keep the same "\n" they rendered before.
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
|
||||
67
packages/app/src/components/resize-handle-sizes.test.ts
Normal file
67
packages/app/src/components/resize-handle-sizes.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
|
||||
|
||||
describe("computeResizeHandleSizes", () => {
|
||||
it("clamps right-edge drags to the adjacent pane minimum", () => {
|
||||
const sizes = computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 1,
|
||||
deltaRatio: 0.5,
|
||||
});
|
||||
|
||||
expect(sizes[0]).toBe(0.25);
|
||||
expect(sizes[1]).toBe(0.65);
|
||||
expect(sizes[2]).toBeCloseTo(0.1, 10);
|
||||
});
|
||||
|
||||
it("clamps left-edge drags to the adjacent pane minimum", () => {
|
||||
const sizes = computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 1,
|
||||
deltaRatio: -0.5,
|
||||
});
|
||||
|
||||
expect(sizes[0]).toBe(0.25);
|
||||
expect(sizes[1]).toBe(0.1);
|
||||
expect(sizes[2]).toBeCloseTo(0.65, 10);
|
||||
});
|
||||
|
||||
it("moves adjacent pane sizes without clamping", () => {
|
||||
const sizes = computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 1,
|
||||
deltaRatio: 0.05,
|
||||
});
|
||||
|
||||
expect(sizes[0]).toBe(0.25);
|
||||
expect(sizes[1]).toBe(0.55);
|
||||
expect(sizes[2]).toBeCloseTo(0.2, 10);
|
||||
});
|
||||
|
||||
it("splits tiny adjacent pairs evenly when the configured minimum cannot fit", () => {
|
||||
expect(
|
||||
computeResizeHandleSizes({
|
||||
sizes: [0.45, 0.05, 0.05, 0.45],
|
||||
index: 1,
|
||||
deltaRatio: 0.05,
|
||||
}),
|
||||
).toEqual([0.45, 0.05, 0.05, 0.45]);
|
||||
});
|
||||
|
||||
it("leaves sizes unchanged when the adjacent pair is invalid", () => {
|
||||
expect(
|
||||
computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 3,
|
||||
deltaRatio: 0.25,
|
||||
}),
|
||||
).toEqual([0.25, 0.5, 0.25]);
|
||||
expect(
|
||||
computeResizeHandleSizes({
|
||||
sizes: [0.25, 0, 0, 0.75],
|
||||
index: 1,
|
||||
deltaRatio: 0.25,
|
||||
}),
|
||||
).toEqual([0.25, 0, 0, 0.75]);
|
||||
});
|
||||
});
|
||||
36
packages/app/src/components/resize-handle-sizes.ts
Normal file
36
packages/app/src/components/resize-handle-sizes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
|
||||
|
||||
interface ComputeResizeHandleSizesInput {
|
||||
sizes: number[];
|
||||
index: number;
|
||||
deltaRatio: number;
|
||||
minSize?: number;
|
||||
}
|
||||
|
||||
export function computeResizeHandleSizes({
|
||||
sizes,
|
||||
index,
|
||||
deltaRatio,
|
||||
minSize = MIN_SPLIT_SIZE,
|
||||
}: ComputeResizeHandleSizesInput): number[] {
|
||||
const nextSizes = sizes.slice();
|
||||
const leftSize = sizes[index];
|
||||
const rightSize = sizes[index + 1];
|
||||
if (leftSize === undefined || rightSize === undefined) {
|
||||
return nextSizes;
|
||||
}
|
||||
|
||||
const pairSize = leftSize + rightSize;
|
||||
if (pairSize <= 0) {
|
||||
return nextSizes;
|
||||
}
|
||||
|
||||
const adjacentMinSize = Math.min(minSize, pairSize / 2);
|
||||
const nextLeftSize = Math.min(
|
||||
pairSize - adjacentMinSize,
|
||||
Math.max(adjacentMinSize, leftSize + deltaRatio),
|
||||
);
|
||||
nextSizes[index] = nextLeftSize;
|
||||
nextSizes[index + 1] = pairSize - nextLeftSize;
|
||||
return nextSizes;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, type PointerEvent as RNPointerEvent } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
|
||||
|
||||
export interface ResizeHandleProps {
|
||||
direction: "horizontal" | "vertical";
|
||||
@@ -13,8 +14,14 @@ export interface ResizeHandleProps {
|
||||
interface PointerState {
|
||||
containerSize: number;
|
||||
pointerStart: number;
|
||||
leftSize: number;
|
||||
rightSize: number;
|
||||
}
|
||||
|
||||
function resetWindowHorizontalScroll() {
|
||||
// Clamp any browser scroll introduced while dragging past the viewport edge.
|
||||
if (window.scrollX === 0) {
|
||||
return;
|
||||
}
|
||||
window.scrollTo(0, window.scrollY);
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
@@ -25,7 +32,8 @@ export function ResizeHandle({
|
||||
onResizeSplit,
|
||||
}: ResizeHandleProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pointerStateRef = useRef<PointerState | null>(null);
|
||||
const pointerStatesRef = useRef(new Map<number, PointerState>());
|
||||
const cursorBeforeDragRef = useRef<string | null>(null);
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -34,7 +42,11 @@ export function ResizeHandle({
|
||||
const handlePointerDown = useCallback(
|
||||
(event: RNPointerEvent) => {
|
||||
const hitAreaElement = event.currentTarget as unknown as HTMLElement | null;
|
||||
const containerElement = hitAreaElement?.parentElement?.parentElement ?? null;
|
||||
if (!hitAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerElement = hitAreaElement.parentElement?.parentElement ?? null;
|
||||
if (!containerElement) {
|
||||
return;
|
||||
}
|
||||
@@ -45,51 +57,83 @@ export function ResizeHandle({
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerId = event.nativeEvent.pointerId;
|
||||
if (pointerStatesRef.current.has(pointerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDragging(true);
|
||||
|
||||
pointerStateRef.current = {
|
||||
pointerStatesRef.current.set(pointerId, {
|
||||
containerSize,
|
||||
pointerStart:
|
||||
direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY,
|
||||
leftSize: sizes[index] ?? 0,
|
||||
rightSize: sizes[index + 1] ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
const previousCursor = document.body.style.cursor;
|
||||
if (pointerStatesRef.current.size === 1) {
|
||||
cursorBeforeDragRef.current = document.body.style.cursor;
|
||||
}
|
||||
const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize";
|
||||
document.body.style.cursor = nextCursor;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const pointerCaptureElement = hitAreaElement;
|
||||
pointerCaptureElement.setPointerCapture?.(pointerId);
|
||||
resetWindowHorizontalScroll();
|
||||
|
||||
function cleanup() {
|
||||
pointerStateRef.current = null;
|
||||
setDragging(false);
|
||||
document.body.style.cursor = previousCursor;
|
||||
pointerStatesRef.current.delete(pointerId);
|
||||
setDragging(pointerStatesRef.current.size > 0);
|
||||
if (pointerStatesRef.current.size === 0) {
|
||||
document.body.style.cursor = cursorBeforeDragRef.current ?? "";
|
||||
cursorBeforeDragRef.current = null;
|
||||
}
|
||||
if (pointerCaptureElement.hasPointerCapture?.(pointerId)) {
|
||||
pointerCaptureElement.releasePointerCapture(pointerId);
|
||||
}
|
||||
resetWindowHorizontalScroll();
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
window.removeEventListener("pointercancel", handlePointerUp);
|
||||
}
|
||||
|
||||
function handlePointerMove(moveEvent: PointerEvent) {
|
||||
const pointerState = pointerStateRef.current;
|
||||
if (moveEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerState = pointerStatesRef.current.get(pointerId);
|
||||
if (!pointerState) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveEvent.preventDefault();
|
||||
resetWindowHorizontalScroll();
|
||||
const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
|
||||
const deltaRatio =
|
||||
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
|
||||
|
||||
const nextSizes = sizes.slice();
|
||||
nextSizes[index] = pointerState.leftSize + deltaRatio;
|
||||
nextSizes[index + 1] = pointerState.rightSize - deltaRatio;
|
||||
onResizeSplit(groupId, nextSizes);
|
||||
onResizeSplit(
|
||||
groupId,
|
||||
computeResizeHandleSizes({
|
||||
sizes,
|
||||
index,
|
||||
deltaRatio,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
function handlePointerUp(upEvent: PointerEvent) {
|
||||
if (upEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
},
|
||||
[direction, groupId, index, onResizeSplit, sizes],
|
||||
);
|
||||
@@ -130,6 +174,7 @@ export function ResizeHandle({
|
||||
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
|
||||
{
|
||||
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
|
||||
touchAction: "none",
|
||||
} as object,
|
||||
],
|
||||
[direction],
|
||||
|
||||
@@ -97,6 +97,7 @@ interface ControlledAgentControlsProps {
|
||||
/** Extra elements rendered inline with the agent controls (desktop only). */
|
||||
desktopExtras?: ReactNode;
|
||||
modelSelectorServerId?: string | null;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
export interface DraftAgentControlsProps {
|
||||
@@ -124,12 +125,14 @@ export interface DraftAgentControlsProps {
|
||||
isRetryingModelProvider?: boolean;
|
||||
disabled?: boolean;
|
||||
modelSelectorServerId?: string | null;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
interface AgentControlsProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
onDropdownClose?: () => void;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
function findOptionLabel(
|
||||
@@ -409,9 +412,11 @@ function ControlledAgentControls({
|
||||
isRetryingModelProvider = false,
|
||||
desktopExtras,
|
||||
modelSelectorServerId = null,
|
||||
isCompactLayout,
|
||||
}: ControlledAgentControlsProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
|
||||
const [openSelector, setOpenSelector] = useState<AgentControlSelector | null>(null);
|
||||
|
||||
@@ -1345,6 +1350,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
agentId,
|
||||
serverId,
|
||||
onDropdownClose,
|
||||
isCompactLayout,
|
||||
}: AgentControlsProps) {
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const agent = useSessionStore(
|
||||
@@ -1518,8 +1524,15 @@ export const AgentControls = memo(function AgentControls({
|
||||
);
|
||||
|
||||
const modeChip = useMemo(
|
||||
() => <AgentModeControl serverId={serverId} agentId={agentId} placement="toolbar" />,
|
||||
[serverId, agentId],
|
||||
() => (
|
||||
<AgentModeControl
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
placement="toolbar"
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
),
|
||||
[serverId, agentId, isCompactLayout],
|
||||
);
|
||||
|
||||
if (!agent) {
|
||||
@@ -1548,6 +1561,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
disabled={!client}
|
||||
desktopExtras={modeChip}
|
||||
modelSelectorServerId={serverId}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1577,9 +1591,11 @@ export function DraftAgentControls({
|
||||
isRetryingModelProvider = false,
|
||||
disabled = false,
|
||||
modelSelectorServerId = null,
|
||||
isCompactLayout,
|
||||
}: DraftAgentControlsProps) {
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
|
||||
const mappedThinkingOptions = useMemo<AgentControlOption[]>(() => {
|
||||
return toThinkingControlOptions(thinkingOptions);
|
||||
@@ -1625,9 +1641,18 @@ export function DraftAgentControls({
|
||||
selectedMode={selectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
disabled={disabled}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
),
|
||||
[selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled],
|
||||
[
|
||||
selectedProvider,
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
onSelectMode,
|
||||
disabled,
|
||||
isCompactLayout,
|
||||
],
|
||||
);
|
||||
|
||||
if (!isCompact) {
|
||||
@@ -1661,6 +1686,7 @@ export function DraftAgentControls({
|
||||
isRetryingModelProvider={isRetryingModelProvider}
|
||||
disabled={disabled}
|
||||
desktopExtras={draftModeChip}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -1688,6 +1714,7 @@ export function DraftAgentControls({
|
||||
isRetryingModelProvider={isRetryingModelProvider}
|
||||
disabled={disabled}
|
||||
modelSelectorServerId={modelSelectorServerId}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,14 +235,17 @@ interface AgentModeControlProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
placement: AgentModeControlPlacement;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
export const AgentModeControl = memo(function AgentModeControl({
|
||||
serverId,
|
||||
agentId,
|
||||
placement,
|
||||
isCompactLayout,
|
||||
}: AgentModeControlProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
const slice = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId);
|
||||
@@ -303,6 +306,7 @@ export interface DraftAgentModeControlProps {
|
||||
onSelectMode: (modeId: string) => void;
|
||||
disabled?: boolean;
|
||||
placement: AgentModeControlPlacement;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
export function DraftAgentModeControl({
|
||||
@@ -313,8 +317,10 @@ export function DraftAgentModeControl({
|
||||
onSelectMode,
|
||||
disabled,
|
||||
placement,
|
||||
isCompactLayout,
|
||||
}: DraftAgentModeControlProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
if (!selectedProvider || modeOptions.length === 0) return null;
|
||||
if (!shouldRenderForPlacement(placement, isCompact)) return null;
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Composer } from "@/composer";
|
||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
@@ -36,7 +37,11 @@ import {
|
||||
useWorkspaceAttachmentScopeKey,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
COMPACT_FORM_FACTOR_WIDTH,
|
||||
MAX_CONTENT_WIDTH,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
|
||||
@@ -380,7 +385,11 @@ export function WorkspaceDraftAgentTab({
|
||||
};
|
||||
}, [pendingAutoSubmit, pendingCreateAttempt]);
|
||||
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
COMPACT_FORM_FACTOR_WIDTH,
|
||||
{ initialIsBelow: isCompactFormFactor },
|
||||
);
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
serverId,
|
||||
cwd: composerState.workingDir,
|
||||
@@ -401,14 +410,14 @@ export function WorkspaceDraftAgentTab({
|
||||
};
|
||||
openFileExplorerForCheckout({
|
||||
checkout,
|
||||
isCompact,
|
||||
isCompact: isCompactFormFactor,
|
||||
});
|
||||
setExplorerTabForCheckout({
|
||||
...checkout,
|
||||
tab: "changes",
|
||||
});
|
||||
},
|
||||
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -625,10 +634,14 @@ export function WorkspaceDraftAgentTab({
|
||||
);
|
||||
const composerFooter = useMemo(
|
||||
() =>
|
||||
isCompact ? (
|
||||
<DraftAgentModeControl placement="footer" {...composerAgentControls} />
|
||||
isCompactComposerLayout ? (
|
||||
<DraftAgentModeControl
|
||||
placement="footer"
|
||||
{...composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
) : undefined,
|
||||
[isCompact, composerAgentControls],
|
||||
[isCompactComposerLayout, composerAgentControls],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -662,7 +675,7 @@ export function WorkspaceDraftAgentTab({
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ReanimatedAnimated.View style={inputAreaWrapperStyle}>
|
||||
<ReanimatedAnimated.View style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
|
||||
{importPillPress ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
@@ -692,6 +705,7 @@ export function WorkspaceDraftAgentTab({
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
footer={composerFooter}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
|
||||
@@ -140,6 +140,10 @@ function resolveIsDesktopWebBreakpoint(isMobile: boolean): boolean {
|
||||
return isWeb && !isMobile;
|
||||
}
|
||||
|
||||
function resolveCompactLayout(override: boolean | undefined, formFactor: boolean): boolean {
|
||||
return override ?? formFactor;
|
||||
}
|
||||
|
||||
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
|
||||
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
|
||||
}
|
||||
@@ -223,14 +227,22 @@ interface RenderLeftContentArgs {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
focusInput: () => void;
|
||||
isCompactLayout: boolean;
|
||||
}
|
||||
|
||||
function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
|
||||
const { agentControls, agentId, serverId, focusInput } = args;
|
||||
const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args;
|
||||
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
|
||||
return <DraftAgentControls {...agentControls} />;
|
||||
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
|
||||
}
|
||||
return <AgentControls agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
|
||||
return (
|
||||
<AgentControls
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
onDropdownClose={focusInput}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RenderAttachmentTrayArgs {
|
||||
@@ -674,6 +686,8 @@ interface ComposerProps {
|
||||
footer?: ReactNode;
|
||||
/** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */
|
||||
externalKeyboardShift?: boolean;
|
||||
/** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||
@@ -869,6 +883,7 @@ export function Composer({
|
||||
inputWrapperStyle,
|
||||
footer,
|
||||
externalKeyboardShift,
|
||||
isCompactLayout: isCompactLayoutOverride,
|
||||
}: ComposerProps) {
|
||||
const buttonIconSize = resolveComposerButtonIconSize();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
@@ -899,9 +914,11 @@ export function Composer({
|
||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isMobile);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopWebBreakpoint);
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
|
||||
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout);
|
||||
const userInput = value;
|
||||
const setUserInput = onChangeText;
|
||||
const {
|
||||
@@ -1437,7 +1454,7 @@ export function Composer({
|
||||
isAgentRunning={isAgentRunning}
|
||||
hasSendableContent={hasSendableContent}
|
||||
isProcessing={isProcessing}
|
||||
isCompact={isMobile}
|
||||
isCompact={isCompactLayout}
|
||||
buttonIconSize={buttonIconSize}
|
||||
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
|
||||
isConnected={isConnected}
|
||||
@@ -1455,7 +1472,7 @@ export function Composer({
|
||||
hasSendableContent,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
isMobile,
|
||||
isCompactLayout,
|
||||
isProcessing,
|
||||
isVoiceModeForAgent,
|
||||
isVoiceSwitching,
|
||||
@@ -1475,13 +1492,13 @@ export function Composer({
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
isMobile,
|
||||
isCompactLayout,
|
||||
),
|
||||
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isMobile],
|
||||
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout],
|
||||
);
|
||||
const { beforeVoiceContent, footerInlineContent } = useMemo(
|
||||
() => resolveContextWindowPlacement(contextWindowMeter, isMobile),
|
||||
[contextWindowMeter, isMobile],
|
||||
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
|
||||
[contextWindowMeter, isCompactLayout],
|
||||
);
|
||||
|
||||
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
||||
@@ -1548,8 +1565,8 @@ export function Composer({
|
||||
);
|
||||
|
||||
const leftContent = useMemo(
|
||||
() => renderLeftContent({ agentControls, agentId, serverId, focusInput }),
|
||||
[agentId, focusInput, serverId, agentControls],
|
||||
() => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }),
|
||||
[agentId, focusInput, serverId, agentControls, isCompactLayout],
|
||||
);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
@@ -1768,6 +1785,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
md: -theme.spacing[3],
|
||||
},
|
||||
alignItems: "center",
|
||||
paddingBottom: {
|
||||
xs: 0,
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
},
|
||||
footerContent: {
|
||||
width: "100%",
|
||||
|
||||
@@ -13,6 +13,7 @@ export const HEADER_TOP_PADDING_MOBILE = 8;
|
||||
|
||||
// Max width for chat content (stream view, input area, new agent form)
|
||||
export const MAX_CONTENT_WIDTH = 820;
|
||||
export const COMPACT_FORM_FACTOR_WIDTH = 500;
|
||||
|
||||
// Desktop app constants for macOS traffic light buttons
|
||||
// These buttons (close/minimize/maximize) overlay the top-left corner
|
||||
|
||||
@@ -67,10 +67,10 @@ const CATALOG_DATA = [
|
||||
title: "Cline",
|
||||
description:
|
||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
version: "2.18.0",
|
||||
version: "3",
|
||||
iconId: "cline",
|
||||
installLink: "https://cline.bot/cli",
|
||||
command: ["npx", "-y", "cline@2.18.0", "--acp"],
|
||||
command: ["npx", "-y", "cline@3", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codebuddy-code",
|
||||
|
||||
@@ -20,11 +20,14 @@ export function useContainerWidth(): {
|
||||
/**
|
||||
* Tracks only whether a container is narrower than a threshold.
|
||||
*/
|
||||
export function useContainerWidthBelow(threshold: number): {
|
||||
export function useContainerWidthBelow(
|
||||
threshold: number,
|
||||
options?: { initialIsBelow?: boolean },
|
||||
): {
|
||||
onLayout: (e: LayoutChangeEvent) => void;
|
||||
isBelow: boolean;
|
||||
} {
|
||||
const [isBelow, setIsBelow] = useState(true);
|
||||
const [isBelow, setIsBelow] = useState(options?.initialIsBelow ?? true);
|
||||
return {
|
||||
onLayout: useCallback(
|
||||
(e: LayoutChangeEvent) => {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
useWorkspaceAttachments,
|
||||
useWorkspaceAttachmentScopeKey,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
@@ -1317,7 +1318,11 @@ function ActiveAgentComposer({
|
||||
onMessageSent: () => void;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
COMPACT_FORM_FACTOR_WIDTH,
|
||||
{ initialIsBelow: isCompactFormFactor },
|
||||
);
|
||||
const paneContext = usePaneContext();
|
||||
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
@@ -1355,14 +1360,14 @@ function ActiveAgentComposer({
|
||||
};
|
||||
openFileExplorerForCheckout({
|
||||
checkout,
|
||||
isCompact,
|
||||
isCompact: isCompactFormFactor,
|
||||
});
|
||||
setExplorerTabForCheckout({
|
||||
...checkout,
|
||||
tab: "changes",
|
||||
});
|
||||
},
|
||||
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
);
|
||||
|
||||
const handleClientSlashCommand = useCallback(
|
||||
@@ -1414,14 +1419,19 @@ function ActiveAgentComposer({
|
||||
|
||||
const composerFooter = useMemo(
|
||||
() =>
|
||||
isCompact ? (
|
||||
<AgentModeControl serverId={serverId} agentId={agentId} placement="footer" />
|
||||
isCompactComposerLayout ? (
|
||||
<AgentModeControl
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
placement="footer"
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
) : undefined,
|
||||
[isCompact, serverId, agentId],
|
||||
[isCompactComposerLayout, serverId, agentId],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReanimatedAnimated.View style={inputAreaStyle}>
|
||||
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
|
||||
<SubagentsTrack
|
||||
rows={subagentRows}
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
@@ -1449,6 +1459,7 @@ function ActiveAgentComposer({
|
||||
onMessageSent={onMessageSent}
|
||||
onClientSlashCommand={handleClientSlashCommand}
|
||||
footer={composerFooter}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import invariant from "tiny-invariant";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
|
||||
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
|
||||
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
|
||||
import {
|
||||
@@ -208,7 +209,6 @@ export interface WorkspaceTabSnapshot {
|
||||
}
|
||||
|
||||
const DEFAULT_PANE_ID = "main";
|
||||
const MIN_SPLIT_SIZE = 0.1;
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
|
||||
1
packages/app/src/stores/workspace-layout-constants.ts
Normal file
1
packages/app/src/stores/workspace-layout-constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const MIN_SPLIT_SIZE = 0.1;
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
|
||||
export function derivePendingPermissionKey(
|
||||
agentId: string,
|
||||
@@ -26,11 +26,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
? new Date(snapshot.attentionTimestamp)
|
||||
: null;
|
||||
const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null;
|
||||
const parentAgentLabel = snapshot.labels?.[PARENT_AGENT_ID_LABEL];
|
||||
const parentAgentId =
|
||||
typeof parentAgentLabel === "string" && parentAgentLabel.trim().length > 0
|
||||
? parentAgentLabel.trim()
|
||||
: null;
|
||||
const parentAgentId = getParentAgentIdFromLabels(snapshot.labels);
|
||||
|
||||
return {
|
||||
serverId,
|
||||
|
||||
@@ -6,9 +6,11 @@ import { resolveWorkspaceScriptLink } from "./workspace-script-links";
|
||||
const runningService: WorkspaceScriptPayload = {
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.feature.paseo.localhost",
|
||||
hostname: "web--feature--paseo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web.feature.paseo.localhost:6767",
|
||||
localProxyUrl: "http://web--feature--paseo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://web--feature--paseo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
@@ -27,8 +29,8 @@ describe("resolveWorkspaceScriptLink", () => {
|
||||
expect(
|
||||
resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }),
|
||||
).toEqual({
|
||||
openUrl: "http://web.feature.paseo.localhost:6767",
|
||||
labelUrl: "http://web.feature.paseo.localhost:6767",
|
||||
openUrl: "http://web--feature--paseo.localhost:6767",
|
||||
labelUrl: "http://web--feature--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,8 +38,8 @@ describe("resolveWorkspaceScriptLink", () => {
|
||||
expect(
|
||||
resolveLink({ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }),
|
||||
).toEqual({
|
||||
openUrl: "http://web.feature.paseo.localhost:6767",
|
||||
labelUrl: "http://web.feature.paseo.localhost:6767",
|
||||
openUrl: "http://web--feature--paseo.localhost:6767",
|
||||
labelUrl: "http://web--feature--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +61,114 @@ describe("resolveWorkspaceScriptLink", () => {
|
||||
resolveLink({ type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }),
|
||||
).toEqual({
|
||||
openUrl: null,
|
||||
labelUrl: "http://web.feature.paseo.localhost:6767",
|
||||
labelUrl: "http://web--feature--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the public URL over relay when one is provided", () => {
|
||||
expect(
|
||||
resolveWorkspaceScriptLink({
|
||||
script: {
|
||||
...runningService,
|
||||
publicProxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
proxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
},
|
||||
activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" },
|
||||
}),
|
||||
).toEqual({
|
||||
openUrl: "https://web--feature--paseo.services.example.com",
|
||||
labelUrl: "https://web--feature--paseo.services.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses local URL for direct loopback even when public URL exists", () => {
|
||||
expect(
|
||||
resolveWorkspaceScriptLink({
|
||||
script: {
|
||||
...runningService,
|
||||
publicProxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
proxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
},
|
||||
activeConnection: { type: "directTcp", endpoint: "127.0.0.1:6767", display: "localhost" },
|
||||
}),
|
||||
).toEqual({
|
||||
openUrl: "http://web--feature--paseo.localhost:6767",
|
||||
labelUrl: "http://web--feature--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses local URL for direct socket and pipe even when public URL exists", () => {
|
||||
expect(
|
||||
resolveWorkspaceScriptLink({
|
||||
script: {
|
||||
...runningService,
|
||||
publicProxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
proxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
},
|
||||
activeConnection: { type: "directPipe", endpoint: "paseo", display: "pipe" },
|
||||
}),
|
||||
).toEqual({
|
||||
openUrl: "http://web--feature--paseo.localhost:6767",
|
||||
labelUrl: "http://web--feature--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses public URL for direct remote TCP when split URLs exist", () => {
|
||||
expect(
|
||||
resolveWorkspaceScriptLink({
|
||||
script: {
|
||||
...runningService,
|
||||
publicProxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
proxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
},
|
||||
activeConnection: {
|
||||
type: "directTcp",
|
||||
endpoint: "mac-mini.tail123.ts.net:6767",
|
||||
display: "remote",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
openUrl: "https://web--feature--paseo.services.example.com",
|
||||
labelUrl: "https://web--feature--paseo.services.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps old daemon local-only proxyUrl payloads working", () => {
|
||||
const {
|
||||
localProxyUrl: _localProxyUrl,
|
||||
publicProxyUrl: _publicProxyUrl,
|
||||
...oldPayload
|
||||
} = runningService;
|
||||
|
||||
expect(
|
||||
resolveWorkspaceScriptLink({
|
||||
script: oldPayload,
|
||||
activeConnection: { type: "directTcp", endpoint: "localhost:6767", display: "localhost" },
|
||||
}),
|
||||
).toEqual({
|
||||
openUrl: "http://web--feature--paseo.localhost:6767",
|
||||
labelUrl: "http://web--feature--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps old daemon public proxyUrl payloads working over relay", () => {
|
||||
const {
|
||||
localProxyUrl: _localProxyUrl,
|
||||
publicProxyUrl: _publicProxyUrl,
|
||||
...oldPayload
|
||||
} = {
|
||||
...runningService,
|
||||
proxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveWorkspaceScriptLink({
|
||||
script: oldPayload,
|
||||
activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" },
|
||||
}),
|
||||
).toEqual({
|
||||
openUrl: "https://web--feature--paseo.services.example.com",
|
||||
labelUrl: "https://web--feature--paseo.services.example.com",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,19 @@ function isLoopbackHost(host: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isLocalOnlyUrl(url: string | null | undefined): boolean {
|
||||
if (!url) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
return isLoopbackHost(hostname) || hostname.endsWith(".localhost");
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDirectServiceUrl(endpoint: string, port: number): string | null {
|
||||
try {
|
||||
const { host, isIpv6 } = parseHostPort(endpoint);
|
||||
@@ -37,21 +50,33 @@ export function resolveWorkspaceScriptLink(input: {
|
||||
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||
}
|
||||
|
||||
const localProxyUrl = script.localProxyUrl ?? script.proxyUrl;
|
||||
const publicProxyUrl =
|
||||
script.publicProxyUrl ?? (!isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null);
|
||||
const preferredProxyUrl = publicProxyUrl ?? localProxyUrl ?? script.proxyUrl;
|
||||
|
||||
if (activeConnection.type === "relay") {
|
||||
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||
return {
|
||||
openUrl: publicProxyUrl,
|
||||
labelUrl: publicProxyUrl ?? localProxyUrl ?? script.proxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") {
|
||||
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
|
||||
return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
|
||||
}
|
||||
|
||||
try {
|
||||
const { host } = parseHostPort(activeConnection.endpoint);
|
||||
if (isLoopbackHost(host)) {
|
||||
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
|
||||
return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
|
||||
}
|
||||
} catch {
|
||||
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||
return { openUrl: null, labelUrl: preferredProxyUrl };
|
||||
}
|
||||
|
||||
if (publicProxyUrl) {
|
||||
return { openUrl: publicProxyUrl, labelUrl: publicProxyUrl };
|
||||
}
|
||||
|
||||
if (script.port === null) {
|
||||
@@ -61,6 +86,6 @@ export function resolveWorkspaceScriptLink(input: {
|
||||
const directUrl = buildDirectServiceUrl(activeConnection.endpoint, script.port);
|
||||
return {
|
||||
openUrl: directUrl,
|
||||
labelUrl: directUrl ?? script.proxyUrl,
|
||||
labelUrl: directUrl ?? preferredProxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -25,9 +25,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.88",
|
||||
"@getpaseo/protocol": "0.1.88",
|
||||
"@getpaseo/server": "0.1.88",
|
||||
"@getpaseo/client": "0.1.89",
|
||||
"@getpaseo/protocol": "0.1.89",
|
||||
"@getpaseo/server": "0.1.89",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -33,8 +33,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.88",
|
||||
"@getpaseo/relay": "0.1.88",
|
||||
"@getpaseo/protocol": "0.1.89",
|
||||
"@getpaseo/relay": "0.1.89",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -10,7 +10,17 @@ import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { app, BrowserWindow, Menu, ipcMain, nativeImage, net, protocol, session } from "electron";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
Menu,
|
||||
ipcMain,
|
||||
nativeImage,
|
||||
net,
|
||||
protocol,
|
||||
screen,
|
||||
session,
|
||||
} from "electron";
|
||||
import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js";
|
||||
import { parsePassthroughCliArgsFromArgv, runPassthroughCli } from "./daemon/cli/passthrough.js";
|
||||
import { closeAllTransportSessions } from "./daemon/local-transport.js";
|
||||
@@ -19,7 +29,9 @@ import {
|
||||
getMainWindowChromeOptions,
|
||||
getWindowBackgroundColor,
|
||||
resolveSystemWindowTheme,
|
||||
resolveWindowBounds,
|
||||
setupWindowResizeEvents,
|
||||
setupWindowStatePersistence,
|
||||
setupDefaultContextMenu,
|
||||
setupDragDropPrevention,
|
||||
buildStandardContextMenuItems,
|
||||
@@ -41,6 +53,7 @@ import {
|
||||
} from "./features/browser-webviews.js";
|
||||
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
||||
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
|
||||
import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js";
|
||||
import {
|
||||
isDesktopManagedDaemonRunningSync,
|
||||
stopDesktopDaemonViaCli,
|
||||
@@ -372,15 +385,28 @@ function applyAppIcon(): void {
|
||||
app.dock?.setIcon(icon);
|
||||
}
|
||||
|
||||
// Work areas with the primary display first, so window-state clamping treats
|
||||
// it as the fallback. getAllDisplays() order is not guaranteed to lead with it.
|
||||
function getWorkAreasPrimaryFirst(): Electron.Rectangle[] {
|
||||
const primary = screen.getPrimaryDisplay();
|
||||
const others = screen.getAllDisplays().filter((display) => display.id !== primary.id);
|
||||
return [primary, ...others].map((display) => display.workArea);
|
||||
}
|
||||
|
||||
async function createMainWindow(): Promise<void> {
|
||||
const iconPath = getWindowIconPath();
|
||||
const systemTheme = resolveSystemWindowTheme();
|
||||
|
||||
const windowStateStore = createWindowStateStore({ userDataPath: app.getPath("userData") });
|
||||
const savedWindowState = await windowStateStore.load();
|
||||
const restoredWindowState = savedWindowState
|
||||
? clampWindowStateToWorkAreas(savedWindowState, getWorkAreasPrimaryFirst())
|
||||
: null;
|
||||
|
||||
const title = devWorktreeName ? `${APP_NAME} (${devWorktreeName})` : APP_NAME;
|
||||
const mainWindow = new BrowserWindow({
|
||||
title,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
...resolveWindowBounds(restoredWindowState),
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(systemTheme),
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
@@ -400,8 +426,13 @@ async function createMainWindow(): Promise<void> {
|
||||
app.dock?.setBadge(devWorktreeName);
|
||||
}
|
||||
|
||||
if (restoredWindowState?.isMaximized) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
|
||||
setupDarwinCompositorWatchdog(mainWindow);
|
||||
setupWindowResizeEvents(mainWindow);
|
||||
setupWindowStatePersistence(mainWindow, windowStateStore);
|
||||
setupDefaultContextMenu(mainWindow);
|
||||
setupDragDropPrevention(mainWindow);
|
||||
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {
|
||||
|
||||
205
packages/desktop/src/settings/window-state.test.ts
Normal file
205
packages/desktop/src/settings/window-state.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MIN_WINDOW_HEIGHT,
|
||||
MIN_WINDOW_WIDTH,
|
||||
type WindowState,
|
||||
type WorkArea,
|
||||
clampWindowStateToWorkAreas,
|
||||
createWindowStateStore,
|
||||
} from "./window-state";
|
||||
|
||||
async function createTempUserDataDir(): Promise<string> {
|
||||
return await mkdtemp(path.join(os.tmpdir(), "paseo-window-state-"));
|
||||
}
|
||||
|
||||
function stateFilePath(userDataPath: string): string {
|
||||
return path.join(userDataPath, "window-state.json");
|
||||
}
|
||||
|
||||
const PRIMARY: WorkArea = { x: 0, y: 0, width: 1920, height: 1080 };
|
||||
|
||||
describe("window-state store", () => {
|
||||
const directories = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
[...directories].map(async (directory) => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}),
|
||||
);
|
||||
directories.clear();
|
||||
});
|
||||
|
||||
it("returns null when no state has been persisted yet", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
const store = createWindowStateStore({ userDataPath });
|
||||
|
||||
expect(await store.load()).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips a saved state through disk", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
const store = createWindowStateStore({ userDataPath });
|
||||
|
||||
const state: WindowState = { x: 100, y: 200, width: 1000, height: 700, isMaximized: false };
|
||||
await store.save(state);
|
||||
|
||||
expect(await store.load()).toEqual(state);
|
||||
});
|
||||
|
||||
it("persists the maximized flag", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
const store = createWindowStateStore({ userDataPath });
|
||||
|
||||
await store.save({ x: 0, y: 0, width: 1280, height: 800, isMaximized: true });
|
||||
|
||||
expect((await store.load())?.isMaximized).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves no temp files behind after an async save", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
const store = createWindowStateStore({ userDataPath });
|
||||
|
||||
await store.save({ x: 10, y: 10, width: 800, height: 600, isMaximized: false });
|
||||
|
||||
expect(await readdir(userDataPath)).toEqual(["window-state.json"]);
|
||||
});
|
||||
|
||||
it("writes atomically and synchronously via saveSync", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
const store = createWindowStateStore({ userDataPath });
|
||||
|
||||
store.saveSync({ x: 5, y: 6, width: 900, height: 650, isMaximized: false });
|
||||
|
||||
const persisted = JSON.parse(await readFile(stateFilePath(userDataPath), "utf8")) as {
|
||||
version: number;
|
||||
state: WindowState;
|
||||
};
|
||||
expect(persisted.state).toEqual({ x: 5, y: 6, width: 900, height: 650, isMaximized: false });
|
||||
expect(await readdir(userDataPath)).toEqual(["window-state.json"]);
|
||||
});
|
||||
|
||||
it("returns null for corrupted JSON instead of throwing", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
await writeFile(stateFilePath(userDataPath), "{ not valid json");
|
||||
const store = createWindowStateStore({ userDataPath });
|
||||
|
||||
expect(await store.load()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when persisted state lacks usable dimensions", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
await writeFile(
|
||||
stateFilePath(userDataPath),
|
||||
JSON.stringify({ version: 1, state: { isMaximized: true } }),
|
||||
);
|
||||
const store = createWindowStateStore({ userDataPath });
|
||||
|
||||
expect(await store.load()).toBeNull();
|
||||
});
|
||||
|
||||
it("clamps persisted dimensions up to the minimum size", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
await writeFile(
|
||||
stateFilePath(userDataPath),
|
||||
JSON.stringify({ version: 1, state: { width: 100, height: 50, isMaximized: false } }),
|
||||
);
|
||||
const store = createWindowStateStore({ userDataPath });
|
||||
|
||||
const loaded = await store.load();
|
||||
expect(loaded?.width).toBe(MIN_WINDOW_WIDTH);
|
||||
expect(loaded?.height).toBe(MIN_WINDOW_HEIGHT);
|
||||
});
|
||||
|
||||
it("drops non-finite coordinates while keeping valid dimensions", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
await writeFile(
|
||||
stateFilePath(userDataPath),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
state: { x: "nope", y: null, width: 1000, height: 700, isMaximized: false },
|
||||
}),
|
||||
);
|
||||
const store = createWindowStateStore({ userDataPath });
|
||||
|
||||
const loaded = await store.load();
|
||||
expect(loaded).toEqual({ width: 1000, height: 700, isMaximized: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampWindowStateToWorkAreas", () => {
|
||||
it("keeps a state fully inside the primary display unchanged", () => {
|
||||
const state: WindowState = { x: 100, y: 100, width: 1000, height: 700, isMaximized: false };
|
||||
expect(clampWindowStateToWorkAreas(state, [PRIMARY])).toEqual(state);
|
||||
});
|
||||
|
||||
it("keeps valid negative coordinates from a left-side secondary monitor", () => {
|
||||
const left: WorkArea = { x: -1920, y: 0, width: 1920, height: 1080 };
|
||||
const state: WindowState = { x: -1800, y: 80, width: 1000, height: 700, isMaximized: false };
|
||||
|
||||
expect(clampWindowStateToWorkAreas(state, [left, PRIMARY])).toEqual(state);
|
||||
});
|
||||
|
||||
it("drops x/y when the window does not meaningfully intersect any display", () => {
|
||||
const state: WindowState = { x: 5000, y: 5000, width: 1000, height: 700, isMaximized: false };
|
||||
|
||||
const clamped = clampWindowStateToWorkAreas(state, [PRIMARY]);
|
||||
|
||||
expect(clamped.x).toBeUndefined();
|
||||
expect(clamped.y).toBeUndefined();
|
||||
expect(clamped.width).toBe(1000);
|
||||
expect(clamped.height).toBe(700);
|
||||
});
|
||||
|
||||
it("shrinks an oversized window to the target work area", () => {
|
||||
const state: WindowState = { x: 0, y: 0, width: 5000, height: 4000, isMaximized: false };
|
||||
|
||||
const clamped = clampWindowStateToWorkAreas(state, [PRIMARY]);
|
||||
|
||||
expect(clamped.width).toBe(PRIMARY.width);
|
||||
expect(clamped.height).toBe(PRIMARY.height);
|
||||
});
|
||||
|
||||
it("repositions an oversized edge window so it stays fully on-screen after shrinking", () => {
|
||||
// Saved near the right edge and larger than the display: a naive clamp would
|
||||
// keep x=1820 and shrink to 1920 wide, leaving the window mostly off-screen.
|
||||
const state: WindowState = { x: 1820, y: 0, width: 3000, height: 2000, isMaximized: false };
|
||||
|
||||
const clamped = clampWindowStateToWorkAreas(state, [PRIMARY]);
|
||||
|
||||
expect(clamped.width).toBe(PRIMARY.width);
|
||||
expect(clamped.height).toBe(PRIMARY.height);
|
||||
expect(clamped.x).toBe(0);
|
||||
expect(clamped.y).toBe(0);
|
||||
});
|
||||
|
||||
it("drops position when there are no known displays", () => {
|
||||
const state: WindowState = { x: 100, y: 100, width: 1000, height: 700, isMaximized: false };
|
||||
|
||||
const clamped = clampWindowStateToWorkAreas(state, []);
|
||||
|
||||
expect(clamped.x).toBeUndefined();
|
||||
expect(clamped.y).toBeUndefined();
|
||||
expect(clamped.width).toBe(1000);
|
||||
expect(clamped.height).toBe(700);
|
||||
});
|
||||
|
||||
it("preserves the maximized flag through clamping", () => {
|
||||
const state: WindowState = { x: 100, y: 100, width: 1000, height: 700, isMaximized: true };
|
||||
|
||||
expect(clampWindowStateToWorkAreas(state, [PRIMARY]).isMaximized).toBe(true);
|
||||
});
|
||||
});
|
||||
230
packages/desktop/src/settings/window-state.ts
Normal file
230
packages/desktop/src/settings/window-state.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const MIN_WINDOW_WIDTH = 400;
|
||||
export const MIN_WINDOW_HEIGHT = 300;
|
||||
|
||||
// Smallest slice of the window that must remain on a display for the saved
|
||||
// position to count as "still reachable" after the monitor layout changes.
|
||||
const MIN_VISIBLE_WIDTH = 100;
|
||||
const MIN_VISIBLE_HEIGHT = 80;
|
||||
|
||||
export interface WindowState {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isMaximized: boolean;
|
||||
}
|
||||
|
||||
/** A display's usable area (excludes the menu bar / taskbar), in DIP coordinates. */
|
||||
export interface WorkArea {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface PersistedWindowStateDocument {
|
||||
version: 1;
|
||||
state: WindowState;
|
||||
}
|
||||
|
||||
export interface WindowStateStore {
|
||||
/** Returns the persisted state, or null when nothing usable is stored. */
|
||||
load(): Promise<WindowState | null>;
|
||||
/** Persists the state atomically off the main thread (serialized writes). */
|
||||
save(state: WindowState): Promise<void>;
|
||||
/** Persists the state synchronously — used as the final writer on close/quit. */
|
||||
saveSync(state: WindowState): void;
|
||||
}
|
||||
|
||||
const WINDOW_STATE_FILENAME = "window-state.json";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error;
|
||||
}
|
||||
|
||||
function coerceFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : null;
|
||||
}
|
||||
|
||||
function coerceDimension(value: unknown, minimum: number): number | null {
|
||||
const rounded = coerceFiniteNumber(value);
|
||||
if (rounded === null) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(minimum, rounded);
|
||||
}
|
||||
|
||||
export function coerceWindowState(input: unknown): WindowState | null {
|
||||
if (!isRecord(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = coerceDimension(input.width, MIN_WINDOW_WIDTH);
|
||||
const height = coerceDimension(input.height, MIN_WINDOW_HEIGHT);
|
||||
if (width === null || height === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state: WindowState = { width, height, isMaximized: input.isMaximized === true };
|
||||
|
||||
const x = coerceFiniteNumber(input.x);
|
||||
const y = coerceFiniteNumber(input.y);
|
||||
// Only trust a position when both coordinates are present; a half-known
|
||||
// position is worse than letting the OS place the window.
|
||||
if (x !== null && y !== null) {
|
||||
state.x = x;
|
||||
state.y = y;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function serializeDocument(state: WindowState): string {
|
||||
const document: PersistedWindowStateDocument = { version: 1, state };
|
||||
return `${JSON.stringify(document, null, 2)}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust a saved window state to the current display layout so the window never
|
||||
* opens off-screen. Drops the saved position when it would not be reachable on
|
||||
* any connected display, and shrinks oversized windows to the target work area.
|
||||
* Pure: the caller supplies the display work areas (from Electron's `screen`).
|
||||
*/
|
||||
export function clampWindowStateToWorkAreas(
|
||||
state: WindowState,
|
||||
workAreas: WorkArea[],
|
||||
): WindowState {
|
||||
const primary = workAreas[0];
|
||||
if (!primary) {
|
||||
// No display info — keep the size, let the OS place the window.
|
||||
return { width: state.width, height: state.height, isMaximized: state.isMaximized };
|
||||
}
|
||||
|
||||
let target: WorkArea = primary;
|
||||
const { x, y } = state;
|
||||
let positioned = false;
|
||||
|
||||
if (x !== undefined && y !== undefined) {
|
||||
const requiredWidth = Math.min(MIN_VISIBLE_WIDTH, state.width);
|
||||
const requiredHeight = Math.min(MIN_VISIBLE_HEIGHT, state.height);
|
||||
let bestOverlap = 0;
|
||||
|
||||
for (const workArea of workAreas) {
|
||||
const overlapWidth = Math.max(
|
||||
0,
|
||||
Math.min(x + state.width, workArea.x + workArea.width) - Math.max(x, workArea.x),
|
||||
);
|
||||
const overlapHeight = Math.max(
|
||||
0,
|
||||
Math.min(y + state.height, workArea.y + workArea.height) - Math.max(y, workArea.y),
|
||||
);
|
||||
const overlap = overlapWidth * overlapHeight;
|
||||
const isVisibleEnough = overlapWidth >= requiredWidth && overlapHeight >= requiredHeight;
|
||||
if (isVisibleEnough && overlap > bestOverlap) {
|
||||
bestOverlap = overlap;
|
||||
target = workArea;
|
||||
positioned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const width = Math.min(Math.max(state.width, MIN_WINDOW_WIDTH), target.width);
|
||||
const height = Math.min(Math.max(state.height, MIN_WINDOW_HEIGHT), target.height);
|
||||
|
||||
if (positioned && x !== undefined && y !== undefined) {
|
||||
// Keep the window inside the chosen display after the size clamp so an
|
||||
// oversized saved state cannot end up mostly off-screen.
|
||||
const clampedX = Math.min(Math.max(x, target.x), target.x + target.width - width);
|
||||
const clampedY = Math.min(Math.max(y, target.y), target.y + target.height - height);
|
||||
return { x: clampedX, y: clampedY, width, height, isMaximized: state.isMaximized };
|
||||
}
|
||||
return { width, height, isMaximized: state.isMaximized };
|
||||
}
|
||||
|
||||
export function createWindowStateStore({
|
||||
userDataPath,
|
||||
}: {
|
||||
userDataPath: string;
|
||||
}): WindowStateStore {
|
||||
const filePath = path.join(userDataPath, WINDOW_STATE_FILENAME);
|
||||
let persistQueue: Promise<void> = Promise.resolve();
|
||||
// Once the synchronous final write lands (on close/quit), pending async
|
||||
// writes must not clobber it with an older snapshot.
|
||||
let finalized = false;
|
||||
|
||||
function tempFilePath(): string {
|
||||
return `${filePath}.tmp.${process.pid}.${randomUUID()}`;
|
||||
}
|
||||
|
||||
return {
|
||||
async load(): Promise<WindowState | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
// No file yet (first launch) is expected; surface anything else.
|
||||
if (isNodeError(error) && error.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
// A corrupted file shouldn't block launch; non-critical state falls back.
|
||||
if (error instanceof SyntaxError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return coerceWindowState(parsed.state);
|
||||
},
|
||||
|
||||
async save(state: WindowState): Promise<void> {
|
||||
const contents = serializeDocument(state);
|
||||
async function write(): Promise<void> {
|
||||
if (finalized) {
|
||||
return;
|
||||
}
|
||||
await mkdir(userDataPath, { recursive: true });
|
||||
const tempPath = tempFilePath();
|
||||
await writeFile(tempPath, contents, "utf8");
|
||||
if (finalized) {
|
||||
// A synchronous final write (saveSync) landed while this one was in
|
||||
// flight. Keep it as the last writer instead of overwriting it, and
|
||||
// discard our now-stale temp file so it can't accumulate in userData.
|
||||
await unlink(tempPath).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await rename(tempPath, filePath);
|
||||
}
|
||||
const queued = persistQueue.then(write, write);
|
||||
persistQueue = queued.catch(() => undefined);
|
||||
await queued;
|
||||
},
|
||||
|
||||
saveSync(state: WindowState): void {
|
||||
finalized = true;
|
||||
const contents = serializeDocument(state);
|
||||
mkdirSync(userDataPath, { recursive: true });
|
||||
const tempPath = tempFilePath();
|
||||
writeFileSync(tempPath, contents, "utf8");
|
||||
renameSync(tempPath, filePath);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,12 +3,15 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyWindowControlsOverlayUpdate,
|
||||
createWindowControlsOverlayState,
|
||||
DEFAULT_WINDOW_HEIGHT,
|
||||
DEFAULT_WINDOW_WIDTH,
|
||||
getMainWindowChromeOptions,
|
||||
getTitleBarOverlayOptions,
|
||||
readBadgeCount,
|
||||
readWindowControlsOverlayUpdate,
|
||||
readWindowTheme,
|
||||
resolveRuntimeTitleBarOverlayOptions,
|
||||
resolveWindowBounds,
|
||||
} from "./window-manager";
|
||||
|
||||
describe("window-manager", () => {
|
||||
@@ -186,4 +189,26 @@ describe("window-manager", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWindowBounds", () => {
|
||||
it("falls back to the default size when no state is saved", () => {
|
||||
expect(resolveWindowBounds(null)).toEqual({
|
||||
width: DEFAULT_WINDOW_WIDTH,
|
||||
height: DEFAULT_WINDOW_HEIGHT,
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the full size and position", () => {
|
||||
expect(
|
||||
resolveWindowBounds({ x: 120, y: 80, width: 1024, height: 720, isMaximized: false }),
|
||||
).toEqual({ width: 1024, height: 720, x: 120, y: 80 });
|
||||
});
|
||||
|
||||
it("omits the position when only the size was persisted", () => {
|
||||
expect(resolveWindowBounds({ width: 1024, height: 720, isMaximized: true })).toEqual({
|
||||
width: 1024,
|
||||
height: 720,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
shell,
|
||||
} from "electron";
|
||||
|
||||
import type { WindowState, WindowStateStore } from "../settings/window-state.js";
|
||||
|
||||
const WINDOW_STATE_SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
export function readBadgeCount(input: unknown): number {
|
||||
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
|
||||
return 0;
|
||||
@@ -87,6 +91,26 @@ export function getMainWindowChromeOptions(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_WINDOW_WIDTH = 1200;
|
||||
export const DEFAULT_WINDOW_HEIGHT = 800;
|
||||
|
||||
/**
|
||||
* Window size/position options for the BrowserWindow constructor, derived from
|
||||
* a restored state when available. Falls back to the default size, and only
|
||||
* sets x/y when a full position was persisted (a partial state lets the OS
|
||||
* place the window).
|
||||
*/
|
||||
export function resolveWindowBounds(
|
||||
state: WindowState | null,
|
||||
): Pick<Electron.BrowserWindowConstructorOptions, "width" | "height" | "x" | "y"> {
|
||||
const width = state?.width ?? DEFAULT_WINDOW_WIDTH;
|
||||
const height = state?.height ?? DEFAULT_WINDOW_HEIGHT;
|
||||
if (state?.x !== undefined && state?.y !== undefined) {
|
||||
return { width, height, x: state.x, y: state.y };
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function readFiniteOverlayHeight(input: unknown): number | null {
|
||||
if (typeof input !== "number" || !Number.isFinite(input)) {
|
||||
return null;
|
||||
@@ -229,6 +253,96 @@ export function setupWindowResizeEvents(win: BrowserWindow): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the window's size/position/maximized state so it can be restored on
|
||||
* the next launch. Debounces disk writes on resize/move, writes immediately on
|
||||
* maximize/unmaximize, and flushes synchronously on close so the final state
|
||||
* survives quit/reboot. The latest geometry is captured into memory on every
|
||||
* event so a queued async write can never overwrite the close-time snapshot.
|
||||
*/
|
||||
export function setupWindowStatePersistence(win: BrowserWindow, store: WindowStateStore): void {
|
||||
let latestState: WindowState | null = null;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let flushed = false;
|
||||
|
||||
function clearTimer(): void {
|
||||
if (saveTimer !== null) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function captureState(): void {
|
||||
// Skip transient geometry: maximized/fullscreen bounds aren't the size we
|
||||
// want to restore to, and a minimized window reports misleading bounds.
|
||||
if (win.isMinimized() || win.isFullScreen()) {
|
||||
return;
|
||||
}
|
||||
const bounds = win.getNormalBounds();
|
||||
latestState = {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
isMaximized: win.isMaximized(),
|
||||
};
|
||||
}
|
||||
|
||||
function persist(): void {
|
||||
if (latestState) {
|
||||
void store.save(latestState).catch((error) => {
|
||||
console.warn("[window-manager] Failed to persist window state", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSave(): void {
|
||||
captureState();
|
||||
clearTimer();
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
persist();
|
||||
}, WINDOW_STATE_SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function saveNow(): void {
|
||||
captureState();
|
||||
clearTimer();
|
||||
persist();
|
||||
}
|
||||
|
||||
// Final synchronous flush. Runs on window close AND on app quit: the app's
|
||||
// before-quit handler calls app.exit(0), which bypasses the window close
|
||||
// event (see daemon/quit-lifecycle.ts), so close alone would miss Cmd+Q.
|
||||
function flushFinal(): void {
|
||||
if (flushed) {
|
||||
return;
|
||||
}
|
||||
flushed = true;
|
||||
clearTimer();
|
||||
captureState();
|
||||
if (latestState) {
|
||||
try {
|
||||
store.saveSync(latestState);
|
||||
} catch (error) {
|
||||
console.warn("[window-manager] Failed to persist window state on exit", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
win.on("resize", scheduleSave);
|
||||
win.on("move", scheduleSave);
|
||||
win.on("maximize", saveNow);
|
||||
win.on("unmaximize", saveNow);
|
||||
win.on("close", flushFinal);
|
||||
app.on("before-quit", flushFinal);
|
||||
|
||||
win.on("closed", () => {
|
||||
clearTimer();
|
||||
app.removeListener("before-quit", flushFinal);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildStandardContextMenuItems(
|
||||
contents: WebContents,
|
||||
params: Electron.ContextMenuParams,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
21
packages/protocol/src/agent-labels.test.ts
Normal file
21
packages/protocol/src/agent-labels.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
getParentAgentIdFromLabels,
|
||||
isDelegatedAgent,
|
||||
PARENT_AGENT_ID_LABEL,
|
||||
} from "./agent-labels.js";
|
||||
|
||||
describe("agent label policy", () => {
|
||||
test("treats a non-empty parent agent label as delegation", () => {
|
||||
const labels = { [PARENT_AGENT_ID_LABEL]: " parent-agent \n" };
|
||||
|
||||
expect(getParentAgentIdFromLabels(labels)).toBe("parent-agent");
|
||||
expect(isDelegatedAgent({ labels })).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores missing, empty, and non-string parent agent labels", () => {
|
||||
expect(isDelegatedAgent({ labels: {} })).toBe(false);
|
||||
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: " " } })).toBe(false);
|
||||
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: 42 } })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1 +1,16 @@
|
||||
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
|
||||
|
||||
export interface AgentLabelSource {
|
||||
labels?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export function getParentAgentIdFromLabels(labels: Record<string, unknown> | null | undefined) {
|
||||
const parentAgentId = labels?.[PARENT_AGENT_ID_LABEL];
|
||||
return typeof parentAgentId === "string" && parentAgentId.trim().length > 0
|
||||
? parentAgentId.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isDelegatedAgent(agent: AgentLabelSource): boolean {
|
||||
return getParentAgentIdFromLabels(agent.labels) !== null;
|
||||
}
|
||||
|
||||
@@ -2325,6 +2325,8 @@ export const WorkspaceScriptPayloadSchema = z.object({
|
||||
type: z.enum(["script", "service"]).optional().default("service"),
|
||||
hostname: z.string(),
|
||||
port: z.number().int().positive().nullable(),
|
||||
localProxyUrl: z.string().nullable().optional(),
|
||||
publicProxyUrl: z.string().nullable().optional(),
|
||||
proxyUrl: z.string().nullable().optional().default(null),
|
||||
lifecycle: WorkspaceScriptLifecycleSchema,
|
||||
health: WorkspaceScriptHealthSchema.nullable(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
WorkspaceDescriptorPayloadSchema,
|
||||
WorkspaceScriptPayloadSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("workspace message schemas", () => {
|
||||
@@ -485,6 +486,53 @@ describe("workspace message schemas", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("parses workspace service payloads from old daemons without split proxy URLs", () => {
|
||||
const parsed = WorkspaceScriptPayloadSchema.parse({
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web--repo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web--repo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
});
|
||||
|
||||
expect(parsed.localProxyUrl).toBeUndefined();
|
||||
expect(parsed.publicProxyUrl).toBeUndefined();
|
||||
expect(parsed.proxyUrl).toBe("http://web--repo.localhost:6767");
|
||||
});
|
||||
|
||||
test("parses workspace service payloads with split local and public proxy URLs", () => {
|
||||
const parsed = WorkspaceScriptPayloadSchema.parse({
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web--repo.localhost",
|
||||
port: 3000,
|
||||
localProxyUrl: "http://web--repo.localhost:6767",
|
||||
publicProxyUrl: "https://web--repo.services.example.com",
|
||||
proxyUrl: "https://web--repo.services.example.com",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
});
|
||||
|
||||
expect(parsed.localProxyUrl).toBe("http://web--repo.localhost:6767");
|
||||
expect(parsed.publicProxyUrl).toBe("https://web--repo.services.example.com");
|
||||
expect(parsed.proxyUrl).toBe("https://web--repo.services.example.com");
|
||||
});
|
||||
|
||||
test("defaults omitted workspace script proxyUrl to null", () => {
|
||||
const parsed = WorkspaceScriptPayloadSchema.parse({
|
||||
scriptName: "typecheck",
|
||||
type: "script",
|
||||
hostname: "typecheck",
|
||||
port: null,
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
});
|
||||
|
||||
expect(parsed.proxyUrl).toBeNull();
|
||||
});
|
||||
|
||||
test("parses workspace_setup_progress payload", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_setup_progress",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.89",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -57,10 +57,10 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/client": "0.1.88",
|
||||
"@getpaseo/highlight": "0.1.88",
|
||||
"@getpaseo/protocol": "0.1.88",
|
||||
"@getpaseo/relay": "0.1.88",
|
||||
"@getpaseo/client": "0.1.89",
|
||||
"@getpaseo/highlight": "0.1.89",
|
||||
"@getpaseo/protocol": "0.1.89",
|
||||
"@getpaseo/relay": "0.1.89",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -3892,6 +3892,39 @@ test("onAgentAttention is not called for internal agents", async () => {
|
||||
expect(attentionCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("onAgentAttention is not called for delegated child agents", async () => {
|
||||
const childAgentId = "00000000-0000-4000-8000-000000000112";
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const attentionCalls: string[] = [];
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => childAgentId,
|
||||
onAgentAttention: ({ agentId }) => {
|
||||
attentionCalls.push(agentId);
|
||||
},
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Delegated Child Agent",
|
||||
},
|
||||
undefined,
|
||||
{ labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" } },
|
||||
);
|
||||
|
||||
await manager.runAgent(agent.id, "hello");
|
||||
|
||||
expect(attentionCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("clearAgentAttention on errored agent stays cleared until a new error transition", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-error-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
AGENT_LIFECYCLE_STATUSES,
|
||||
type AgentLifecycleStatus,
|
||||
} from "@getpaseo/protocol/agent-lifecycle";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { isDelegatedAgent, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
@@ -3368,6 +3368,10 @@ export class AgentManager {
|
||||
agent: ManagedAgent,
|
||||
reason: "finished" | "error" | "permission",
|
||||
): void {
|
||||
if (isDelegatedAgent(agent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onAgentAttention?.({
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { AgentStorage } from "./agent-storage.js";
|
||||
|
||||
interface CreateAgentLifecycleDispatchDependencies {
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
github: GitHubService;
|
||||
@@ -106,6 +107,7 @@ export class CreateAgentLifecycleDispatch {
|
||||
firstAgentContext,
|
||||
runSetup: false,
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
worktreesRoot: this.dependencies.worktreesRoot,
|
||||
} as const;
|
||||
|
||||
switch (target.mode) {
|
||||
@@ -191,6 +193,7 @@ export class CreateAgentLifecycleDispatch {
|
||||
}): Promise<void> {
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(options.worktreePath, {
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
worktreesRoot: this.dependencies.worktreesRoot,
|
||||
});
|
||||
if (!ownership.allowed) {
|
||||
throw new Error("Auto-created worktree is not a Paseo-owned worktree");
|
||||
@@ -199,6 +202,7 @@ export class CreateAgentLifecycleDispatch {
|
||||
await archivePaseoWorktree(
|
||||
{
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
worktreesRoot: this.dependencies.worktreesRoot,
|
||||
github: this.dependencies.github,
|
||||
workspaceGitService: this.dependencies.workspaceGitService,
|
||||
agentManager: this.dependencies.agentManager,
|
||||
@@ -215,6 +219,7 @@ export class CreateAgentLifecycleDispatch {
|
||||
targetPath: options.worktreePath,
|
||||
repoRoot: options.repoRoot ?? ownership.repoRoot ?? null,
|
||||
worktreesRoot: ownership.worktreeRoot,
|
||||
worktreesBaseRoot: this.dependencies.worktreesRoot,
|
||||
requestId: randomUUID(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ interface CreateAgentCommandDependencies {
|
||||
agentStorage: AgentStorage;
|
||||
logger: Logger;
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
workspaceGitService?: Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
|
||||
@@ -93,6 +94,7 @@ export interface CreateAgentFromMcpInput {
|
||||
mode?: string;
|
||||
background: boolean;
|
||||
notifyOnFinish: boolean;
|
||||
detached?: boolean;
|
||||
callerAgentId?: string;
|
||||
callerContext?: {
|
||||
lockedCwd?: string;
|
||||
@@ -256,11 +258,12 @@ async function resolveMcpCreateAgent(
|
||||
parent: parentAgent,
|
||||
});
|
||||
|
||||
const labels = mergeLabels(
|
||||
input.callerAgentId,
|
||||
input.callerContext?.childAgentDefaultLabels,
|
||||
input.labels,
|
||||
);
|
||||
const labels = mergeLabels({
|
||||
callerAgentId: input.callerAgentId,
|
||||
detached: input.detached ?? false,
|
||||
childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels,
|
||||
labels: input.labels,
|
||||
});
|
||||
|
||||
const trimmedPrompt = input.initialPrompt.trim();
|
||||
return {
|
||||
@@ -417,6 +420,7 @@ async function resolveMcpCwd(params: {
|
||||
...(params.initialPrompt ? { firstAgentContext: { prompt: params.initialPrompt } } : {}),
|
||||
runSetup: false,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: dependencies.worktreesRoot,
|
||||
},
|
||||
createPaseoWorktree: dependencies.createPaseoWorktree,
|
||||
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||
@@ -469,15 +473,21 @@ async function createMcpWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
function mergeLabels(
|
||||
callerAgentId: string | undefined,
|
||||
childAgentDefaultLabels: Record<string, string> | undefined,
|
||||
labels: Record<string, string> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
function mergeLabels(params: {
|
||||
callerAgentId: string | undefined;
|
||||
detached: boolean;
|
||||
childAgentDefaultLabels: Record<string, string> | undefined;
|
||||
labels: Record<string, string> | undefined;
|
||||
}): Record<string, string> | undefined {
|
||||
const mergedLabels = {
|
||||
...(callerAgentId ? { [PARENT_AGENT_ID_LABEL]: callerAgentId } : {}),
|
||||
...childAgentDefaultLabels,
|
||||
...labels,
|
||||
...(!params.detached && params.callerAgentId
|
||||
? { [PARENT_AGENT_ID_LABEL]: params.callerAgentId }
|
||||
: {}),
|
||||
...params.childAgentDefaultLabels,
|
||||
...params.labels,
|
||||
};
|
||||
if (params.detached) {
|
||||
delete mergedLabels[PARENT_AGENT_ID_LABEL];
|
||||
}
|
||||
return Object.keys(mergedLabels).length > 0 ? mergedLabels : undefined;
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ async function createChildAgent(args?: Partial<StructuredContent>): Promise<stri
|
||||
title: "Parity child",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "say done and stop",
|
||||
background: true,
|
||||
notifyOnFinish: false,
|
||||
...args,
|
||||
});
|
||||
return str(payload.agentId);
|
||||
@@ -286,6 +286,17 @@ describe("Suite A: Core Fixes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("create_agent with detached true omits the parent agent label", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
agentId = await createChildAgent({ detached: true });
|
||||
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
expect(snapshot?.labels?.[PARENT_AGENT_ID_LABEL]).toBeUndefined();
|
||||
} finally {
|
||||
await archiveAgentIfPresent(agentId);
|
||||
}
|
||||
});
|
||||
|
||||
test("agentManager.createAgent injects paseo MCP using the daemon listen target", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
@@ -565,7 +576,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
name: "Parity schedule list",
|
||||
provider: "claude",
|
||||
});
|
||||
@@ -591,7 +602,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
name: "Parity provider schedule",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
@@ -613,7 +624,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
name: "Parity inspect schedule",
|
||||
provider: "claude",
|
||||
});
|
||||
@@ -638,7 +649,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
name: "Parity pause schedule",
|
||||
provider: "claude",
|
||||
});
|
||||
@@ -665,7 +676,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
name: "Parity delete schedule",
|
||||
provider: "claude",
|
||||
});
|
||||
@@ -682,14 +693,13 @@ describe("Suite C: Schedule Tools", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule target self with callerAgentId", async () => {
|
||||
test("create_heartbeat targets the scoped agent", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(agentScopedClient, "create_schedule", {
|
||||
const created = await callToolStructured(agentScopedClient, "create_heartbeat", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Parity self schedule",
|
||||
target: "self",
|
||||
cron: "*/5 * * * *",
|
||||
name: "Parity heartbeat",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
expect(created.target).toMatchObject({
|
||||
@@ -706,7 +716,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
try {
|
||||
const created = await callToolStructured(agentScopedClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
@@ -722,16 +732,15 @@ describe("Suite C: Schedule Tools", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule target self without callerAgentId throws", async () => {
|
||||
test("create_heartbeat without callerAgentId throws", async () => {
|
||||
await expectToolError(
|
||||
topLevelClient,
|
||||
"create_schedule",
|
||||
"create_heartbeat",
|
||||
{
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
target: "self",
|
||||
cron: "*/5 * * * *",
|
||||
},
|
||||
/requires a caller agent/i,
|
||||
/requires an agent-scoped session/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1692,6 +1692,141 @@ describe("create_agent MCP tool", () => {
|
||||
await rm(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects background from caller agents and defaults notify-on-finish on", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
cwd: existingCwd,
|
||||
provider: "codex",
|
||||
currentModeId: "full-access",
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await expect(
|
||||
tool.handler({
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: false,
|
||||
}),
|
||||
).rejects.toThrow(/Unrecognized key/);
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (!parsed.success) {
|
||||
throw new Error("Expected caller create_agent input to parse");
|
||||
}
|
||||
expect(parsed.data).toMatchObject({
|
||||
detached: false,
|
||||
notifyOnFinish: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns notify-on-finish guidance for caller-created agents", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const parentAgent = {
|
||||
id: "parent-agent",
|
||||
cwd: existingCwd,
|
||||
provider: "codex",
|
||||
currentModeId: "full-access",
|
||||
} as ManagedAgent;
|
||||
const childAgent = {
|
||||
id: "child-agent",
|
||||
cwd: existingCwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Child" },
|
||||
} as ManagedAgent;
|
||||
spies.agentManager.getAgent.mockImplementation((agentId: string) => {
|
||||
if (agentId === "parent-agent") return parentAgent;
|
||||
if (agentId === "child-agent") return childAgent;
|
||||
return null;
|
||||
});
|
||||
spies.agentManager.createAgent.mockResolvedValue(childAgent);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
const response = await tool.handler({
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
});
|
||||
|
||||
expect(response.structuredContent.guidance).toBe(
|
||||
"You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates detached caller agents without a parent label", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
cwd: existingCwd,
|
||||
provider: "codex",
|
||||
currentModeId: "full-access",
|
||||
} as ManagedAgent);
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "detached-agent",
|
||||
cwd: existingCwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Detached" },
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
title: "Detached",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Take over",
|
||||
detached: true,
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: "spoofed-parent",
|
||||
source: "handoff",
|
||||
},
|
||||
});
|
||||
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: existingCwd,
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
labels: {
|
||||
source: "handoff",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts provider features from caller agents and passes them through createAgent", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
@@ -1721,7 +1856,6 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: true,
|
||||
settings: { features: { fast_mode: true } },
|
||||
};
|
||||
|
||||
@@ -2139,7 +2273,7 @@ describe("update_agent MCP tool", () => {
|
||||
describe("create_schedule MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("requires provider for new-agent schedules", async () => {
|
||||
it("requires provider for schedules", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -2154,7 +2288,7 @@ describe("create_schedule MCP tool", () => {
|
||||
await expect(
|
||||
tool.handler({
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
name: "Default schedule",
|
||||
}),
|
||||
).rejects.toThrow("provider is required when target is new-agent");
|
||||
@@ -2175,12 +2309,12 @@ describe("create_schedule MCP tool", () => {
|
||||
|
||||
await tool.handler({
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
provider: "codex",
|
||||
});
|
||||
await tool.handler({
|
||||
prompt: "say hello again",
|
||||
every: "10m",
|
||||
cron: "*/10 * * * *",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
|
||||
@@ -2239,8 +2373,7 @@ describe("create_schedule MCP tool", () => {
|
||||
|
||||
const response = await tool.handler({
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
target: "new-agent",
|
||||
cron: "*/5 * * * *",
|
||||
provider: "opencode/openai/gpt-5.5",
|
||||
});
|
||||
|
||||
@@ -2251,83 +2384,6 @@ describe("create_schedule MCP tool", () => {
|
||||
expectOutputSchemaAccepts(tool, response.structuredContent);
|
||||
});
|
||||
|
||||
it("accepts a blank cron field when every is provided", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
|
||||
createStoredSchedule(scheduleInput),
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await invokeToolWithParsedInput(tool, {
|
||||
prompt: "say hello",
|
||||
every: "10m",
|
||||
cron: "",
|
||||
provider: "codex",
|
||||
});
|
||||
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cadence: { type: "every", everyMs: 600000 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "whitespace cron field",
|
||||
input: { prompt: "say hello", every: "10m", cron: " ", provider: "codex" },
|
||||
cadence: { type: "every", everyMs: 600000 },
|
||||
},
|
||||
{
|
||||
label: "blank every field for cron cadence",
|
||||
input: {
|
||||
prompt: "say hello",
|
||||
every: "",
|
||||
cron: "*/10 * * * *",
|
||||
provider: "codex",
|
||||
},
|
||||
cadence: { type: "cron", expression: "*/10 * * * *" },
|
||||
},
|
||||
{
|
||||
label: "whitespace every field for cron cadence",
|
||||
input: {
|
||||
prompt: "say hello",
|
||||
every: " ",
|
||||
cron: "*/10 * * * *",
|
||||
provider: "codex",
|
||||
},
|
||||
cadence: { type: "cron", expression: "*/10 * * * *" },
|
||||
},
|
||||
])("normalizes create_schedule blank cadence input for $label", async ({ input, cadence }) => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
|
||||
createStoredSchedule(scheduleInput),
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await invokeToolWithParsedInput(tool, input);
|
||||
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cadence,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes timezone through cron create_schedule input", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
|
||||
@@ -2360,7 +2416,7 @@ describe("create_schedule MCP tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("still rejects both real every and cron inputs", async () => {
|
||||
it("rejects removed create_schedule every input", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -2372,19 +2428,17 @@ describe("create_schedule MCP tool", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await expect(
|
||||
invokeToolWithParsedInput(tool, {
|
||||
prompt: "say hello",
|
||||
every: "10m",
|
||||
cron: "*/10 * * * *",
|
||||
provider: "codex",
|
||||
}),
|
||||
).rejects.toThrow("Specify exactly one of every or cron");
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
prompt: "say hello",
|
||||
every: "10m",
|
||||
provider: "codex",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects create_schedule timezone without cron", async () => {
|
||||
it("rejects create_schedule without cron", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -2397,13 +2451,11 @@ describe("create_schedule MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await expect(
|
||||
invokeToolWithParsedInput(tool, {
|
||||
tool.handler({
|
||||
prompt: "say hello",
|
||||
every: "10m",
|
||||
timezone: "America/New_York",
|
||||
provider: "codex",
|
||||
}),
|
||||
).rejects.toThrow("timezone can only be used with cron");
|
||||
).rejects.toThrow(/cron/);
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2431,17 +2483,55 @@ describe("create_schedule MCP tool", () => {
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "missing both cadence fields",
|
||||
input: { prompt: "say hello", provider: "codex" },
|
||||
},
|
||||
{
|
||||
label: "blank cadence fields",
|
||||
input: { prompt: "say hello", every: " ", cron: "", provider: "codex" },
|
||||
},
|
||||
])("still rejects create_schedule when $label", async ({ input }) => {
|
||||
describe("create_heartbeat MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("creates a self-targeted cron heartbeat", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
provider: "codex",
|
||||
cwd: REPO_CWD,
|
||||
lifecycle: "idle",
|
||||
currentModeId: "build",
|
||||
availableModes: [],
|
||||
config: { title: "Parent agent" },
|
||||
} as ManagedAgent);
|
||||
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_heartbeat");
|
||||
|
||||
await invokeToolWithParsedInput(tool, {
|
||||
prompt: "check status",
|
||||
cron: "*/15 * * * *",
|
||||
timezone: "America/New_York",
|
||||
name: "status heartbeat",
|
||||
});
|
||||
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "check status",
|
||||
cadence: {
|
||||
type: "cron",
|
||||
expression: "*/15 * * * *",
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
target: { type: "agent", agentId: "parent-agent" },
|
||||
name: "status heartbeat",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires an agent-scoped session", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -2451,11 +2541,14 @@ describe("create_schedule MCP tool", () => {
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
const tool = registeredTool(server, "create_heartbeat");
|
||||
|
||||
await expect(invokeToolWithParsedInput(tool, input)).rejects.toThrow(
|
||||
"Specify exactly one of every or cron",
|
||||
);
|
||||
await expect(
|
||||
tool.handler({
|
||||
prompt: "check status",
|
||||
cron: "*/15 * * * *",
|
||||
}),
|
||||
).rejects.toThrow("create_heartbeat requires an agent-scoped session");
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -3131,7 +3224,7 @@ describe("speak MCP tool", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "speak");
|
||||
await expect(tool.handler({ text: "Hello." })).rejects.toThrow(
|
||||
"No speak handler registered for caller agent",
|
||||
"No speak handler registered for your session",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export interface AgentMcpServerOptions {
|
||||
clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"];
|
||||
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
/**
|
||||
* ID of the agent that is connecting to this MCP server.
|
||||
* Used for cwd/mode inheritance when agents spawn child agents.
|
||||
@@ -514,6 +515,28 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
registerRawTool(name, relaxMcpToolOutputSchema(config), (async (args: never, extra: never) =>
|
||||
addModelVisibleStructuredContent(await handler(args, extra))) as typeof handler);
|
||||
|
||||
const buildCronScheduleCadence = (input: {
|
||||
cron: string | undefined;
|
||||
timezone?: string;
|
||||
}): ScheduleCadence => {
|
||||
const expression = input.cron?.trim() ?? "";
|
||||
if (!expression) {
|
||||
throw new Error("cron is required");
|
||||
}
|
||||
const timezone = normalizeScheduleTimeZoneArg(input.timezone);
|
||||
return {
|
||||
type: "cron",
|
||||
expression,
|
||||
...(timezone !== undefined ? { timezone } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const buildScheduleExpiry = (expiresIn: string | undefined): string | undefined => {
|
||||
return expiresIn === undefined
|
||||
? undefined
|
||||
: new Date(Date.now() + parseDurationString(expiresIn)).toISOString();
|
||||
};
|
||||
|
||||
const resolveCallerAgent = () => {
|
||||
if (!callerAgentId) {
|
||||
return null;
|
||||
@@ -541,7 +564,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
if (opts?.required) {
|
||||
throw new Error("cwd is required");
|
||||
}
|
||||
throw new Error("cwd is required when no caller agent is available");
|
||||
throw new Error("cwd is required outside an agent-scoped session");
|
||||
}
|
||||
|
||||
return expandUserPath(trimmedCwd);
|
||||
@@ -699,7 +722,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional working directory. Defaults to the caller agent working directory."),
|
||||
.describe("Optional working directory. Defaults to your current working directory."),
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -718,19 +741,19 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
.trim()
|
||||
.min(1, "initialPrompt is required")
|
||||
.describe("Required first task to run immediately after creation."),
|
||||
background: z
|
||||
detached: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
|
||||
"If true, the created agent stands on its own: it does not appear in your subagent track and is not archived with you.",
|
||||
),
|
||||
notifyOnFinish: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.default(true)
|
||||
.describe(
|
||||
"Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission. Requires a caller agent context.",
|
||||
"Get notified when the created agent finishes, errors, or needs permission. Set false only for truly fire-and-forget agents.",
|
||||
),
|
||||
};
|
||||
|
||||
@@ -787,7 +810,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission. Requires a caller agent context.",
|
||||
"Agent-scoped only: get notified when the created agent finishes, errors, or needs permission.",
|
||||
),
|
||||
};
|
||||
|
||||
@@ -806,6 +829,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
"Draft provider settings used to compute available features.",
|
||||
),
|
||||
};
|
||||
type AgentToAgentCreateAgentArgs = z.infer<typeof agentToAgentCreateAgentArgsSchema>;
|
||||
type TopLevelCreateAgentArgs = z.infer<typeof topLevelCreateAgentArgsSchema>;
|
||||
|
||||
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
|
||||
@@ -832,7 +856,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
}
|
||||
const handler = resolveSpeakHandler?.(callerAgentId) ?? null;
|
||||
if (!handler) {
|
||||
throw new Error(`No speak handler registered for caller agent '${callerAgentId}'`);
|
||||
throw new Error(`No speak handler registered for your session '${callerAgentId}'`);
|
||||
}
|
||||
await handler({
|
||||
text: args.text,
|
||||
@@ -867,16 +891,35 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
availableModes: z.array(ProviderModeSchema),
|
||||
lastMessage: z.string().nullable().optional(),
|
||||
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
|
||||
guidance: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async (args: unknown) => {
|
||||
const { parsedArgs, worktree } = resolveCreateAgentToolArgs(args);
|
||||
const { snapshot, background, initialPromptStarted } = await createAgentCommand(
|
||||
const resolvedArgs = resolveCreateAgentToolArgs(args);
|
||||
const { parsedArgs, worktree } = resolvedArgs;
|
||||
let requestedBackground: boolean;
|
||||
let notifyOnFinish: boolean;
|
||||
let detached: boolean;
|
||||
if (resolvedArgs.kind === "agent-scoped") {
|
||||
requestedBackground = true;
|
||||
notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish;
|
||||
detached = resolvedArgs.parsedArgs.detached;
|
||||
} else {
|
||||
requestedBackground = resolvedArgs.parsedArgs.background;
|
||||
notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false;
|
||||
detached = false;
|
||||
}
|
||||
const {
|
||||
snapshot,
|
||||
background: createdInBackground,
|
||||
initialPromptStarted,
|
||||
} = await createAgentCommand(
|
||||
{
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger: childLogger,
|
||||
paseoHome: options.paseoHome,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
terminalManager,
|
||||
providerSnapshotManager,
|
||||
@@ -892,8 +935,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
features: parsedArgs.settings?.features,
|
||||
labels: parsedArgs.labels,
|
||||
mode: parsedArgs.settings?.modeId,
|
||||
background: parsedArgs.background ?? false,
|
||||
notifyOnFinish: parsedArgs.notifyOnFinish ?? false,
|
||||
background: requestedBackground,
|
||||
notifyOnFinish,
|
||||
detached,
|
||||
callerAgentId,
|
||||
callerContext,
|
||||
worktree,
|
||||
@@ -901,7 +945,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
);
|
||||
|
||||
try {
|
||||
if (!background && initialPromptStarted) {
|
||||
if (!createdInBackground && initialPromptStarted) {
|
||||
const result = await waitForAgentWithTimeout(agentManager, snapshot.id, {
|
||||
waitForActive: true,
|
||||
});
|
||||
@@ -930,8 +974,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Return immediately if background=true
|
||||
// Return immediately for async creation.
|
||||
const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot;
|
||||
const guidance =
|
||||
callerAgentId && notifyOnFinish && initialPromptStarted
|
||||
? "You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives."
|
||||
: undefined;
|
||||
const response = {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
@@ -943,26 +991,36 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
availableModes: currentSnapshot.availableModes,
|
||||
lastMessage: null,
|
||||
permission: null,
|
||||
...(guidance ? { guidance } : {}),
|
||||
}),
|
||||
};
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
function resolveCreateAgentToolArgs(args: unknown): {
|
||||
parsedArgs:
|
||||
| z.infer<typeof agentToAgentCreateAgentArgsSchema>
|
||||
| z.infer<typeof topLevelCreateAgentArgsSchema>;
|
||||
worktree: ReturnType<typeof resolveTopLevelCreateAgentWorktree>;
|
||||
} {
|
||||
type ResolvedCreateAgentToolArgs =
|
||||
| {
|
||||
kind: "agent-scoped";
|
||||
parsedArgs: AgentToAgentCreateAgentArgs;
|
||||
worktree: undefined;
|
||||
}
|
||||
| {
|
||||
kind: "top-level";
|
||||
parsedArgs: TopLevelCreateAgentArgs;
|
||||
worktree: ReturnType<typeof resolveTopLevelCreateAgentWorktree>;
|
||||
};
|
||||
|
||||
function resolveCreateAgentToolArgs(args: unknown): ResolvedCreateAgentToolArgs {
|
||||
if (callerAgentId) {
|
||||
return {
|
||||
kind: "agent-scoped",
|
||||
parsedArgs: agentToAgentCreateAgentArgsSchema.parse(args),
|
||||
worktree: undefined,
|
||||
};
|
||||
}
|
||||
const parsedArgs = topLevelCreateAgentArgsSchema.parse(args);
|
||||
return {
|
||||
kind: "top-level",
|
||||
parsedArgs,
|
||||
worktree: resolveTopLevelCreateAgentWorktree(parsedArgs),
|
||||
};
|
||||
@@ -1088,7 +1146,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission.",
|
||||
"Agent-scoped only: get notified when this run finishes, errors, or needs permission.",
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
@@ -1402,7 +1460,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional working directory. Defaults to the caller agent cwd."),
|
||||
.describe("Optional working directory. Defaults to your current working directory."),
|
||||
all: z.boolean().optional().describe("List terminals across all working directories."),
|
||||
},
|
||||
outputSchema: {
|
||||
@@ -1450,7 +1508,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional working directory. Defaults to the caller agent cwd."),
|
||||
.describe("Optional working directory. Defaults to your current working directory."),
|
||||
name: z.string().optional().describe("Optional terminal name."),
|
||||
},
|
||||
outputSchema: TerminalSummarySchema.shape,
|
||||
@@ -1591,22 +1649,18 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
"create_schedule",
|
||||
{
|
||||
title: "Create schedule",
|
||||
description: "Create a recurring schedule that runs on an agent or a new agent.",
|
||||
description: "Create a recurring schedule that starts a new agent on a cron cadence.",
|
||||
inputSchema: {
|
||||
prompt: z.string().trim().min(1, "prompt is required"),
|
||||
every: z.string().optional(),
|
||||
cron: z.string().optional(),
|
||||
cron: z.string().trim().min(1, "cron is required"),
|
||||
timezone: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"IANA time zone for cron cadence; requires cron. For example: America/New_York.",
|
||||
),
|
||||
.describe("IANA time zone for the cron cadence. For example: America/New_York."),
|
||||
name: z.string().optional(),
|
||||
target: z.enum(["self", "new-agent"]).optional(),
|
||||
provider: AgentProviderEnum.optional().describe(
|
||||
provider: AgentProviderEnum.describe(
|
||||
"Provider, or provider/model (for example: codex or codex/gpt-5.4).",
|
||||
),
|
||||
cwd: z.string().optional(),
|
||||
@@ -1615,70 +1669,71 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
outputSchema: ScheduleSummarySchema.shape,
|
||||
},
|
||||
async ({ prompt, every, cron, timezone, name, target, provider, cwd, maxRuns, expiresIn }) => {
|
||||
async ({ prompt, cron, timezone, name, provider, cwd, maxRuns, expiresIn }) => {
|
||||
if (!scheduleService) {
|
||||
throw new Error("Schedule service is not configured");
|
||||
}
|
||||
|
||||
const normalizedEvery = normalizeScheduleCadenceArg(every);
|
||||
const normalizedCron = normalizeScheduleCadenceArg(cron);
|
||||
const normalizedTimeZone = normalizeScheduleTimeZoneArg(timezone);
|
||||
const cadenceCount =
|
||||
Number(normalizedEvery !== undefined) + Number(normalizedCron !== undefined);
|
||||
if (cadenceCount !== 1) {
|
||||
throw new Error("Specify exactly one of every or cron");
|
||||
}
|
||||
if (normalizedTimeZone !== undefined && normalizedCron === undefined) {
|
||||
throw new Error("timezone can only be used with cron");
|
||||
}
|
||||
|
||||
const scheduleTarget =
|
||||
target === "self"
|
||||
? (() => {
|
||||
const callerAgent = resolveCallerAgent();
|
||||
if (!callerAgentId || !callerAgent) {
|
||||
throw new Error("target=self requires a caller agent");
|
||||
}
|
||||
const trimmedCwd = cwd?.trim();
|
||||
if (trimmedCwd && expandUserPath(trimmedCwd) !== callerAgent.cwd) {
|
||||
throw new Error("cwd can only differ from the caller agent when target=new-agent");
|
||||
}
|
||||
if (provider !== undefined) {
|
||||
const resolved = resolveScheduleProviderAndModel({
|
||||
provider,
|
||||
defaultProvider: callerAgent.provider,
|
||||
});
|
||||
if (
|
||||
resolved.provider !== callerAgent.provider ||
|
||||
(resolved.model !== undefined && resolved.model !== callerAgent.config.model)
|
||||
) {
|
||||
throw new Error(
|
||||
"provider can only differ from the caller agent when target=new-agent",
|
||||
);
|
||||
}
|
||||
}
|
||||
return { type: "agent" as const, agentId: callerAgentId };
|
||||
})()
|
||||
: (() => {
|
||||
return resolveNewAgentScheduleTarget({ provider, cwd });
|
||||
})();
|
||||
|
||||
const expiresAt = buildScheduleExpiry(expiresIn);
|
||||
const schedule = await scheduleService.create({
|
||||
prompt: prompt.trim(),
|
||||
cadence:
|
||||
normalizedEvery !== undefined
|
||||
? { type: "every" as const, everyMs: parseDurationString(normalizedEvery) }
|
||||
: {
|
||||
type: "cron" as const,
|
||||
expression: normalizedCron!,
|
||||
...(normalizedTimeZone !== undefined ? { timezone: normalizedTimeZone } : {}),
|
||||
},
|
||||
target: scheduleTarget,
|
||||
cadence: buildCronScheduleCadence({
|
||||
cron,
|
||||
...(timezone !== undefined ? { timezone } : {}),
|
||||
}),
|
||||
target: resolveNewAgentScheduleTarget({ provider, cwd }),
|
||||
...(name?.trim() ? { name: name.trim() } : {}),
|
||||
...(maxRuns === undefined ? {} : { maxRuns }),
|
||||
...(expiresIn === undefined
|
||||
? {}
|
||||
: { expiresAt: new Date(Date.now() + parseDurationString(expiresIn)).toISOString() }),
|
||||
...(expiresAt === undefined ? {} : { expiresAt }),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson(toScheduleSummary(schedule)),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
registerTool(
|
||||
"create_heartbeat",
|
||||
{
|
||||
title: "Create heartbeat",
|
||||
description: "Create a recurring heartbeat that sends you a prompt on a cron cadence.",
|
||||
inputSchema: {
|
||||
prompt: z.string().trim().min(1, "prompt is required"),
|
||||
cron: z.string().trim().min(1, "cron is required"),
|
||||
timezone: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("IANA time zone for the cron cadence. For example: America/New_York."),
|
||||
name: z.string().optional(),
|
||||
maxRuns: z.number().int().positive().optional(),
|
||||
expiresIn: z.string().optional(),
|
||||
},
|
||||
outputSchema: ScheduleSummarySchema.shape,
|
||||
},
|
||||
async ({ prompt, cron, timezone, name, maxRuns, expiresIn }) => {
|
||||
if (!scheduleService) {
|
||||
throw new Error("Schedule service is not configured");
|
||||
}
|
||||
if (!callerAgentId) {
|
||||
throw new Error("create_heartbeat requires an agent-scoped session");
|
||||
}
|
||||
resolveCallerAgent();
|
||||
|
||||
const expiresAt = buildScheduleExpiry(expiresIn);
|
||||
const schedule = await scheduleService.create({
|
||||
prompt: prompt.trim(),
|
||||
cadence: buildCronScheduleCadence({
|
||||
cron,
|
||||
...(timezone !== undefined ? { timezone } : {}),
|
||||
}),
|
||||
target: { type: "agent", agentId: callerAgentId },
|
||||
...(name?.trim() ? { name: name.trim() } : {}),
|
||||
...(maxRuns === undefined ? {} : { maxRuns }),
|
||||
...(expiresAt === undefined ? {} : { expiresAt }),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -2027,7 +2082,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional repository cwd. Defaults to the caller agent cwd."),
|
||||
.describe("Optional repository cwd. Defaults to your current working directory."),
|
||||
},
|
||||
outputSchema: {
|
||||
worktrees: z.array(WorktreeSummarySchema),
|
||||
@@ -2099,6 +2154,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
const commandResult = await createPaseoWorktreeCommand(
|
||||
{
|
||||
paseoHome: options.paseoHome,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
createPaseoWorktreeWorkflow: options.createPaseoWorktree,
|
||||
},
|
||||
createMcpWorktreeCommandInput(repoRoot, target),
|
||||
@@ -2131,7 +2187,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional repository cwd. Defaults to the caller agent cwd."),
|
||||
.describe("Optional repository cwd. Defaults to your current working directory."),
|
||||
worktreePath: z.string().optional(),
|
||||
worktreeSlug: z.string().optional(),
|
||||
},
|
||||
@@ -2361,6 +2417,7 @@ function archiveWorktreeDependencies(
|
||||
}
|
||||
return {
|
||||
paseoHome: options.paseoHome,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
github: options.github,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
agentManager: context.agentManager,
|
||||
|
||||
@@ -97,6 +97,18 @@ class SessionEvents {
|
||||
.map((event) => event.item);
|
||||
}
|
||||
|
||||
timelineAndCompletionEvents() {
|
||||
return this.events.flatMap((event) => {
|
||||
if (event.type === "timeline") {
|
||||
return [{ type: "timeline" as const, item: event.item }];
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
return [{ type: "turn_completed" as const }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
nextTurnCompletion(): Promise<Extract<AgentStreamEvent, { type: "turn_completed" }>> {
|
||||
return this.nextEvent(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
|
||||
@@ -459,6 +471,28 @@ describe("PiRpcAgentSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("surfaces Pi extension command messages and completes when no agent turn starts", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
|
||||
await session.startTurn("/show-status");
|
||||
fakeSession.emit({
|
||||
type: "message_end",
|
||||
message: {
|
||||
role: "custom",
|
||||
content: [{ type: "text", text: "Extension command output" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(events.timelineAndCompletionEvents()).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
item: { type: "assistant_message", text: "Extension command output" },
|
||||
},
|
||||
{ type: "turn_completed" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("adds Pi assistant context to generic provider finish errors", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
|
||||
|
||||
@@ -1499,6 +1499,20 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
event: Extract<PiAgentSessionEvent, { type: "message_end" }>,
|
||||
turnId: string | undefined,
|
||||
): void {
|
||||
if (event.message.role === "custom") {
|
||||
const text = getUserMessageText(event.message.content);
|
||||
if (text) {
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
turnId,
|
||||
item: { type: "assistant_message", text },
|
||||
});
|
||||
}
|
||||
this.completeTurn(turnId, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.message.role !== "user") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ export type PiAgentMessage =
|
||||
role: "user";
|
||||
content: string | Array<PiTextContent | PiImageContent>;
|
||||
}
|
||||
| {
|
||||
role: "custom";
|
||||
content: string | Array<PiTextContent | PiImageContent>;
|
||||
}
|
||||
| {
|
||||
role: "assistant";
|
||||
content: PiAssistantContent[];
|
||||
|
||||
@@ -15,6 +15,7 @@ import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
|
||||
|
||||
export interface AutoArchiveArchiveOptions {
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
daemonConfigStore: DaemonConfigStore;
|
||||
workspaceGitService: WorkspaceGitServiceImpl;
|
||||
github: GitHubService;
|
||||
@@ -81,7 +82,10 @@ export async function archiveIfSafe(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
const ownership = await deps.isPaseoOwnedWorktreeCwd(cwd, { paseoHome: options.paseoHome });
|
||||
const ownership = await deps.isPaseoOwnedWorktreeCwd(cwd, {
|
||||
paseoHome: options.paseoHome,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
});
|
||||
if (!ownership.allowed) {
|
||||
return;
|
||||
}
|
||||
@@ -90,6 +94,7 @@ export async function archiveIfSafe(input: {
|
||||
await deps.archivePaseoWorktree(
|
||||
{
|
||||
paseoHome: options.paseoHome,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
github: options.github,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
agentManager: options.agentManager,
|
||||
@@ -115,6 +120,7 @@ export async function archiveIfSafe(input: {
|
||||
targetPath: cwd,
|
||||
repoRoot: ownership.repoRoot ?? null,
|
||||
worktreesRoot: ownership.worktreeRoot,
|
||||
worktreesBaseRoot: options.worktreesRoot,
|
||||
requestId: "auto-archive-on-merge",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import os from "node:os";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import pino from "pino";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
|
||||
import { hashDaemonPassword } from "./auth.js";
|
||||
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { findFreePort } from "./service-proxy.js";
|
||||
|
||||
describe("paseo daemon bootstrap", () => {
|
||||
afterEach(() => {
|
||||
@@ -41,6 +45,253 @@ describe("paseo daemon bootstrap", () => {
|
||||
}
|
||||
});
|
||||
|
||||
function httpGetWithHost(port: number, host: string, requestPath: string): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(
|
||||
{ hostname: "127.0.0.1", port, path: requestPath, headers: { host } },
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
resolve(
|
||||
new Response(Buffer.concat(chunks), {
|
||||
status: res.statusCode ?? 0,
|
||||
headers: res.headers as HeadersInit,
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
test("proxies registered service hosts before daemon auth while daemon APIs stay protected", async () => {
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.end("service-ok");
|
||||
});
|
||||
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
|
||||
const address = upstream.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected upstream TCP address");
|
||||
}
|
||||
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
auth: { password: hashDaemonPassword("secret") },
|
||||
});
|
||||
try {
|
||||
daemonHandle.daemon.serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-service-auth",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "web",
|
||||
port: address.port,
|
||||
});
|
||||
|
||||
const serviceResponse = await httpGetWithHost(
|
||||
daemonHandle.port,
|
||||
`web--repo.localhost:${daemonHandle.port}`,
|
||||
"/",
|
||||
);
|
||||
expect(serviceResponse.status).toBe(200);
|
||||
expect(await serviceResponse.text()).toBe("service-ok");
|
||||
|
||||
const daemonResponse = await httpGetWithHost(
|
||||
daemonHandle.port,
|
||||
`daemon.localhost:${daemonHandle.port}`,
|
||||
"/api/status",
|
||||
);
|
||||
expect(daemonResponse.status).toBe(401);
|
||||
} finally {
|
||||
await daemonHandle.close();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("configured public service namespace misses never reach daemon APIs", async () => {
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
serviceProxy: {
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
standaloneListen: null,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await httpGetWithHost(
|
||||
daemonHandle.port,
|
||||
`missing.services.example.com:${daemonHandle.port}`,
|
||||
"/api/status",
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("404 Not Found");
|
||||
} finally {
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("rolls back daemon listener when standalone service proxy startup fails", async () => {
|
||||
const occupiedServer = http.createServer((_req, res) => {
|
||||
res.end("occupied");
|
||||
});
|
||||
await new Promise<void>((resolve) => occupiedServer.listen(0, "127.0.0.1", resolve));
|
||||
const address = occupiedServer.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected occupied TCP address");
|
||||
}
|
||||
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-standalone-rollback-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: "127.0.0.1:0",
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
relayEnabled: false,
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
openai: undefined,
|
||||
speech: undefined,
|
||||
serviceProxy: {
|
||||
standaloneListen: `127.0.0.1:${address.port}`,
|
||||
},
|
||||
};
|
||||
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
|
||||
|
||||
try {
|
||||
await expect(daemon.start()).rejects.toThrow();
|
||||
await expect(fetch(`http://127.0.0.1:${daemon.port}/api/health`)).rejects.toThrow();
|
||||
} finally {
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await new Promise<void>((resolve) => occupiedServer.close(() => resolve()));
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("local service namespace misses never reach daemon APIs", async () => {
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
auth: { password: hashDaemonPassword("secret") },
|
||||
});
|
||||
try {
|
||||
const response = await httpGetWithHost(
|
||||
daemonHandle.port,
|
||||
`missing--repo.localhost:${daemonHandle.port}`,
|
||||
"/api/status",
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("404 Not Found");
|
||||
} finally {
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("daemon websocket still upgrades when service proxy upgrade handler is mounted", async () => {
|
||||
const daemonHandle = await createTestPaseoDaemon();
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${daemonHandle.port}/ws`);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.once("open", resolve);
|
||||
ws.once("error", reject);
|
||||
});
|
||||
expect(ws.readyState).toBe(WebSocket.OPEN);
|
||||
} finally {
|
||||
ws.close();
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("standalone listener exposes services only", async () => {
|
||||
const standalonePort = await findFreePort();
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
res.end("service-ok");
|
||||
});
|
||||
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
|
||||
const upstreamAddress = upstream.address();
|
||||
if (!upstreamAddress || typeof upstreamAddress === "string") {
|
||||
throw new Error("Expected upstream TCP address");
|
||||
}
|
||||
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
serviceProxy: { standaloneListen: `127.0.0.1:${standalonePort}` },
|
||||
});
|
||||
try {
|
||||
daemonHandle.daemon.serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-standalone",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "web",
|
||||
port: upstreamAddress.port,
|
||||
});
|
||||
|
||||
const serviceResponse = await httpGetWithHost(
|
||||
standalonePort,
|
||||
`web--repo.localhost:${standalonePort}`,
|
||||
"/",
|
||||
);
|
||||
expect(serviceResponse.status).toBe(200);
|
||||
expect(await serviceResponse.text()).toBe("service-ok");
|
||||
|
||||
for (const requestPath of ["/api/health", "/ws", "/mcp/agents", "/index.html", "/files/x"]) {
|
||||
const response = await httpGetWithHost(
|
||||
standalonePort,
|
||||
`daemon.localhost:${standalonePort}`,
|
||||
requestPath,
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
}
|
||||
} finally {
|
||||
await daemonHandle.close();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("rolls back already-open standalone listener when main daemon listen fails", async () => {
|
||||
const mainPort = await findFreePort();
|
||||
const standalonePort = await findFreePort();
|
||||
const occupiedMain = http.createServer((_req, res) => {
|
||||
res.end("occupied-main");
|
||||
});
|
||||
await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve));
|
||||
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: `127.0.0.1:${mainPort}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
relayEnabled: false,
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
openai: undefined,
|
||||
speech: undefined,
|
||||
serviceProxy: { standaloneListen: `127.0.0.1:${standalonePort}` },
|
||||
};
|
||||
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
|
||||
|
||||
try {
|
||||
await expect(daemon.start()).rejects.toThrow();
|
||||
await expect(fetch(`http://127.0.0.1:${standalonePort}/api/health`)).rejects.toThrow();
|
||||
} finally {
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await new Promise<void>((resolve) => occupiedMain.close(() => resolve()));
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("redacts Agent MCP debug request credentials and bodies", async () => {
|
||||
const logLines: string[] = [];
|
||||
const logger = pino(
|
||||
|
||||
@@ -126,11 +126,7 @@ import type {
|
||||
ProviderOverride,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import type { PersistedConfig } from "./persisted-config.js";
|
||||
import {
|
||||
ScriptRouteStore,
|
||||
createScriptProxyMiddleware,
|
||||
createScriptProxyUpgradeHandler,
|
||||
} from "./script-proxy.js";
|
||||
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import { ScriptHealthMonitor } from "./script-health-monitor.js";
|
||||
import { createScriptStatusEmitter } from "./script-status-projection.js";
|
||||
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
@@ -231,6 +227,7 @@ export type DaemonLifecycleIntent =
|
||||
export interface PaseoDaemonConfig {
|
||||
listen: string;
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
corsAllowedOrigins: string[];
|
||||
allowedHosts?: HostnamesConfig;
|
||||
hostnames?: HostnamesConfig;
|
||||
@@ -248,6 +245,10 @@ export interface PaseoDaemonConfig {
|
||||
relayPublicEndpoint?: string;
|
||||
relayUseTls?: boolean;
|
||||
relayPublicUseTls?: boolean;
|
||||
serviceProxy?: {
|
||||
publicBaseUrl: string | null;
|
||||
standaloneListen: string | null;
|
||||
};
|
||||
appBaseUrl?: string;
|
||||
auth?: DaemonAuthConfig;
|
||||
openai?: PaseoOpenAIConfig;
|
||||
@@ -276,7 +277,7 @@ export interface PaseoDaemon {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
terminalManager: TerminalManager;
|
||||
scriptRouteStore: ScriptRouteStore;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
scriptRuntimeStore: WorkspaceScriptRuntimeStore;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
@@ -330,33 +331,46 @@ export async function createPaseoDaemon(
|
||||
let boundListenTarget: ListenTarget | null = null;
|
||||
let workspaceRegistry: FileBackedWorkspaceRegistry | null = null;
|
||||
|
||||
const scriptRouteStore = new ScriptRouteStore();
|
||||
const serviceProxyPublicBaseUrl = config.serviceProxy?.publicBaseUrl
|
||||
? config.serviceProxy.publicBaseUrl
|
||||
: null;
|
||||
const serviceProxy = createServiceProxySubsystem({
|
||||
logger,
|
||||
publicBaseUrl: serviceProxyPublicBaseUrl,
|
||||
});
|
||||
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const configuredHostnames = config.hostnames ?? config.allowedHosts;
|
||||
let wsServer: VoiceAssistantWebSocketServer | null = null;
|
||||
let serviceProxyListenTarget: ListenTarget | null = null;
|
||||
const scriptHealthMonitor = new ScriptHealthMonitor({
|
||||
routeStore: scriptRouteStore,
|
||||
serviceProxy,
|
||||
onChange: createScriptStatusEmitter({
|
||||
sessions: () =>
|
||||
wsServer?.listActiveSessions().map((session) => ({
|
||||
emit: (message) => session.emitServerMessage(message),
|
||||
})) ?? [],
|
||||
routeStore: scriptRouteStore,
|
||||
serviceProxy,
|
||||
runtimeStore: scriptRuntimeStore,
|
||||
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
resolveWorkspaceDirectory: async (workspaceId) =>
|
||||
(await workspaceRegistry?.get(workspaceId))?.cwd ?? null,
|
||||
logger,
|
||||
serviceProxyPublicBaseUrl,
|
||||
}),
|
||||
});
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore: scriptRouteStore,
|
||||
serviceProxy,
|
||||
onRoutesChanged: (workspaceId) => {
|
||||
scriptHealthMonitor.invalidateWorkspace(workspaceId);
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
// Service proxy classifies service hosts before daemon auth/route fallthrough.
|
||||
// Registered service hosts proxy directly; known service namespaces without a
|
||||
// route return 404 and never reach daemon APIs.
|
||||
app.use(serviceProxy.middleware());
|
||||
|
||||
// Host allowlist / DNS rebinding protection (vite-like semantics).
|
||||
// For non-TCP (unix sockets), skip host validation.
|
||||
if (listenTarget.type === "tcp") {
|
||||
@@ -406,11 +420,6 @@ export async function createPaseoDaemon(
|
||||
}),
|
||||
);
|
||||
|
||||
// Script proxy — intercepts requests for registered *.localhost hostnames
|
||||
// and forwards them to the corresponding local script port. Placed after
|
||||
// host/CORS/auth checks but before the rest of the routes.
|
||||
app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
|
||||
|
||||
// Serve static files from public directory
|
||||
app.use("/public", express.static(staticDir));
|
||||
|
||||
@@ -494,11 +503,11 @@ export async function createPaseoDaemon(
|
||||
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
|
||||
// script-bound upgrades are forwarded first. The handler is a no-op for
|
||||
// requests that don't match a registered script route.
|
||||
const scriptProxyUpgradeHandler = createScriptProxyUpgradeHandler({
|
||||
routeStore: scriptRouteStore,
|
||||
logger,
|
||||
});
|
||||
httpServer.on("upgrade", scriptProxyUpgradeHandler);
|
||||
httpServer.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: true }));
|
||||
|
||||
if (config.serviceProxy?.standaloneListen) {
|
||||
serviceProxyListenTarget = parseListenString(config.serviceProxy.standaloneListen);
|
||||
}
|
||||
|
||||
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
@@ -518,6 +527,7 @@ export async function createPaseoDaemon(
|
||||
const workspaceGitService = new WorkspaceGitServiceImpl({
|
||||
logger,
|
||||
paseoHome: config.paseoHome,
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
deps: {
|
||||
github,
|
||||
},
|
||||
@@ -657,6 +667,7 @@ export async function createPaseoDaemon(
|
||||
|
||||
setupAutoArchiveOnMerge({
|
||||
paseoHome: config.paseoHome,
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
daemonConfigStore,
|
||||
workspaceGitService,
|
||||
github,
|
||||
@@ -694,6 +705,7 @@ export async function createPaseoDaemon(
|
||||
return createPaseoWorktreeWorkflow(
|
||||
{
|
||||
paseoHome: config.paseoHome,
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
createPaseoWorktree: async (workflowInput, workflowOptions) => {
|
||||
return createRegisteredPaseoWorktree(workflowInput, {
|
||||
github,
|
||||
@@ -727,12 +739,13 @@ export async function createPaseoDaemon(
|
||||
sessionLogger: logger,
|
||||
terminalManager,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
scriptRouteStore,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
getDaemonTcpPort: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||
getDaemonTcpHost: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
|
||||
serviceProxyPublicBaseUrl,
|
||||
onScriptsChanged: null,
|
||||
},
|
||||
input,
|
||||
@@ -740,6 +753,7 @@ export async function createPaseoDaemon(
|
||||
);
|
||||
},
|
||||
paseoHome: config.paseoHome,
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
callerAgentId,
|
||||
enableVoiceTools: false,
|
||||
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
|
||||
@@ -872,157 +886,185 @@ export async function createPaseoDaemon(
|
||||
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
|
||||
|
||||
const start = async () => {
|
||||
// Start main HTTP server
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
httpServer.off("listening", onListening);
|
||||
reject(err);
|
||||
};
|
||||
const onListening = () => {
|
||||
httpServer.off("error", onError);
|
||||
const logAndResolve = async () => {
|
||||
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
|
||||
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
|
||||
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
|
||||
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
|
||||
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
|
||||
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
|
||||
});
|
||||
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
|
||||
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
|
||||
});
|
||||
const relayEnabled = config.relayEnabled ?? true;
|
||||
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
|
||||
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
|
||||
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
|
||||
const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
|
||||
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
|
||||
|
||||
if (boundListenTarget.type === "tcp") {
|
||||
logger.info(
|
||||
{
|
||||
host: boundListenTarget.host,
|
||||
port: boundListenTarget.port,
|
||||
authRequired: !!config.auth?.password,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
{
|
||||
path: boundListenTarget.path,
|
||||
authRequired: !!config.auth?.password,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
`Server listening on ${boundListenTarget.path}`,
|
||||
);
|
||||
}
|
||||
if (config.auth?.password) {
|
||||
logger.info("Daemon password authentication enabled");
|
||||
}
|
||||
|
||||
wsServer = new VoiceAssistantWebSocketServer(
|
||||
httpServer,
|
||||
logger,
|
||||
serverId,
|
||||
agentManager,
|
||||
agentStorage,
|
||||
downloadTokenStore,
|
||||
config.paseoHome,
|
||||
daemonConfigStore,
|
||||
mcpBaseUrl,
|
||||
{ allowedOrigins, hostnames: configuredHostnames },
|
||||
config.auth,
|
||||
speechService,
|
||||
terminalManager,
|
||||
{
|
||||
finalTimeoutMs: config.dictationFinalTimeoutMs,
|
||||
},
|
||||
daemonVersion,
|
||||
(intent) => {
|
||||
try {
|
||||
config.onLifecycleIntent?.(intent);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
|
||||
}
|
||||
},
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
chatService,
|
||||
loopService,
|
||||
scheduleService,
|
||||
checkoutDiffManager,
|
||||
scriptRouteStore,
|
||||
scriptRuntimeStore,
|
||||
handleBranchChange,
|
||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
|
||||
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
|
||||
workspaceGitService,
|
||||
github,
|
||||
config.pushNotificationSender,
|
||||
providerSnapshotManager,
|
||||
{
|
||||
listen: formatListenTarget(boundListenTarget ?? listenTarget),
|
||||
relay: {
|
||||
enabled: relayEnabled,
|
||||
endpoint: relayEndpoint,
|
||||
publicEndpoint: relayPublicEndpoint,
|
||||
useTls: relayUseTls,
|
||||
publicUseTls: relayPublicUseTls,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (relayEnabled) {
|
||||
const offer = await createConnectionOfferV2({
|
||||
serverId,
|
||||
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
|
||||
relay: {
|
||||
endpoint: relayPublicEndpoint,
|
||||
useTls: relayPublicUseTls,
|
||||
},
|
||||
});
|
||||
|
||||
encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||
|
||||
relayTransport?.stop().catch(() => undefined);
|
||||
relayTransport = startRelayTransport({
|
||||
logger,
|
||||
attachSocket: (ws, metadata) => {
|
||||
if (!wsServer) {
|
||||
throw new Error("WebSocket server not initialized");
|
||||
}
|
||||
return wsServer.attachExternalSocket(ws, metadata);
|
||||
},
|
||||
relayEndpoint,
|
||||
relayUseTls,
|
||||
serverId,
|
||||
daemonKeyPair: daemonKeyPair.keyPair,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
logAndResolve().then(resolve, reject);
|
||||
};
|
||||
httpServer.once("error", onError);
|
||||
httpServer.once("listening", onListening);
|
||||
|
||||
if (listenTarget.type === "tcp") {
|
||||
httpServer.listen(listenTarget.port, listenTarget.host);
|
||||
} else {
|
||||
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
|
||||
unlinkSync(listenTarget.path);
|
||||
}
|
||||
httpServer.listen(listenTarget.path);
|
||||
let mainStarted = false;
|
||||
try {
|
||||
if (serviceProxyListenTarget) {
|
||||
const boundServiceProxyTarget = await serviceProxy.startStandalone({
|
||||
listenTarget: serviceProxyListenTarget,
|
||||
});
|
||||
serviceProxyListenTarget = boundServiceProxyTarget;
|
||||
logger.info(
|
||||
{
|
||||
listen: formatListenTarget(serviceProxyListenTarget),
|
||||
publicBaseUrl: serviceProxyPublicBaseUrl,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
"Service proxy listening",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Start speech service after listening so synchronous Sherpa native
|
||||
// model loading doesn't block the server from accepting connections.
|
||||
speechService.start();
|
||||
scriptHealthMonitor.start();
|
||||
// Start main HTTP server
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
httpServer.off("listening", onListening);
|
||||
reject(err);
|
||||
};
|
||||
const onListening = () => {
|
||||
httpServer.off("error", onError);
|
||||
mainStarted = true;
|
||||
const logAndResolve = async () => {
|
||||
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
|
||||
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
|
||||
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
|
||||
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
|
||||
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
|
||||
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
|
||||
});
|
||||
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
|
||||
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
|
||||
});
|
||||
const relayEnabled = config.relayEnabled ?? true;
|
||||
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
|
||||
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
|
||||
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
|
||||
const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
|
||||
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
|
||||
|
||||
if (boundListenTarget.type === "tcp") {
|
||||
logger.info(
|
||||
{
|
||||
host: boundListenTarget.host,
|
||||
port: boundListenTarget.port,
|
||||
authRequired: !!config.auth?.password,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
{
|
||||
path: boundListenTarget.path,
|
||||
authRequired: !!config.auth?.password,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
`Server listening on ${boundListenTarget.path}`,
|
||||
);
|
||||
}
|
||||
if (config.auth?.password) {
|
||||
logger.info("Daemon password authentication enabled");
|
||||
}
|
||||
|
||||
wsServer = new VoiceAssistantWebSocketServer(
|
||||
httpServer,
|
||||
logger,
|
||||
serverId,
|
||||
agentManager,
|
||||
agentStorage,
|
||||
downloadTokenStore,
|
||||
config.paseoHome,
|
||||
daemonConfigStore,
|
||||
mcpBaseUrl,
|
||||
{ allowedOrigins, hostnames: configuredHostnames },
|
||||
config.auth,
|
||||
speechService,
|
||||
terminalManager,
|
||||
{
|
||||
finalTimeoutMs: config.dictationFinalTimeoutMs,
|
||||
},
|
||||
daemonVersion,
|
||||
(intent) => {
|
||||
try {
|
||||
config.onLifecycleIntent?.(intent);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
|
||||
}
|
||||
},
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
chatService,
|
||||
loopService,
|
||||
scheduleService,
|
||||
checkoutDiffManager,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
handleBranchChange,
|
||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
|
||||
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
|
||||
workspaceGitService,
|
||||
github,
|
||||
config.pushNotificationSender,
|
||||
providerSnapshotManager,
|
||||
{
|
||||
listen: formatListenTarget(boundListenTarget ?? listenTarget),
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
relay: {
|
||||
enabled: relayEnabled,
|
||||
endpoint: relayEndpoint,
|
||||
publicEndpoint: relayPublicEndpoint,
|
||||
useTls: relayUseTls,
|
||||
publicUseTls: relayPublicUseTls,
|
||||
},
|
||||
},
|
||||
serviceProxyPublicBaseUrl,
|
||||
);
|
||||
|
||||
if (relayEnabled) {
|
||||
const offer = await createConnectionOfferV2({
|
||||
serverId,
|
||||
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
|
||||
relay: {
|
||||
endpoint: relayPublicEndpoint,
|
||||
useTls: relayPublicUseTls,
|
||||
},
|
||||
});
|
||||
|
||||
encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||
|
||||
relayTransport?.stop().catch(() => undefined);
|
||||
relayTransport = startRelayTransport({
|
||||
logger,
|
||||
attachSocket: (ws, metadata) => {
|
||||
if (!wsServer) {
|
||||
throw new Error("WebSocket server not initialized");
|
||||
}
|
||||
return wsServer.attachExternalSocket(ws, metadata);
|
||||
},
|
||||
relayEndpoint,
|
||||
relayUseTls,
|
||||
serverId,
|
||||
daemonKeyPair: daemonKeyPair.keyPair,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
logAndResolve().then(resolve, reject);
|
||||
};
|
||||
httpServer.once("error", onError);
|
||||
httpServer.once("listening", onListening);
|
||||
|
||||
if (listenTarget.type === "tcp") {
|
||||
httpServer.listen(listenTarget.port, listenTarget.host);
|
||||
} else {
|
||||
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
|
||||
unlinkSync(listenTarget.path);
|
||||
}
|
||||
httpServer.listen(listenTarget.path);
|
||||
}
|
||||
});
|
||||
|
||||
// Start speech service after listening so synchronous Sherpa native
|
||||
// model loading doesn't block the server from accepting connections.
|
||||
speechService.start();
|
||||
scriptHealthMonitor.start();
|
||||
} catch (error) {
|
||||
await serviceProxy.stopStandalone().catch(() => undefined);
|
||||
if (mainStarted) {
|
||||
httpServer.closeAllConnections();
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
@@ -1039,6 +1081,7 @@ export async function createPaseoDaemon(
|
||||
if (wsServer) {
|
||||
await wsServer.close();
|
||||
}
|
||||
await serviceProxy.stopStandalone();
|
||||
// Force-drop remaining sockets so httpServer.close() resolves promptly.
|
||||
// We've already closed wsServer (which sent ws-layer close frames) and
|
||||
// stopped every other service, so anything still attached is a TCP
|
||||
@@ -1061,7 +1104,7 @@ export async function createPaseoDaemon(
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
scriptRouteStore,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
start,
|
||||
stop,
|
||||
|
||||
@@ -83,3 +83,95 @@ describe("daemon relay config", () => {
|
||||
expect(config.relayPublicUseTls).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon service proxy config", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
test("loads public base URL from env before persisted config", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: {
|
||||
serviceProxy: {
|
||||
publicBaseUrl: "https://persisted.example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const config = loadConfig(home, {
|
||||
env: { PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: "https://env.example.com/" },
|
||||
});
|
||||
|
||||
expect(config.serviceProxy).toEqual({
|
||||
publicBaseUrl: "https://env.example.com",
|
||||
standaloneListen: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not synthesize a standalone service listener from enabled true", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: { serviceProxy: { enabled: true } },
|
||||
});
|
||||
|
||||
expect(loadConfig(home, { env: {} }).serviceProxy).toEqual({
|
||||
publicBaseUrl: null,
|
||||
standaloneListen: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("enabled false suppresses optional service proxy layers only", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: {
|
||||
serviceProxy: {
|
||||
enabled: false,
|
||||
listen: "127.0.0.1:9999",
|
||||
publicBaseUrl: "https://persisted.example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadConfig(home, { env: {} }).serviceProxy).toEqual({
|
||||
publicBaseUrl: null,
|
||||
standaloneListen: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects invalid PASEO_SERVICE_PROXY_PUBLIC_BASE_URL values", async () => {
|
||||
const home = await createPaseoHome({ version: 1 });
|
||||
|
||||
expect(() =>
|
||||
loadConfig(home, {
|
||||
env: { PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: "not-a-url" },
|
||||
}),
|
||||
).toThrow("Invalid PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: not-a-url");
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon worktree root config", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
test("resolves relative worktrees.root against PASEO_HOME", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
worktrees: { root: "custom-worktrees" },
|
||||
});
|
||||
|
||||
expect(loadConfig(home, { env: {} }).worktreesRoot).toBe(path.join(home, "custom-worktrees"));
|
||||
});
|
||||
|
||||
test("keeps absolute worktrees.root absolute", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
worktrees: { root: path.join(os.tmpdir(), "paseo-custom-worktrees") },
|
||||
});
|
||||
|
||||
expect(loadConfig(home, { env: {} }).worktreesRoot).toBe(
|
||||
path.join(os.tmpdir(), "paseo-custom-worktrees"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { resolvePaseoNodeEnv } from "./paseo-env.js";
|
||||
import { z } from "zod";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
|
||||
import type { PaseoDaemonConfig } from "./bootstrap.js";
|
||||
import {
|
||||
@@ -151,6 +152,11 @@ interface ResolvedRelay {
|
||||
publicUseTls: boolean;
|
||||
}
|
||||
|
||||
interface ResolvedServiceProxy {
|
||||
publicBaseUrl: string | null;
|
||||
standaloneListen: string | null;
|
||||
}
|
||||
|
||||
function resolveTlsFromEnv(
|
||||
envValue: string | undefined,
|
||||
persistedValue: boolean | undefined,
|
||||
@@ -197,6 +203,41 @@ interface ResolvedVoiceLlm {
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
function resolveServiceProxyPublicBaseUrl(value: string | null): string | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(value).toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
throw new Error(`Invalid PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveServiceProxyConfig(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
): ResolvedServiceProxy {
|
||||
const enabledShim =
|
||||
parseBooleanEnv(env.PASEO_SERVICE_PROXY_ENABLED) ?? persisted.daemon?.serviceProxy?.enabled;
|
||||
// COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
|
||||
// `enabled=false` used to disable the separate service proxy listener. Localhost
|
||||
// service proxying is now always enabled; this only suppresses optional layers.
|
||||
const optionalLayersEnabled = enabledShim !== false;
|
||||
const publicBaseUrl = optionalLayersEnabled
|
||||
? resolveServiceProxyPublicBaseUrl(
|
||||
env.PASEO_SERVICE_PROXY_PUBLIC_BASE_URL ??
|
||||
persisted.daemon?.serviceProxy?.publicBaseUrl ??
|
||||
null,
|
||||
)
|
||||
: null;
|
||||
const standaloneListen = optionalLayersEnabled
|
||||
? (env.PASEO_SERVICE_PROXY_LISTEN ?? persisted.daemon?.serviceProxy?.listen ?? null)
|
||||
: null;
|
||||
|
||||
return { publicBaseUrl, standaloneListen };
|
||||
}
|
||||
|
||||
function resolveVoiceLlmConfig(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
@@ -256,6 +297,21 @@ function resolveAuthConfig(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveWorktreesRoot(
|
||||
paseoHome: string,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
): string | undefined {
|
||||
const configuredRoot = persisted.worktrees?.root?.trim();
|
||||
if (!configuredRoot) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const expandedRoot = expandTilde(configuredRoot);
|
||||
return path.isAbsolute(expandedRoot)
|
||||
? path.resolve(expandedRoot)
|
||||
: path.resolve(paseoHome, expandedRoot);
|
||||
}
|
||||
|
||||
function resolveAppendSystemPrompt(persisted: ReturnType<typeof loadPersistedConfig>): string {
|
||||
return persisted.daemon?.appendSystemPrompt ?? "";
|
||||
}
|
||||
@@ -306,6 +362,7 @@ export function loadConfig(
|
||||
cliRelayEnabled: options?.cli?.relayEnabled,
|
||||
cliRelayUseTls: options?.cli?.relayUseTls,
|
||||
});
|
||||
const serviceProxy = resolveServiceProxyConfig(env, persisted);
|
||||
|
||||
const { openai, speech } = resolveSpeechConfig({
|
||||
paseoHome,
|
||||
@@ -321,6 +378,7 @@ export function loadConfig(
|
||||
return {
|
||||
listen,
|
||||
paseoHome,
|
||||
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
|
||||
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
|
||||
hostnames,
|
||||
mcpEnabled,
|
||||
@@ -337,6 +395,7 @@ export function loadConfig(
|
||||
relayPublicEndpoint: relay.publicEndpoint,
|
||||
relayUseTls: relay.useTls,
|
||||
relayPublicUseTls: relay.publicUseTls,
|
||||
serviceProxy,
|
||||
appBaseUrl,
|
||||
auth: resolveAuthConfig(env, persisted),
|
||||
openai,
|
||||
|
||||
@@ -5,11 +5,16 @@ import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
||||
import type { GitHubService } from "../services/github-service.js";
|
||||
import { deletePaseoWorktree, resolvePaseoWorktreeRootForCwd } from "../utils/worktree.js";
|
||||
import {
|
||||
deletePaseoWorktree,
|
||||
resolvePaseoWorktreeRootForCwd,
|
||||
WorktreeTeardownError,
|
||||
} from "../utils/worktree.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
|
||||
export interface ArchivePaseoWorktreeDependencies {
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
github: GitHubService;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
|
||||
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
|
||||
@@ -37,12 +42,14 @@ export async function archivePaseoWorktree(
|
||||
targetPath: string;
|
||||
repoRoot: string | null;
|
||||
worktreesRoot?: string;
|
||||
worktreesBaseRoot?: string;
|
||||
requestId: string;
|
||||
},
|
||||
): Promise<string[]> {
|
||||
let targetPath = options.targetPath;
|
||||
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot,
|
||||
});
|
||||
if (resolvedWorktree) {
|
||||
targetPath = resolvedWorktree.worktreePath;
|
||||
@@ -104,14 +111,28 @@ export async function archivePaseoWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
await deletePaseoWorktree({
|
||||
cwd: options.repoRoot,
|
||||
worktreePath: targetPath,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
let teardownError: WorktreeTeardownError | null = null;
|
||||
try {
|
||||
await deletePaseoWorktree({
|
||||
cwd: options.repoRoot,
|
||||
worktreePath: targetPath,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesBaseRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorktreeTeardownError) {
|
||||
teardownError = error;
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, targetPath },
|
||||
"Worktree teardown failed during archive; archiving workspace record anyway",
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.repoRoot) {
|
||||
if (!teardownError && options.repoRoot) {
|
||||
try {
|
||||
await dependencies.workspaceGitService.getSnapshot(options.repoRoot, {
|
||||
force: true,
|
||||
@@ -136,11 +157,17 @@ export async function archivePaseoWorktree(
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, workspaceId },
|
||||
"Failed to archive workspace record; worktree FS already removed",
|
||||
teardownError
|
||||
? "Failed to archive workspace record after teardown failed"
|
||||
: "Failed to archive workspace record; worktree FS already removed",
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (teardownError) {
|
||||
throw teardownError;
|
||||
}
|
||||
} finally {
|
||||
dependencies.clearWorkspaceArchiving(affectedWorkspaceIdList);
|
||||
await dependencies.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIdList);
|
||||
|
||||
@@ -63,6 +63,18 @@ describe("PersistedConfigSchema daemon relay config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema worktrees config", () => {
|
||||
test("accepts optional worktree root", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
worktrees: {
|
||||
root: "/mnt/fast/paseo-worktrees",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.worktrees?.root).toBe("/mnt/fast/paseo-worktrees");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema daemon append system prompt", () => {
|
||||
test("accepts optional append system prompt", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
|
||||
@@ -63,6 +63,12 @@ const ProvidersSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const WorktreesConfigSchema = z
|
||||
.object({
|
||||
root: z.string().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, {
|
||||
message: "Expected a bcrypt hash",
|
||||
});
|
||||
@@ -231,6 +237,17 @@ export const PersistedConfigSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
serviceProxy: z
|
||||
.object({
|
||||
// COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
|
||||
// Parsed only to suppress optional public/listen layers for old configs;
|
||||
// localhost service proxying remains always enabled.
|
||||
enabled: z.boolean().optional(),
|
||||
listen: z.string().optional(),
|
||||
publicBaseUrl: z.string().url().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
auth: DaemonAuthSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
@@ -248,6 +265,7 @@ export const PersistedConfigSchema = z
|
||||
.optional(),
|
||||
|
||||
providers: ProvidersSchema.optional(),
|
||||
worktrees: WorktreesConfigSchema.optional(),
|
||||
agents: z
|
||||
.object({
|
||||
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
@@ -214,7 +214,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
@@ -261,7 +261,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
@@ -292,7 +292,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
@@ -343,7 +343,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
@@ -393,7 +393,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
@@ -450,7 +450,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
branchName: null,
|
||||
scriptName,
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(
|
||||
createTerminalCalls,
|
||||
@@ -462,7 +462,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
expect(createTerminalCalls).toHaveLength(2);
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "api.repo.localhost",
|
||||
hostname: "api--repo.localhost",
|
||||
port: service.port,
|
||||
workspaceId: workspace.repoDir,
|
||||
projectSlug: "repo",
|
||||
@@ -472,7 +472,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
@@ -487,7 +487,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
expect(onChange).toHaveBeenCalledWith(workspace.repoDir, [
|
||||
{
|
||||
scriptName: "api",
|
||||
hostname: "api.repo.localhost",
|
||||
hostname: "api--repo.localhost",
|
||||
port: service.port,
|
||||
health: "healthy",
|
||||
},
|
||||
@@ -524,7 +524,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import net from "node:net";
|
||||
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { ServiceProxyHealthTarget, ServiceProxySubsystem } from "./service-proxy.js";
|
||||
|
||||
export type ScriptHealthState = "pending" | "healthy" | "unhealthy";
|
||||
|
||||
@@ -18,7 +18,7 @@ interface RouteHealthState {
|
||||
}
|
||||
|
||||
export class ScriptHealthMonitor {
|
||||
private readonly routeStore: ScriptRouteStore;
|
||||
private readonly serviceProxy: ServiceProxySubsystem;
|
||||
private readonly onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void;
|
||||
private readonly pollIntervalMs: number;
|
||||
private readonly probeTimeoutMs: number;
|
||||
@@ -31,21 +31,21 @@ export class ScriptHealthMonitor {
|
||||
private pollInFlight = false;
|
||||
|
||||
constructor({
|
||||
routeStore,
|
||||
serviceProxy,
|
||||
onChange,
|
||||
pollIntervalMs = 3_000,
|
||||
probeTimeoutMs = 500,
|
||||
graceMs = 5_000,
|
||||
failuresBeforeStopped = 2,
|
||||
}: {
|
||||
routeStore: ScriptRouteStore;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void;
|
||||
pollIntervalMs?: number;
|
||||
probeTimeoutMs?: number;
|
||||
graceMs?: number;
|
||||
failuresBeforeStopped?: number;
|
||||
}) {
|
||||
this.routeStore = routeStore;
|
||||
this.serviceProxy = serviceProxy;
|
||||
this.onChange = onChange;
|
||||
this.pollIntervalMs = pollIntervalMs;
|
||||
this.probeTimeoutMs = probeTimeoutMs;
|
||||
@@ -59,7 +59,7 @@ export class ScriptHealthMonitor {
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const route of this.routeStore.listRoutes()) {
|
||||
for (const route of this.serviceProxy.getHealthCheckTargets()) {
|
||||
this.getOrCreateState(route, now);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export class ScriptHealthMonitor {
|
||||
|
||||
this.pollInFlight = true;
|
||||
try {
|
||||
const routes = this.routeStore.listRoutes();
|
||||
const routes = this.serviceProxy.getHealthCheckTargets();
|
||||
const activeHostnames = new Set(routes.map((route) => route.hostname));
|
||||
const changedWorkspaceIds = new Set<string>();
|
||||
const now = Date.now();
|
||||
@@ -142,7 +142,7 @@ export class ScriptHealthMonitor {
|
||||
}
|
||||
|
||||
private getOrCreateState(
|
||||
route: Pick<ScriptRouteEntry, "hostname" | "workspaceId">,
|
||||
route: Pick<ServiceProxyHealthTarget, "hostname" | "workspaceId">,
|
||||
registeredAt: number,
|
||||
): RouteHealthState {
|
||||
const existing = this.routeStates.get(route.hostname);
|
||||
@@ -171,7 +171,7 @@ export class ScriptHealthMonitor {
|
||||
}
|
||||
|
||||
private buildWorkspaceScriptList(workspaceId: string): ScriptHealthEntry[] {
|
||||
return this.routeStore.listRoutesForWorkspace(workspaceId).flatMap((route) => {
|
||||
return this.serviceProxy.getWorkspaceHealthTargets(workspaceId).flatMap((route) => {
|
||||
const state = this.routeStates.get(route.hostname);
|
||||
if (!state) {
|
||||
return [];
|
||||
@@ -186,7 +186,7 @@ export class ScriptHealthMonitor {
|
||||
return state.health;
|
||||
}
|
||||
|
||||
const route = this.routeStore.getRouteEntry(hostname);
|
||||
const route = this.serviceProxy.getHealthTargetForHostname(hostname);
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export class ScriptHealthMonitor {
|
||||
}
|
||||
|
||||
private toScriptHealthEntry(
|
||||
route: ScriptRouteEntry,
|
||||
route: ServiceProxyHealthTarget,
|
||||
health: ScriptHealthEntry["health"],
|
||||
): ScriptHealthEntry {
|
||||
return {
|
||||
|
||||
@@ -1,484 +1,16 @@
|
||||
import { it, expect, afterEach } from "vitest";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import express from "express";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import pino from "pino";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ScriptRouteStore,
|
||||
createScriptProxyMiddleware,
|
||||
createScriptProxyUpgradeHandler,
|
||||
findFreePort,
|
||||
ScriptRouteStore,
|
||||
} from "./script-proxy.js";
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function closeServer(server: http.Server): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScriptRouteStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("registerRoute and findRoute with exact match", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "route-a.example.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "editor",
|
||||
});
|
||||
|
||||
const route = store.findRoute("route-a.example.localhost");
|
||||
expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 });
|
||||
});
|
||||
|
||||
it("findRoute strips port from host header", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "route-a.example.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "editor",
|
||||
});
|
||||
|
||||
const route = store.findRoute("route-a.example.localhost:6767");
|
||||
expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 });
|
||||
});
|
||||
|
||||
it("findRoute subdomain match", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "editor.example.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "editor",
|
||||
});
|
||||
|
||||
const route = store.findRoute("tenant.editor.example.localhost");
|
||||
expect(route).toEqual({ hostname: "editor.example.localhost", port: 3000 });
|
||||
});
|
||||
|
||||
it("listRoutes returns enriched entries", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
projectSlug: "repo",
|
||||
scriptName: "docs",
|
||||
});
|
||||
|
||||
const routes = store.listRoutes();
|
||||
expect(routes).toHaveLength(2);
|
||||
expect(routes).toContainEqual({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "web",
|
||||
});
|
||||
expect(routes).toContainEqual({
|
||||
hostname: "b.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
projectSlug: "repo",
|
||||
scriptName: "docs",
|
||||
describe("script-proxy compatibility re-exports", () => {
|
||||
it("keeps the legacy imports available", () => {
|
||||
expect(createScriptProxyMiddleware).toEqual(expect.any(Function));
|
||||
expect(createScriptProxyUpgradeHandler).toEqual(expect.any(Function));
|
||||
expect(findFreePort).toEqual(expect.any(Function));
|
||||
expect(new ScriptRouteStore()).toEqual(expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it("listRoutesForWorkspace returns only routes for that workspace", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
projectSlug: "repo",
|
||||
scriptName: "docs",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "c.localhost",
|
||||
port: 5000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([
|
||||
{
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "web",
|
||||
},
|
||||
{
|
||||
hostname: "c.localhost",
|
||||
port: 5000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("removeRoute works", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "route-a.example.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "editor",
|
||||
});
|
||||
store.removeRoute("route-a.example.localhost");
|
||||
|
||||
expect(store.findRoute("route-a.example.localhost")).toBeNull();
|
||||
});
|
||||
|
||||
it("removeRoute cleans up workspace index", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "route-a.example.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "editor",
|
||||
});
|
||||
|
||||
store.removeRoute("route-a.example.localhost");
|
||||
|
||||
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("removeRoutesForPort works", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "c.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
projectSlug: "repo",
|
||||
scriptName: "docs",
|
||||
});
|
||||
|
||||
store.removeRoutesForPort(3000);
|
||||
|
||||
expect(store.findRoute("a.localhost")).toBeNull();
|
||||
expect(store.findRoute("b.localhost")).toBeNull();
|
||||
expect(store.findRoute("c.localhost")).toEqual({
|
||||
hostname: "c.localhost",
|
||||
port: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it("removeRoutesForPort cleans up workspace index", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
store.removeRoutesForPort(3000);
|
||||
|
||||
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("findRoute returns null for unknown hosts", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "route-a.example.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "editor",
|
||||
});
|
||||
|
||||
expect(store.findRoute("unknown.example.com")).toBeNull();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP proxy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const servers: http.Server[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.map(closeServer));
|
||||
servers.length = 0;
|
||||
});
|
||||
|
||||
/** Start a real HTTP server that echoes back a known body and records received headers. */
|
||||
async function startUpstream(): Promise<{
|
||||
port: number;
|
||||
server: http.Server;
|
||||
receivedHeaders: () => http.IncomingHttpHeaders;
|
||||
}> {
|
||||
const port = await findFreePort();
|
||||
let lastHeaders: http.IncomingHttpHeaders = {};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
lastHeaders = req.headers;
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.end("upstream-ok");
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
|
||||
servers.push(server);
|
||||
|
||||
return {
|
||||
port,
|
||||
server,
|
||||
receivedHeaders: () => lastHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
/** Start an Express app with the service proxy middleware and an optional fallback. */
|
||||
async function startProxy(
|
||||
routeStore: ScriptRouteStore,
|
||||
opts?: { fallback?: boolean },
|
||||
): Promise<{ port: number; server: http.Server }> {
|
||||
const port = await findFreePort();
|
||||
const app = express();
|
||||
app.use(createScriptProxyMiddleware({ routeStore, logger }));
|
||||
|
||||
if (opts?.fallback) {
|
||||
app.use((_req, res) => {
|
||||
res.status(404).send("no route");
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
|
||||
servers.push(server);
|
||||
|
||||
return { port, server };
|
||||
}
|
||||
|
||||
/** Simple HTTP GET helper that returns status code and body. */
|
||||
function httpGet(
|
||||
port: number,
|
||||
host: string,
|
||||
path = "/",
|
||||
): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get({ hostname: "127.0.0.1", port, path, headers: { host } }, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk: Buffer) => (body += chunk.toString()));
|
||||
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
it("proxies requests to the correct upstream based on Host header", async () => {
|
||||
const upstream = await startUpstream();
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "test-service.localhost",
|
||||
port: upstream.port,
|
||||
workspaceId: "workspace-test",
|
||||
projectSlug: "test",
|
||||
scriptName: "service",
|
||||
});
|
||||
|
||||
const proxy = await startProxy(routeStore);
|
||||
const res = await httpGet(proxy.port, `test-service.localhost:${proxy.port}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBe("upstream-ok");
|
||||
|
||||
const headers = upstream.receivedHeaders();
|
||||
expect(headers["x-forwarded-for"]).toBeDefined();
|
||||
expect(headers["x-forwarded-host"]).toBe("test-service.localhost");
|
||||
});
|
||||
|
||||
it("falls through when no route matches", async () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const proxy = await startProxy(routeStore, { fallback: true });
|
||||
|
||||
const res = await httpGet(proxy.port, `unknown.localhost:${proxy.port}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toBe("no route");
|
||||
});
|
||||
|
||||
it("returns 502 when upstream is down", async () => {
|
||||
// Get a port that nothing is listening on
|
||||
const deadPort = await findFreePort();
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "dead-service.localhost",
|
||||
port: deadPort,
|
||||
workspaceId: "workspace-dead",
|
||||
projectSlug: "dead",
|
||||
scriptName: "service",
|
||||
});
|
||||
|
||||
const proxy = await startProxy(routeStore);
|
||||
const res = await httpGet(proxy.port, `dead-service.localhost:${proxy.port}`);
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body).toBe("502 Bad Gateway");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket proxy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const httpServers: http.Server[] = [];
|
||||
const wsServers: WebSocketServer[] = [];
|
||||
const wsClients: WebSocket[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const ws of wsClients) {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close();
|
||||
}
|
||||
wsClients.length = 0;
|
||||
|
||||
for (const wss of wsServers) {
|
||||
wss.close();
|
||||
}
|
||||
wsServers.length = 0;
|
||||
|
||||
await Promise.all(httpServers.map(closeServer));
|
||||
httpServers.length = 0;
|
||||
});
|
||||
|
||||
it("proxies WebSocket connections to the correct upstream", async () => {
|
||||
// 1. Start a real WebSocket echo server
|
||||
const upstreamPort = await findFreePort();
|
||||
const upstreamServer = http.createServer();
|
||||
const wss = new WebSocketServer({ server: upstreamServer });
|
||||
wsServers.push(wss);
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
ws.on("message", (data) => {
|
||||
ws.send(`echo: ${data.toString()}`);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => upstreamServer.listen(upstreamPort, "127.0.0.1", resolve));
|
||||
httpServers.push(upstreamServer);
|
||||
|
||||
// 2. Create the proxy server with the upgrade handler
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "ws-service.localhost",
|
||||
port: upstreamPort,
|
||||
workspaceId: "workspace-ws",
|
||||
projectSlug: "ws",
|
||||
scriptName: "service",
|
||||
});
|
||||
|
||||
const proxyPort = await findFreePort();
|
||||
const proxyServer = http.createServer((_req, res) => {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
const upgradeHandler = createScriptProxyUpgradeHandler({
|
||||
routeStore,
|
||||
logger,
|
||||
});
|
||||
proxyServer.on("upgrade", upgradeHandler);
|
||||
|
||||
await new Promise<void>((resolve) => proxyServer.listen(proxyPort, "127.0.0.1", resolve));
|
||||
httpServers.push(proxyServer);
|
||||
|
||||
// 3. Connect a WebSocket client through the proxy
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`, {
|
||||
headers: { host: `ws-service.localhost:${proxyPort}` },
|
||||
});
|
||||
wsClients.push(ws);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.on("open", resolve);
|
||||
ws.on("error", reject);
|
||||
});
|
||||
|
||||
// 4. Send a message and verify echo
|
||||
const reply = await new Promise<string>((resolve, reject) => {
|
||||
ws.on("message", (data) => resolve(data.toString()));
|
||||
ws.on("error", reject);
|
||||
ws.send("hello proxy");
|
||||
});
|
||||
|
||||
expect(reply).toBe("echo: hello proxy");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findFreePort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("returns a number", async () => {
|
||||
const port = await findFreePort();
|
||||
expect(typeof port).toBe("number");
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(port).toBeLessThan(65536);
|
||||
});
|
||||
|
||||
it("returns a port that is actually available", async () => {
|
||||
const port = await findFreePort();
|
||||
|
||||
// Verify we can bind a server to it
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(port, "127.0.0.1", () => resolve());
|
||||
server.on("error", reject);
|
||||
});
|
||||
|
||||
const addr = server.address();
|
||||
expect(addr).not.toBeNull();
|
||||
expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe(port);
|
||||
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
@@ -1,320 +1,7 @@
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { Logger } from "pino";
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hop-by-hop headers that must not be forwarded
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HOP_BY_HOP_HEADERS = new Set([
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"keep-alive",
|
||||
"upgrade",
|
||||
"proxy-connection",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScriptRouteStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ScriptRoute {
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface ScriptRouteEntry extends ScriptRoute {
|
||||
workspaceId: string;
|
||||
projectSlug: string;
|
||||
scriptName: string;
|
||||
}
|
||||
|
||||
export class ScriptRouteStore {
|
||||
private routes = new Map<string, ScriptRouteEntry>();
|
||||
private workspaceHostnames = new Map<string, Set<string>>();
|
||||
|
||||
registerRoute(entry: ScriptRouteEntry): void {
|
||||
const previous = this.routes.get(entry.hostname);
|
||||
if (previous) {
|
||||
this.removeHostnameFromWorkspaceIndex(previous.workspaceId, previous.hostname);
|
||||
}
|
||||
|
||||
const storedEntry = { ...entry };
|
||||
this.routes.set(storedEntry.hostname, storedEntry);
|
||||
this.addHostnameToWorkspaceIndex(storedEntry.workspaceId, storedEntry.hostname);
|
||||
}
|
||||
|
||||
removeRoute(hostname: string): void {
|
||||
const entry = this.routes.get(hostname);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
this.routes.delete(hostname);
|
||||
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
|
||||
}
|
||||
|
||||
removeRouteForWorkspaceScript(params: { workspaceId: string; scriptName: string }): void {
|
||||
const routes = this.listRoutesForWorkspace(params.workspaceId);
|
||||
const route = routes.find((entry) => entry.scriptName === params.scriptName);
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
this.removeRoute(route.hostname);
|
||||
}
|
||||
|
||||
removeRoutesForPort(port: number): void {
|
||||
for (const [hostname, entry] of this.routes) {
|
||||
if (entry.port === port) {
|
||||
this.routes.delete(hostname);
|
||||
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findRoute(host: string): ScriptRoute | null {
|
||||
// Strip port suffix from the Host header value
|
||||
const hostname = host.replace(/:\d+$/, "");
|
||||
|
||||
// 1. Exact match
|
||||
const exactRoute = this.routes.get(hostname);
|
||||
if (exactRoute !== undefined) {
|
||||
return { hostname: exactRoute.hostname, port: exactRoute.port };
|
||||
}
|
||||
|
||||
// 2. Subdomain match — walk up the labels looking for a registered parent
|
||||
const parts = hostname.split(".");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const candidate = parts.slice(i).join(".");
|
||||
const candidateRoute = this.routes.get(candidate);
|
||||
if (candidateRoute !== undefined) {
|
||||
return { hostname: candidateRoute.hostname, port: candidateRoute.port };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getRouteEntry(hostname: string): ScriptRouteEntry | null {
|
||||
const entry = this.routes.get(hostname);
|
||||
return entry ? { ...entry } : null;
|
||||
}
|
||||
|
||||
listRoutes(): ScriptRouteEntry[] {
|
||||
return Array.from(this.routes.values()).map((entry) => Object.assign({}, entry));
|
||||
}
|
||||
|
||||
listRoutesForWorkspace(workspaceId: string): ScriptRouteEntry[] {
|
||||
const hostnames = this.workspaceHostnames.get(workspaceId);
|
||||
if (!hostnames) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const routes: ScriptRouteEntry[] = [];
|
||||
for (const hostname of hostnames) {
|
||||
const entry = this.routes.get(hostname);
|
||||
if (entry) {
|
||||
routes.push({ ...entry });
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
private addHostnameToWorkspaceIndex(workspaceId: string, hostname: string): void {
|
||||
const hostnames = this.workspaceHostnames.get(workspaceId) ?? new Set<string>();
|
||||
hostnames.add(hostname);
|
||||
this.workspaceHostnames.set(workspaceId, hostnames);
|
||||
}
|
||||
|
||||
private removeHostnameFromWorkspaceIndex(workspaceId: string, hostname: string): void {
|
||||
const hostnames = this.workspaceHostnames.get(workspaceId);
|
||||
if (!hostnames) {
|
||||
return;
|
||||
}
|
||||
|
||||
hostnames.delete(hostname);
|
||||
if (hostnames.size === 0) {
|
||||
this.workspaceHostnames.delete(workspaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stripHopByHopHeaders(
|
||||
rawHeaders: http.IncomingHttpHeaders,
|
||||
): Record<string, string | string[]> {
|
||||
const out: Record<string, string | string[]> = {};
|
||||
for (const [key, value] of Object.entries(rawHeaders)) {
|
||||
if (value === undefined) continue;
|
||||
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createScriptProxyMiddleware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createScriptProxyMiddleware({
|
||||
routeStore,
|
||||
logger,
|
||||
}: {
|
||||
routeStore: ScriptRouteStore;
|
||||
logger: Logger;
|
||||
}): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
const hostHeader = req.headers.host;
|
||||
if (!hostHeader) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const route = routeStore.findRoute(hostHeader);
|
||||
if (!route) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const forwardedHeaders = stripHopByHopHeaders(req.headers);
|
||||
forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1";
|
||||
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
|
||||
forwardedHeaders["x-forwarded-proto"] = req.protocol;
|
||||
|
||||
const proxyReq = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: route.port,
|
||||
path: req.originalUrl,
|
||||
method: req.method,
|
||||
headers: forwardedHeaders,
|
||||
},
|
||||
(proxyRes) => {
|
||||
const responseHeaders = stripHopByHopHeaders(proxyRes.headers);
|
||||
res.writeHead(proxyRes.statusCode ?? 502, responseHeaders);
|
||||
proxyRes.pipe(res, { end: true });
|
||||
},
|
||||
);
|
||||
|
||||
proxyReq.on("error", (err) => {
|
||||
logger.warn(
|
||||
{ err, hostname: route.hostname, port: route.port },
|
||||
"Script proxy: upstream unreachable",
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { "content-type": "text/plain" });
|
||||
res.end("502 Bad Gateway");
|
||||
}
|
||||
});
|
||||
|
||||
req.pipe(proxyReq, { end: true });
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createScriptProxyUpgradeHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createScriptProxyUpgradeHandler({
|
||||
routeStore,
|
||||
logger,
|
||||
}: {
|
||||
routeStore: ScriptRouteStore;
|
||||
logger: Logger;
|
||||
}): (req: IncomingMessage, socket: net.Socket, head: Buffer) => void {
|
||||
return (req, socket, head) => {
|
||||
const hostHeader = req.headers.host;
|
||||
if (!hostHeader) {
|
||||
return;
|
||||
}
|
||||
|
||||
const route = routeStore.findRoute(hostHeader);
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSocket = net.connect({ host: "127.0.0.1", port: route.port }, () => {
|
||||
// Reconstruct the raw HTTP upgrade request to send to the target
|
||||
const forwardedHeaders = stripHopByHopHeaders(req.headers);
|
||||
forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1";
|
||||
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
|
||||
forwardedHeaders["x-forwarded-proto"] = "http";
|
||||
|
||||
// Re-include upgrade and connection headers — they are required for
|
||||
// WebSocket handshake even though they are hop-by-hop.
|
||||
forwardedHeaders["connection"] = "Upgrade";
|
||||
forwardedHeaders["upgrade"] = req.headers.upgrade ?? "websocket";
|
||||
|
||||
const headerLines: string[] = [];
|
||||
headerLines.push(`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`);
|
||||
for (const [key, value] of Object.entries(forwardedHeaders)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
headerLines.push(`${key}: ${v}`);
|
||||
}
|
||||
} else {
|
||||
headerLines.push(`${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
headerLines.push("\r\n");
|
||||
|
||||
targetSocket.write(headerLines.join("\r\n"));
|
||||
|
||||
if (head.length > 0) {
|
||||
targetSocket.write(head);
|
||||
}
|
||||
|
||||
// Pipe in both directions
|
||||
targetSocket.pipe(socket);
|
||||
socket.pipe(targetSocket);
|
||||
});
|
||||
|
||||
targetSocket.on("error", (err) => {
|
||||
logger.warn(
|
||||
{ err, hostname: route.hostname, port: route.port },
|
||||
"Script proxy: WebSocket upstream unreachable",
|
||||
);
|
||||
socket.end();
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
targetSocket.destroy();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findFreePort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function findFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
reject(new Error("Failed to get assigned port"));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(port);
|
||||
}
|
||||
});
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
export {
|
||||
createScriptProxyMiddleware,
|
||||
createScriptProxyUpgradeHandler,
|
||||
findFreePort,
|
||||
ScriptRouteStore,
|
||||
} from "./service-proxy.js";
|
||||
export type { ScriptRoute, ScriptRouteEntry } from "./service-proxy.js";
|
||||
|
||||
@@ -49,12 +49,16 @@ function registerRoute(
|
||||
workspaceId = "workspace-a",
|
||||
projectSlug = "paseo",
|
||||
scriptName,
|
||||
publicHostname,
|
||||
publicBaseUrl,
|
||||
}: {
|
||||
hostname: string;
|
||||
port: number;
|
||||
workspaceId?: string;
|
||||
projectSlug?: string;
|
||||
scriptName: string;
|
||||
publicHostname?: string | null;
|
||||
publicBaseUrl?: string | null;
|
||||
},
|
||||
): void {
|
||||
routeStore.registerRoute({
|
||||
@@ -63,6 +67,8 @@ function registerRoute(
|
||||
workspaceId,
|
||||
projectSlug,
|
||||
scriptName,
|
||||
...(publicHostname ? { publicHostname } : {}),
|
||||
...(publicBaseUrl ? { publicBaseUrl } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,22 +76,22 @@ describe("script-route-branch-handler", () => {
|
||||
it("updates routes on branch rename by removing old hostnames and registering new ones", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api.feature-auth.paseo.localhost",
|
||||
hostname: "api--feature-auth--paseo.localhost",
|
||||
port: 3001,
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
|
||||
|
||||
expect(routeStore.findRoute("api.feature-auth.paseo.localhost")).toBeNull();
|
||||
expect(routeStore.findRoute("api.feature-billing.paseo.localhost")).toEqual({
|
||||
hostname: "api.feature-billing.paseo.localhost",
|
||||
expect(routeStore.findRoute("api--feature-auth--paseo.localhost")).toBeNull();
|
||||
expect(routeStore.findRoute("api--feature-billing--paseo.localhost")).toEqual({
|
||||
hostname: "api--feature-billing--paseo.localhost",
|
||||
port: 3001,
|
||||
});
|
||||
});
|
||||
@@ -94,7 +100,7 @@ describe("script-route-branch-handler", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
@@ -107,14 +113,14 @@ describe("script-route-branch-handler", () => {
|
||||
it("is a no-op when the resolved hostnames do not change", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api.paseo.localhost",
|
||||
hostname: "api--paseo.localhost",
|
||||
port: 3001,
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
@@ -122,7 +128,7 @@ describe("script-route-branch-handler", () => {
|
||||
|
||||
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
|
||||
{
|
||||
hostname: "api.paseo.localhost",
|
||||
hostname: "api--paseo.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "paseo",
|
||||
@@ -135,14 +141,14 @@ describe("script-route-branch-handler", () => {
|
||||
it("triggers shared reprojection after a route change", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api.feature-auth.paseo.localhost",
|
||||
hostname: "api--feature-auth--paseo.localhost",
|
||||
port: 3001,
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
@@ -151,20 +157,56 @@ describe("script-route-branch-handler", () => {
|
||||
expect(onRoutesChanged).toHaveBeenCalledWith("workspace-a");
|
||||
});
|
||||
|
||||
it("updates public route aliases from the stored public base URL", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api--feature-auth--paseo.localhost",
|
||||
publicHostname: "api--feature-auth--paseo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com:8443",
|
||||
port: 3001,
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
|
||||
|
||||
expect(routeStore.findRoute("api--feature-auth--paseo.services.example.com")).toBeNull();
|
||||
expect(routeStore.findRoute("api--feature-billing--paseo.services.example.com")).toEqual({
|
||||
hostname: "api--feature-billing--paseo.localhost",
|
||||
port: 3001,
|
||||
});
|
||||
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
|
||||
{
|
||||
hostname: "api--feature-billing--paseo.localhost",
|
||||
publicHostname: "api--feature-billing--paseo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com:8443",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "paseo",
|
||||
scriptName: "api",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("updates all services for a workspace when multiple routes are registered", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api.feature-auth.paseo.localhost",
|
||||
hostname: "api--feature-auth--paseo.localhost",
|
||||
port: 3001,
|
||||
scriptName: "api",
|
||||
});
|
||||
registerRoute(routeStore, {
|
||||
hostname: "web.feature-auth.paseo.localhost",
|
||||
hostname: "web--feature-auth--paseo.localhost",
|
||||
port: 3002,
|
||||
scriptName: "web",
|
||||
});
|
||||
registerRoute(routeStore, {
|
||||
hostname: "docs.docs-app.localhost",
|
||||
hostname: "docs--docs-app.localhost",
|
||||
port: 3003,
|
||||
workspaceId: "workspace-b",
|
||||
projectSlug: "docs-app",
|
||||
@@ -173,7 +215,7 @@ describe("script-route-branch-handler", () => {
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
@@ -181,14 +223,14 @@ describe("script-route-branch-handler", () => {
|
||||
|
||||
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
|
||||
{
|
||||
hostname: "api.feature-billing.paseo.localhost",
|
||||
hostname: "api--feature-billing--paseo.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "paseo",
|
||||
scriptName: "api",
|
||||
},
|
||||
{
|
||||
hostname: "web.feature-billing.paseo.localhost",
|
||||
hostname: "web--feature-billing--paseo.localhost",
|
||||
port: 3002,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "paseo",
|
||||
@@ -197,7 +239,7 @@ describe("script-route-branch-handler", () => {
|
||||
]);
|
||||
expect(routeStore.listRoutesForWorkspace("workspace-b")).toEqual([
|
||||
{
|
||||
hostname: "docs.docs-app.localhost",
|
||||
hostname: "docs--docs-app.localhost",
|
||||
port: 3003,
|
||||
workspaceId: "workspace-b",
|
||||
projectSlug: "docs-app",
|
||||
@@ -209,14 +251,14 @@ describe("script-route-branch-handler", () => {
|
||||
it("does not emit a status update when no changes are needed", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "web.paseo.localhost",
|
||||
hostname: "web--paseo.localhost",
|
||||
port: 3002,
|
||||
scriptName: "web",
|
||||
});
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
@@ -237,7 +279,7 @@ describe("script-route-branch-handler", () => {
|
||||
});
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api.feature-auth.repo.localhost",
|
||||
hostname: "api--feature-auth--repo.localhost",
|
||||
port: 3001,
|
||||
workspaceId: workspace.repoDir,
|
||||
projectSlug: "repo",
|
||||
@@ -246,7 +288,7 @@ describe("script-route-branch-handler", () => {
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
@@ -255,7 +297,7 @@ describe("script-route-branch-handler", () => {
|
||||
|
||||
expect(routeStore.listRoutesForWorkspace(workspace.repoDir)).toEqual([
|
||||
{
|
||||
hostname: "api.feature-billing.repo.localhost",
|
||||
hostname: "api--feature-billing--repo.localhost",
|
||||
port: 3001,
|
||||
workspaceId: workspace.repoDir,
|
||||
projectSlug: "repo",
|
||||
@@ -267,4 +309,99 @@ describe("script-route-branch-handler", () => {
|
||||
workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves existing local and public routes intact when a branch rename collides", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api--feature-auth--repo.localhost",
|
||||
publicHostname: "api--feature-auth--repo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api--feature-billing--repo.localhost",
|
||||
publicHostname: "api--feature-billing--repo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 4001,
|
||||
workspaceId: "workspace-b",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
expect(() => handleBranchChange("workspace-a", "feature/auth", "feature/billing")).toThrow(
|
||||
"Service proxy hostname collision",
|
||||
);
|
||||
|
||||
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
|
||||
{
|
||||
hostname: "api--feature-auth--repo.localhost",
|
||||
publicHostname: "api--feature-auth--repo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
},
|
||||
]);
|
||||
expect(routeStore.getRouteEntry("api--feature-auth--repo.services.example.com")).toMatchObject({
|
||||
workspaceId: "workspace-a",
|
||||
port: 3001,
|
||||
});
|
||||
expect(
|
||||
routeStore.getRouteEntry("api--feature-billing--repo.services.example.com"),
|
||||
).toMatchObject({
|
||||
workspaceId: "workspace-b",
|
||||
port: 4001,
|
||||
});
|
||||
expect(onRoutesChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves old routes intact when branch rename creates an internal incoming collision", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api--feature-one--repo.localhost",
|
||||
publicHostname: "api--feature-one--repo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "api--feature-two--repo.localhost",
|
||||
publicHostname: "api--feature-two--repo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 3002,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
expect(() => handleBranchChange("workspace-a", "feature/one", "feature/collide")).toThrow(
|
||||
"Service proxy hostname collision",
|
||||
);
|
||||
expect(routeStore.getRouteEntry("api--feature-one--repo.localhost")).toMatchObject({
|
||||
port: 3001,
|
||||
});
|
||||
expect(routeStore.getRouteEntry("api--feature-two--repo.localhost")).toMatchObject({
|
||||
port: 3002,
|
||||
});
|
||||
expect(routeStore.getRouteEntry("api--feature-collide--repo.localhost")).toBeNull();
|
||||
expect(onRoutesChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,68 +1,24 @@
|
||||
import type { Logger } from "pino";
|
||||
import { buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||
|
||||
interface BranchChangeRouteHandlerOptions {
|
||||
routeStore: ScriptRouteStore;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
onRoutesChanged: (workspaceId: string) => void;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
interface RouteHostnameUpdate {
|
||||
oldHostname: string;
|
||||
newHostname: string;
|
||||
route: ScriptRouteEntry;
|
||||
}
|
||||
|
||||
export function createBranchChangeRouteHandler(
|
||||
options: BranchChangeRouteHandlerOptions,
|
||||
): (workspaceId: string, oldBranch: string | null, newBranch: string | null) => void {
|
||||
return (workspaceId, _oldBranch, newBranch) => {
|
||||
// Only service scripts register routes, so branch renames only touch services.
|
||||
const routes = options.routeStore.listRoutesForWorkspace(workspaceId);
|
||||
if (routes.length === 0) {
|
||||
const changed = options.serviceProxy.replaceWorkspaceBranchRoutes({ workspaceId, newBranch });
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: RouteHostnameUpdate[] = [];
|
||||
for (const route of routes) {
|
||||
const newHostname = buildScriptHostname({
|
||||
projectSlug: route.projectSlug,
|
||||
branchName: newBranch,
|
||||
scriptName: route.scriptName,
|
||||
});
|
||||
if (newHostname !== route.hostname) {
|
||||
updates.push({
|
||||
oldHostname: route.hostname,
|
||||
newHostname,
|
||||
route,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { oldHostname, newHostname, route } of updates) {
|
||||
options.routeStore.removeRoute(oldHostname);
|
||||
options.routeStore.registerRoute({
|
||||
hostname: newHostname,
|
||||
port: route.port,
|
||||
workspaceId: route.workspaceId,
|
||||
projectSlug: route.projectSlug,
|
||||
scriptName: route.scriptName,
|
||||
});
|
||||
options.logger?.info(
|
||||
{
|
||||
oldHostname,
|
||||
newHostname,
|
||||
scriptName: route.scriptName,
|
||||
},
|
||||
"Updated script route for branch rename",
|
||||
);
|
||||
}
|
||||
|
||||
options.logger?.info(
|
||||
{ workspaceId, newBranch },
|
||||
"Updated service proxy routes for branch rename",
|
||||
);
|
||||
options.onRoutesChanged(workspaceId);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,15 +54,22 @@ function buildPayloads(input: {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
paseoConfig?: PaseoConfig | null;
|
||||
routeStore: ScriptRouteStore;
|
||||
routeStore?: ScriptRouteStore;
|
||||
serviceProxy?: ScriptRouteStore;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
daemonPort: number | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
gitMetadata?: { projectSlug: string; currentBranch: string | null };
|
||||
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
||||
}) {
|
||||
const paseoConfig =
|
||||
input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory);
|
||||
return buildWorkspaceScriptPayloads({ ...input, paseoConfig });
|
||||
const { routeStore, serviceProxy, ...rest } = input;
|
||||
return buildWorkspaceScriptPayloads({
|
||||
...rest,
|
||||
serviceProxy: serviceProxy ?? routeStore ?? new ScriptRouteStore(),
|
||||
paseoConfig,
|
||||
});
|
||||
}
|
||||
|
||||
function loadConfig(repoRoot: string): PaseoConfig | null {
|
||||
@@ -131,9 +138,11 @@ describe("script-status-projection", () => {
|
||||
{
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.repo.localhost",
|
||||
hostname: "web--repo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web.repo.localhost:6767",
|
||||
localProxyUrl: "http://web--repo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://web--repo.localhost:6767",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
@@ -162,7 +171,7 @@ describe("script-status-projection", () => {
|
||||
const payloads = buildPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory: workspace.repoDir,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
daemonPort: 6767,
|
||||
gitMetadata: {
|
||||
@@ -175,9 +184,54 @@ describe("script-status-projection", () => {
|
||||
{
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.feature-from-service.service-provided.localhost",
|
||||
hostname: "web--feature-from-service--service-provided.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web.feature-from-service.service-provided.localhost:6767",
|
||||
localProxyUrl: "http://web--feature-from-service--service-provided.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://web--feature-from-service--service-provided.localhost:6767",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
terminalId: null,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("projects local and public service URLs while keeping proxyUrl public-first", () => {
|
||||
const workspaceId = "workspace-public-service";
|
||||
const workspace = createWorkspaceRepo({
|
||||
paseoConfig: {
|
||||
scripts: {
|
||||
web: { type: "service", command: "npm run web", port: 3000 },
|
||||
},
|
||||
},
|
||||
});
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
|
||||
try {
|
||||
expect(
|
||||
buildPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory: workspace.repoDir,
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
daemonPort: 6767,
|
||||
serviceProxyPublicBaseUrl: "https://services.example.com",
|
||||
gitMetadata: { projectSlug: "repo", currentBranch: "feature/card" },
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web--feature-card--repo.localhost",
|
||||
port: 3000,
|
||||
localProxyUrl: "http://web--feature-card--repo.localhost:6767",
|
||||
publicProxyUrl: "https://web--feature-card--repo.services.example.com",
|
||||
proxyUrl: "https://web--feature-card--repo.services.example.com",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
@@ -201,7 +255,7 @@ describe("script-status-projection", () => {
|
||||
});
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.feature-card.repo.localhost",
|
||||
hostname: "web--feature-card--repo.localhost",
|
||||
port: 4321,
|
||||
workspaceId,
|
||||
projectSlug: "repo",
|
||||
@@ -231,9 +285,11 @@ describe("script-status-projection", () => {
|
||||
{
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.feature-card.repo.localhost",
|
||||
hostname: "web--feature-card--repo.localhost",
|
||||
port: 4321,
|
||||
proxyUrl: "http://web.feature-card.repo.localhost:6767",
|
||||
localProxyUrl: "http://web--feature-card--repo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://web--feature-card--repo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
@@ -256,7 +312,7 @@ describe("script-status-projection", () => {
|
||||
});
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.repo.localhost",
|
||||
hostname: "web--repo.localhost",
|
||||
port: 4321,
|
||||
workspaceId,
|
||||
projectSlug: "repo",
|
||||
@@ -286,9 +342,11 @@ describe("script-status-projection", () => {
|
||||
{
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.repo.localhost",
|
||||
hostname: "web--repo.localhost",
|
||||
port: 4321,
|
||||
proxyUrl: "http://web.repo.localhost:6767",
|
||||
localProxyUrl: "http://web--repo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://web--repo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
@@ -305,7 +363,7 @@ describe("script-status-projection", () => {
|
||||
const workspace = createWorkspaceRepo();
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "docs.repo.localhost",
|
||||
hostname: "docs--repo.localhost",
|
||||
port: 3002,
|
||||
workspaceId,
|
||||
projectSlug: "repo",
|
||||
@@ -334,9 +392,11 @@ describe("script-status-projection", () => {
|
||||
{
|
||||
scriptName: "docs",
|
||||
type: "service",
|
||||
hostname: "docs.repo.localhost",
|
||||
hostname: "docs--repo.localhost",
|
||||
port: 3002,
|
||||
proxyUrl: "http://docs.repo.localhost:6767",
|
||||
localProxyUrl: "http://docs--repo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://docs--repo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
@@ -462,7 +522,7 @@ describe("script-status-projection", () => {
|
||||
});
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.repo.localhost",
|
||||
hostname: "api--repo.localhost",
|
||||
port: 3001,
|
||||
workspaceId,
|
||||
projectSlug: "repo",
|
||||
@@ -481,7 +541,7 @@ describe("script-status-projection", () => {
|
||||
const session = { emit: vi.fn() };
|
||||
const emitUpdate = createScriptStatusEmitter({
|
||||
sessions: () => [session],
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
daemonPort: 6767,
|
||||
resolveWorkspaceDirectory: async (requestedWorkspaceId) =>
|
||||
@@ -493,7 +553,7 @@ describe("script-status-projection", () => {
|
||||
emitUpdate(workspaceId, [
|
||||
{
|
||||
scriptName: "api",
|
||||
hostname: "api.repo.localhost",
|
||||
hostname: "api--repo.localhost",
|
||||
port: 3001,
|
||||
health: "healthy",
|
||||
},
|
||||
@@ -508,9 +568,11 @@ describe("script-status-projection", () => {
|
||||
{
|
||||
scriptName: "api",
|
||||
type: "service",
|
||||
hostname: "api.repo.localhost",
|
||||
hostname: "api--repo.localhost",
|
||||
port: 3001,
|
||||
proxyUrl: "http://api.repo.localhost:6767",
|
||||
localProxyUrl: "http://api--repo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://api--repo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
|
||||
@@ -5,11 +5,13 @@ import type {
|
||||
WorkspaceScriptPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import { buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import { getScriptConfigs, isServiceScript, readPaseoConfig } from "../utils/worktree.js";
|
||||
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
|
||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
import type {
|
||||
ServiceProxySubsystem,
|
||||
ServiceProxyWorkspaceScriptProjection,
|
||||
} from "./service-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
|
||||
interface SessionEmitter {
|
||||
@@ -20,9 +22,10 @@ interface BuildWorkspaceScriptPayloadsOptions {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
paseoConfig: PaseoConfig | null;
|
||||
routeStore: ScriptRouteStore;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
daemonPort: number | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
gitMetadata?: {
|
||||
projectSlug: string;
|
||||
currentBranch: string | null;
|
||||
@@ -52,13 +55,6 @@ function resolveDaemonPort(daemonPort: number | null | (() => number | null)): n
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function toServiceProxyUrl(hostname: string, daemonPort: number | null): string | null {
|
||||
if (daemonPort === null) {
|
||||
return null;
|
||||
}
|
||||
return `http://${hostname}:${daemonPort}`;
|
||||
}
|
||||
|
||||
function toWireHealth(health: ScriptHealthState | null): WorkspaceScriptPayload["health"] {
|
||||
if (health === "pending" || health === null) {
|
||||
return null;
|
||||
@@ -76,43 +72,92 @@ function sortPayloads(payloads: WorkspaceScriptPayload[]): WorkspaceScriptPayloa
|
||||
}
|
||||
|
||||
type RuntimeEntry = ReturnType<WorkspaceScriptRuntimeStore["listForWorkspace"]>[number];
|
||||
type RouteEntry = ReturnType<ScriptRouteStore["listRoutesForWorkspace"]>[number];
|
||||
|
||||
interface BuildPayloadContext {
|
||||
projectSlug: string;
|
||||
branchName: string | null;
|
||||
daemonPort: number | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
||||
}
|
||||
|
||||
function projectWorkspaceServiceState(params: {
|
||||
workspaceId: string;
|
||||
scriptName: string;
|
||||
ctx: BuildPayloadContext;
|
||||
}): ServiceProxyWorkspaceScriptProjection {
|
||||
return params.ctx.serviceProxy.projectWorkspaceServiceState({
|
||||
workspaceId: params.workspaceId,
|
||||
projectSlug: params.ctx.projectSlug,
|
||||
branchName: params.ctx.branchName,
|
||||
scriptName: params.scriptName,
|
||||
daemonPort: params.ctx.daemonPort,
|
||||
publicBaseUrl: params.ctx.serviceProxyPublicBaseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
function buildConfiguredPlainScriptPayload(
|
||||
scriptName: string,
|
||||
runtimeEntry: RuntimeEntry | null,
|
||||
): WorkspaceScriptPayload {
|
||||
return {
|
||||
scriptName,
|
||||
type: "script",
|
||||
hostname: scriptName,
|
||||
port: null,
|
||||
proxyUrl: null,
|
||||
lifecycle: runtimeEntry?.lifecycle ?? "stopped",
|
||||
health: null,
|
||||
exitCode: runtimeEntry?.exitCode ?? null,
|
||||
terminalId: runtimeEntry?.terminalId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildConfiguredScriptPayload(
|
||||
scriptName: string,
|
||||
config: ReturnType<typeof getScriptConfigs> extends Map<string, infer V> ? V : never,
|
||||
runtimeEntry: RuntimeEntry | null,
|
||||
routeEntry: RouteEntry | null,
|
||||
serviceState: ServiceProxyWorkspaceScriptProjection | null,
|
||||
ctx: BuildPayloadContext,
|
||||
): WorkspaceScriptPayload {
|
||||
const configIsService = isServiceScript(config);
|
||||
const type = configIsService ? "service" : "script";
|
||||
const configuredPort = configIsService ? (config.port ?? null) : null;
|
||||
const hostname =
|
||||
type === "service"
|
||||
? (routeEntry?.hostname ??
|
||||
buildScriptHostname({
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName,
|
||||
}))
|
||||
: scriptName;
|
||||
if (!configIsService) {
|
||||
return buildConfiguredPlainScriptPayload(scriptName, runtimeEntry);
|
||||
}
|
||||
|
||||
const type = "service";
|
||||
const configuredPort = config.port ?? null;
|
||||
const hostname = (
|
||||
serviceState ??
|
||||
ctx.serviceProxy.projectWorkspaceService({
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName,
|
||||
daemonPort: ctx.daemonPort,
|
||||
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
|
||||
})
|
||||
).hostname;
|
||||
|
||||
const urls =
|
||||
serviceState ??
|
||||
ctx.serviceProxy.projectUrls({
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName,
|
||||
daemonPort: ctx.daemonPort,
|
||||
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
scriptName,
|
||||
type,
|
||||
hostname,
|
||||
port: type === "service" ? (routeEntry?.port ?? configuredPort) : null,
|
||||
proxyUrl: type === "service" ? toServiceProxyUrl(hostname, ctx.daemonPort) : null,
|
||||
port: serviceState?.port ?? configuredPort,
|
||||
localProxyUrl: urls.localProxyUrl,
|
||||
publicProxyUrl: urls.publicProxyUrl,
|
||||
proxyUrl: urls.proxyUrl,
|
||||
lifecycle: runtimeEntry?.lifecycle ?? "stopped",
|
||||
health: type === "service" ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null,
|
||||
health: toWireHealth(ctx.resolveHealth?.(hostname) ?? null),
|
||||
exitCode: runtimeEntry?.exitCode ?? null,
|
||||
terminalId: runtimeEntry?.terminalId ?? null,
|
||||
};
|
||||
@@ -120,28 +165,47 @@ function buildConfiguredScriptPayload(
|
||||
|
||||
function buildOrphanRuntimePayload(
|
||||
runtimeEntry: RuntimeEntry,
|
||||
routeEntry: RouteEntry | null,
|
||||
serviceState: ServiceProxyWorkspaceScriptProjection | null,
|
||||
ctx: BuildPayloadContext,
|
||||
): WorkspaceScriptPayload {
|
||||
const type = runtimeEntry.type;
|
||||
const hostname =
|
||||
type === "service"
|
||||
? (routeEntry?.hostname ??
|
||||
buildScriptHostname({
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName: runtimeEntry.scriptName,
|
||||
}))
|
||||
? (
|
||||
serviceState ??
|
||||
ctx.serviceProxy.projectWorkspaceService({
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName: runtimeEntry.scriptName,
|
||||
daemonPort: ctx.daemonPort,
|
||||
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
|
||||
})
|
||||
).hostname
|
||||
: runtimeEntry.scriptName;
|
||||
const urls =
|
||||
serviceState ??
|
||||
ctx.serviceProxy.projectUrls({
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName: runtimeEntry.scriptName,
|
||||
daemonPort: ctx.daemonPort,
|
||||
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
scriptName: runtimeEntry.scriptName,
|
||||
type,
|
||||
hostname,
|
||||
port: type === "service" ? (routeEntry?.port ?? null) : null,
|
||||
proxyUrl: type === "service" ? toServiceProxyUrl(hostname, ctx.daemonPort) : null,
|
||||
port: type === "service" ? (serviceState?.port ?? null) : null,
|
||||
...(type === "service"
|
||||
? { localProxyUrl: urls.localProxyUrl, publicProxyUrl: urls.publicProxyUrl }
|
||||
: {}),
|
||||
proxyUrl: type === "service" ? urls.proxyUrl : null,
|
||||
lifecycle: runtimeEntry.lifecycle,
|
||||
health:
|
||||
type === "service" && routeEntry ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null,
|
||||
type === "service" && serviceState?.port !== null
|
||||
? toWireHealth(ctx.resolveHealth?.(hostname) ?? null)
|
||||
: null,
|
||||
exitCode: runtimeEntry.exitCode,
|
||||
terminalId: runtimeEntry.terminalId,
|
||||
};
|
||||
@@ -160,16 +224,12 @@ export function buildWorkspaceScriptPayloads(
|
||||
.listForWorkspace(workspaceId)
|
||||
.map((entry) => [entry.scriptName, entry] as const),
|
||||
);
|
||||
const routesByScriptName = new Map(
|
||||
options.routeStore
|
||||
.listRoutesForWorkspace(workspaceId)
|
||||
.map((entry) => [entry.scriptName, entry] as const),
|
||||
);
|
||||
|
||||
const ctx: BuildPayloadContext = {
|
||||
projectSlug,
|
||||
branchName,
|
||||
daemonPort: options.daemonPort,
|
||||
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
|
||||
serviceProxy: options.serviceProxy,
|
||||
resolveHealth: options.resolveHealth,
|
||||
};
|
||||
|
||||
@@ -177,16 +237,23 @@ export function buildWorkspaceScriptPayloads(
|
||||
|
||||
for (const [scriptName, config] of scriptConfigs.entries()) {
|
||||
const runtimeEntry = runtimeEntries.get(scriptName) ?? null;
|
||||
const routeEntry = routesByScriptName.get(scriptName) ?? null;
|
||||
payloads.push(buildConfiguredScriptPayload(scriptName, config, runtimeEntry, routeEntry, ctx));
|
||||
const serviceState = isServiceScript(config)
|
||||
? projectWorkspaceServiceState({ workspaceId, scriptName, ctx })
|
||||
: null;
|
||||
payloads.push(
|
||||
buildConfiguredScriptPayload(scriptName, config, runtimeEntry, serviceState, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
for (const runtimeEntry of runtimeEntries.values()) {
|
||||
if (scriptConfigs.has(runtimeEntry.scriptName) || runtimeEntry.lifecycle !== "running") {
|
||||
continue;
|
||||
}
|
||||
const routeEntry = routesByScriptName.get(runtimeEntry.scriptName) ?? null;
|
||||
payloads.push(buildOrphanRuntimePayload(runtimeEntry, routeEntry, ctx));
|
||||
const serviceState =
|
||||
runtimeEntry.type === "service"
|
||||
? projectWorkspaceServiceState({ workspaceId, scriptName: runtimeEntry.scriptName, ctx })
|
||||
: null;
|
||||
payloads.push(buildOrphanRuntimePayload(runtimeEntry, serviceState, ctx));
|
||||
}
|
||||
|
||||
return sortPayloads(payloads);
|
||||
@@ -207,16 +274,18 @@ function buildScriptStatusUpdateMessage(params: {
|
||||
|
||||
export function createScriptStatusEmitter({
|
||||
sessions,
|
||||
routeStore,
|
||||
serviceProxy,
|
||||
runtimeStore,
|
||||
daemonPort,
|
||||
serviceProxyPublicBaseUrl,
|
||||
resolveWorkspaceDirectory,
|
||||
logger,
|
||||
}: {
|
||||
sessions: () => SessionEmitter[];
|
||||
routeStore: ScriptRouteStore;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
daemonPort: number | null | (() => number | null);
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise<string | null>;
|
||||
logger: Logger;
|
||||
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void {
|
||||
@@ -236,9 +305,10 @@ export function createScriptStatusEmitter({
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
|
||||
routeStore,
|
||||
serviceProxy,
|
||||
runtimeStore,
|
||||
daemonPort: resolvedDaemonPort,
|
||||
serviceProxyPublicBaseUrl,
|
||||
resolveHealth: (hostname) => scriptHealthByHostname.get(hostname) ?? null,
|
||||
});
|
||||
|
||||
|
||||
295
packages/server/src/server/service-proxy.test.ts
Normal file
295
packages/server/src/server/service-proxy.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import express from "express";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import pino from "pino";
|
||||
import {
|
||||
buildLocalServiceHostname,
|
||||
buildPublicServiceHostname,
|
||||
buildServiceProxyLabel,
|
||||
createServiceProxySubsystem,
|
||||
findFreePort,
|
||||
ServiceProxyRouteRegistry,
|
||||
} from "./service-proxy.js";
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
function readServerSourceFiles(dir = path.resolve(import.meta.dirname)): string[] {
|
||||
const entries: string[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const fullPath = path.join(dir, name);
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
entries.push(...readServerSourceFiles(fullPath));
|
||||
} else if (fullPath.endsWith(".ts") && !fullPath.endsWith(".test.ts")) {
|
||||
entries.push(fullPath);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function httpGet(port: number, host: string, requestPath = "/api/health") {
|
||||
return new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||
const req = http.get(
|
||||
{ hostname: "127.0.0.1", port, path: requestPath, headers: { host } },
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("service proxy subsystem shape", () => {
|
||||
it("keeps production imports behind the service-proxy entrypoint", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const filePath of readServerSourceFiles()) {
|
||||
if (filePath.endsWith("service-proxy.ts") || filePath.endsWith("script-proxy.ts")) {
|
||||
continue;
|
||||
}
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
for (const needle of ["./script-proxy.js", "../utils/script-hostname.js"]) {
|
||||
if (source.includes(needle)) {
|
||||
offenders.push(`${path.relative(import.meta.dirname, filePath)} imports ${needle}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("classifies the configured public namespace before any route exists", async () => {
|
||||
const serviceProxy = createServiceProxySubsystem({
|
||||
logger,
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
});
|
||||
const port = await findFreePort();
|
||||
const app = express();
|
||||
app.use(serviceProxy.middleware());
|
||||
app.use((_req, res) => {
|
||||
res.status(200).send("daemon-api");
|
||||
});
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
|
||||
try {
|
||||
await expect(httpGet(port, `missing.services.example.com:${port}`)).resolves.toEqual({
|
||||
status: 404,
|
||||
body: "404 Not Found",
|
||||
});
|
||||
await expect(httpGet(port, `daemon.localhost:${port}`)).resolves.toEqual({
|
||||
status: 200,
|
||||
body: "daemon-api",
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps configured public namespace classified after the last public route is removed", async () => {
|
||||
const serviceProxy = createServiceProxySubsystem({
|
||||
logger,
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
});
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: 3000,
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
});
|
||||
serviceProxy.removeWorkspaceService({ workspaceId: "workspace-a", scriptName: "api" });
|
||||
|
||||
const port = await findFreePort();
|
||||
const app = express();
|
||||
app.use(serviceProxy.middleware());
|
||||
app.use((_req, res) => {
|
||||
res.status(200).send("daemon-api");
|
||||
});
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
|
||||
try {
|
||||
await expect(httpGet(port, `missing.services.example.com:${port}`)).resolves.toEqual({
|
||||
status: 404,
|
||||
body: "404 Not Found",
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the same hash-truncated service label for local and public hostnames", () => {
|
||||
const input = {
|
||||
projectSlug: "project-".repeat(10),
|
||||
branchName: "branch-".repeat(10),
|
||||
scriptName: "script-".repeat(10),
|
||||
};
|
||||
const label = buildServiceProxyLabel(input);
|
||||
|
||||
expect(label.length).toBeLessThanOrEqual(63);
|
||||
expect(label.endsWith("-")).toBe(false);
|
||||
expect(buildLocalServiceHostname(input)).toBe(`${label}.localhost`);
|
||||
expect(
|
||||
buildPublicServiceHostname({ ...input, publicBaseUrl: "https://services.example.com" }),
|
||||
).toBe(`${label}.services.example.com`);
|
||||
expect(buildServiceProxyLabel(input)).toBe(label);
|
||||
expect(
|
||||
buildServiceProxyLabel({ ...input, scriptName: `different-${input.scriptName}` }),
|
||||
).not.toBe(label);
|
||||
});
|
||||
|
||||
it("gives long labels with the same prefix different hash suffixes", () => {
|
||||
const sharedPrefix = "service-".repeat(12);
|
||||
const first = buildServiceProxyLabel({
|
||||
projectSlug: "repo",
|
||||
branchName: "feature/shared-prefix",
|
||||
scriptName: `${sharedPrefix}alpha`,
|
||||
});
|
||||
const second = buildServiceProxyLabel({
|
||||
projectSlug: "repo",
|
||||
branchName: "feature/shared-prefix",
|
||||
scriptName: `${sharedPrefix}beta`,
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first.slice(0, -10)).toBe(second.slice(0, -10));
|
||||
expect(first.split("--").at(-1)).not.toBe(second.split("--").at(-1));
|
||||
});
|
||||
|
||||
it("rejects cross-service collisions without deleting the existing route", () => {
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: 3000,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-b",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: 4000,
|
||||
}),
|
||||
).toThrow("Service proxy hostname collision");
|
||||
|
||||
expect(serviceProxy.getHealthTargetForHostname("api--repo.localhost")).toMatchObject({
|
||||
workspaceId: "workspace-a",
|
||||
port: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects public alias collisions without deleting the existing route", () => {
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: 3000,
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-b",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: 4000,
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
}),
|
||||
).toThrow("Service proxy hostname collision");
|
||||
|
||||
expect(serviceProxy.getHealthTargetForHostname("api--repo.services.example.com")).toMatchObject(
|
||||
{
|
||||
workspaceId: "workspace-a",
|
||||
port: 3000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects public alias collisions even when canonical hostnames differ", () => {
|
||||
const serviceProxy = new ServiceProxyRouteRegistry();
|
||||
serviceProxy.registerRoute({
|
||||
hostname: "api--repo-a.localhost",
|
||||
publicHostname: "api.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 3000,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo-a",
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
serviceProxy.registerRoute({
|
||||
hostname: "api--repo-b.localhost",
|
||||
publicHostname: "api.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 4000,
|
||||
workspaceId: "workspace-b",
|
||||
projectSlug: "repo-b",
|
||||
scriptName: "api",
|
||||
}),
|
||||
).toThrow("Service proxy hostname collision");
|
||||
|
||||
expect(serviceProxy.getRouteEntry("api--repo-a.localhost")).toMatchObject({ port: 3000 });
|
||||
expect(serviceProxy.getRouteEntry("api--repo-b.localhost")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects canonical-to-public-alias collisions", () => {
|
||||
const serviceProxy = new ServiceProxyRouteRegistry();
|
||||
serviceProxy.registerRoute({
|
||||
hostname: "api--repo.localhost",
|
||||
publicHostname: "api.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 3000,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
serviceProxy.registerRoute({
|
||||
hostname: "api.services.example.com",
|
||||
port: 4000,
|
||||
workspaceId: "workspace-b",
|
||||
projectSlug: "other",
|
||||
scriptName: "api",
|
||||
}),
|
||||
).toThrow("Service proxy hostname collision");
|
||||
|
||||
expect(serviceProxy.getRouteEntry("api--repo.localhost")).toMatchObject({ port: 3000 });
|
||||
expect(serviceProxy.getRouteEntry("api.services.example.com")).toMatchObject({ port: 3000 });
|
||||
});
|
||||
|
||||
it("allows same workspace/script replacement", () => {
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: 3000,
|
||||
});
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: 4000,
|
||||
});
|
||||
|
||||
expect(serviceProxy.getHealthTargetForHostname("api--repo.localhost")).toMatchObject({
|
||||
port: 4000,
|
||||
});
|
||||
});
|
||||
});
|
||||
1082
packages/server/src/server/service-proxy.ts
Normal file
1082
packages/server/src/server/service-proxy.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -229,7 +229,7 @@ interface SessionForTestOptions {
|
||||
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
|
||||
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
|
||||
terminalManager?: SessionOptions["terminalManager"];
|
||||
scriptRouteStore?: SessionOptions["scriptRouteStore"];
|
||||
serviceProxy?: SessionOptions["serviceProxy"];
|
||||
scriptRuntimeStore?: SessionOptions["scriptRuntimeStore"];
|
||||
getDaemonTcpPort?: () => number | null;
|
||||
getDaemonTcpHost?: () => string | null;
|
||||
@@ -311,7 +311,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
terminalManager: options.terminalManager ?? null,
|
||||
providerSnapshotManager:
|
||||
options.providerSnapshotManager ?? createProviderSnapshotManagerStub().manager,
|
||||
scriptRouteStore: options.scriptRouteStore,
|
||||
serviceProxy: options.serviceProxy,
|
||||
scriptRuntimeStore: options.scriptRuntimeStore,
|
||||
getDaemonTcpPort: options.getDaemonTcpPort,
|
||||
getDaemonTcpHost: options.getDaemonTcpHost,
|
||||
@@ -3549,7 +3549,7 @@ describe("session workspace script handling", () => {
|
||||
workspaceGitService,
|
||||
workspaceRegistry,
|
||||
terminalManager: { subscribeTerminalsChanged: vi.fn(() => () => {}) },
|
||||
scriptRouteStore: { listRoutesForWorkspace: vi.fn(() => []) },
|
||||
serviceProxy: { listRoutesForWorkspace: vi.fn(() => []) },
|
||||
scriptRuntimeStore: { listForWorkspace: vi.fn(() => []) },
|
||||
getDaemonTcpPort: () => 6767,
|
||||
getDaemonTcpHost: () => "127.0.0.1",
|
||||
|
||||
@@ -192,7 +192,7 @@ import {
|
||||
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
|
||||
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import {
|
||||
checkoutResolvedBranch,
|
||||
type CheckoutExistingBranchResult,
|
||||
@@ -569,6 +569,7 @@ export interface SessionOptions {
|
||||
downloadTokenStore: DownloadTokenStore;
|
||||
pushTokenStore: PushTokenStore;
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
projectRegistry: ProjectRegistry;
|
||||
@@ -587,7 +588,7 @@ export interface SessionOptions {
|
||||
tts: Resolvable<TextToSpeechProvider | null>;
|
||||
terminalManager: TerminalManager | null;
|
||||
providerSnapshotManager: ProviderSnapshotManager;
|
||||
scriptRouteStore?: ScriptRouteStore;
|
||||
serviceProxy?: ServiceProxySubsystem;
|
||||
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
|
||||
workspaceSetupSnapshots?: Map<string, WorkspaceSetupSnapshot>;
|
||||
onBranchChanged?: (
|
||||
@@ -597,6 +598,7 @@ export interface SessionOptions {
|
||||
) => void;
|
||||
getDaemonTcpPort?: () => number | null;
|
||||
getDaemonTcpHost?: () => string | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null;
|
||||
voice?: {
|
||||
turnDetection?: Resolvable<TurnDetectionProvider | null>;
|
||||
@@ -742,6 +744,7 @@ export class Session {
|
||||
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
|
||||
private readonly sessionLogger: pino.Logger;
|
||||
private readonly paseoHome: string;
|
||||
private readonly worktreesRoot: string | undefined;
|
||||
|
||||
// State machine
|
||||
private abortController: AbortController;
|
||||
@@ -800,7 +803,7 @@ export class Session {
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private readonly providerSnapshotManager: ProviderSnapshotManager;
|
||||
private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
|
||||
private readonly scriptRouteStore: ScriptRouteStore | null;
|
||||
private readonly serviceProxy: ServiceProxySubsystem | null;
|
||||
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||
private readonly onBranchChanged?: (
|
||||
workspaceId: string,
|
||||
@@ -809,6 +812,7 @@ export class Session {
|
||||
) => void;
|
||||
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||||
private readonly getDaemonTcpHost: (() => string | null) | null;
|
||||
private readonly serviceProxyPublicBaseUrl: string | null;
|
||||
private readonly resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null;
|
||||
private readonly terminalController: TerminalSessionController;
|
||||
private inflightRequests = 0;
|
||||
@@ -859,6 +863,7 @@ export class Session {
|
||||
downloadTokenStore,
|
||||
pushTokenStore,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
agentManager,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
@@ -876,12 +881,13 @@ export class Session {
|
||||
tts,
|
||||
terminalManager,
|
||||
providerSnapshotManager,
|
||||
scriptRouteStore,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
workspaceSetupSnapshots,
|
||||
onBranchChanged,
|
||||
getDaemonTcpPort,
|
||||
getDaemonTcpHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
resolveScriptHealth,
|
||||
voice,
|
||||
voiceBridge,
|
||||
@@ -900,6 +906,7 @@ export class Session {
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.pushTokenStore = pushTokenStore;
|
||||
this.paseoHome = paseoHome;
|
||||
this.worktreesRoot = worktreesRoot;
|
||||
this.sessionLogger = logger.child({
|
||||
module: "session",
|
||||
clientId: this.clientId,
|
||||
@@ -930,6 +937,7 @@ export class Session {
|
||||
});
|
||||
this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
github: this.github,
|
||||
@@ -958,12 +966,13 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.providerSnapshotManager = providerSnapshotManager;
|
||||
this.scriptRouteStore = scriptRouteStore ?? null;
|
||||
this.serviceProxy = serviceProxy ?? null;
|
||||
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
|
||||
this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map();
|
||||
this.onBranchChanged = onBranchChanged;
|
||||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
|
||||
this.serviceProxyPublicBaseUrl = serviceProxyPublicBaseUrl ?? null;
|
||||
this.resolveScriptHealth = resolveScriptHealth ?? null;
|
||||
this.sttLanguage = sttLanguage ?? "en";
|
||||
this.subscribeToOptionalManagers();
|
||||
@@ -3111,6 +3120,7 @@ export class Session {
|
||||
agentStorage: this.agentStorage,
|
||||
logger: this.sessionLogger,
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
providerSnapshotManager: this.providerSnapshotManager,
|
||||
daemonConfig: this.readStructuredGenerationDaemonConfig(),
|
||||
@@ -3491,6 +3501,7 @@ export class Session {
|
||||
return buildWorktreeAgentSessionConfig(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
sessionLogger: this.sessionLogger,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
createPaseoWorktree: (input, serviceOptions) =>
|
||||
@@ -5230,7 +5241,7 @@ export class Session {
|
||||
baseRef,
|
||||
mode: msg.strategy === "squash" ? "squash" : "merge",
|
||||
},
|
||||
{ paseoHome: this.paseoHome },
|
||||
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot },
|
||||
);
|
||||
await Promise.all([
|
||||
this.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }),
|
||||
@@ -5711,6 +5722,7 @@ export class Session {
|
||||
return handleWorktreeArchiveRequest(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
github: this.github,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
agentManager: this.agentManager,
|
||||
@@ -6283,14 +6295,15 @@ export class Session {
|
||||
activityAt: null,
|
||||
diffStat,
|
||||
scripts:
|
||||
this.scriptRouteStore && this.scriptRuntimeStore
|
||||
this.serviceProxy && this.scriptRuntimeStore
|
||||
? buildWorkspaceScriptPayloads({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.cwd,
|
||||
paseoConfig: readPaseoConfigForProjection(workspace.cwd, this.sessionLogger),
|
||||
routeStore: this.scriptRouteStore,
|
||||
serviceProxy: this.serviceProxy,
|
||||
runtimeStore: this.scriptRuntimeStore,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspace.cwd),
|
||||
resolveHealth: this.resolveScriptHealth ?? undefined,
|
||||
})
|
||||
@@ -7111,16 +7124,17 @@ export class Session {
|
||||
workspaceId: string,
|
||||
workspaceDirectory: string,
|
||||
): WorkspaceDescriptorPayload["scripts"] {
|
||||
if (!this.scriptRouteStore || !this.scriptRuntimeStore) {
|
||||
if (!this.serviceProxy || !this.scriptRuntimeStore) {
|
||||
return [];
|
||||
}
|
||||
return buildWorkspaceScriptPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, this.sessionLogger),
|
||||
routeStore: this.scriptRouteStore,
|
||||
serviceProxy: this.serviceProxy,
|
||||
runtimeStore: this.scriptRuntimeStore,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspaceDirectory),
|
||||
resolveHealth: this.resolveScriptHealth ?? undefined,
|
||||
});
|
||||
@@ -7168,7 +7182,7 @@ export class Session {
|
||||
request: StartWorkspaceScriptRequest,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!this.terminalManager || !this.scriptRouteStore || !this.scriptRuntimeStore) {
|
||||
if (!this.terminalManager || !this.serviceProxy || !this.scriptRuntimeStore) {
|
||||
throw new Error("Workspace scripts are not available on this daemon");
|
||||
}
|
||||
|
||||
@@ -7186,7 +7200,8 @@ export class Session {
|
||||
scriptName: request.scriptName,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
|
||||
routeStore: this.scriptRouteStore,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
serviceProxy: this.serviceProxy,
|
||||
runtimeStore: this.scriptRuntimeStore,
|
||||
terminalManager: this.terminalManager,
|
||||
logger: this.sessionLogger,
|
||||
@@ -7298,6 +7313,7 @@ export class Session {
|
||||
return handleCreateWorktreeRequest(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result),
|
||||
emit: (message) => this.emit(message),
|
||||
sessionLogger: this.sessionLogger,
|
||||
@@ -7317,6 +7333,7 @@ export class Session {
|
||||
return createWorktreeWorkflow(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
createPaseoWorktree: (workflowInput, serviceOptions) =>
|
||||
this.createPaseoWorktree(workflowInput, serviceOptions),
|
||||
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
|
||||
@@ -7331,10 +7348,11 @@ export class Session {
|
||||
sessionLogger: this.sessionLogger,
|
||||
terminalManager: this.terminalManager,
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
scriptRouteStore: this.scriptRouteStore,
|
||||
serviceProxy: this.serviceProxy,
|
||||
scriptRuntimeStore: this.scriptRuntimeStore,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort,
|
||||
getDaemonTcpHost: this.getDaemonTcpHost,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
onScriptsChanged: (workspaceId, workspaceDirectory) => {
|
||||
this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory);
|
||||
},
|
||||
|
||||
@@ -2,10 +2,11 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import path from "node:path";
|
||||
import type pino from "pino";
|
||||
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
|
||||
import { ScriptRouteStore } from "./script-proxy.js";
|
||||
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import { Session, type SessionOptions } from "./session.js";
|
||||
import { asInternals, createStub } from "./test-utils/class-mocks.js";
|
||||
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import type {
|
||||
WorkspaceGitListener,
|
||||
@@ -102,7 +103,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
||||
oldBranch: string | null,
|
||||
newBranch: string | null,
|
||||
) => void;
|
||||
scriptRouteStore?: ScriptRouteStore;
|
||||
serviceProxy?: ServiceProxySubsystem;
|
||||
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
|
||||
}): {
|
||||
session: Session;
|
||||
@@ -234,7 +235,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
||||
tts: null,
|
||||
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
||||
terminalManager: null,
|
||||
scriptRouteStore: options?.scriptRouteStore,
|
||||
serviceProxy: options?.serviceProxy,
|
||||
scriptRuntimeStore: options?.scriptRuntimeStore,
|
||||
onBranchChanged: options?.onBranchChanged,
|
||||
getDaemonTcpPort: () => 6767,
|
||||
@@ -425,12 +426,12 @@ describe("workspace git watch targets", () => {
|
||||
});
|
||||
|
||||
test("updates running service script URLs when the git branch changes", async () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "app.old-branch.paseo.localhost",
|
||||
const serviceProxy = createServiceProxySubsystem({ logger: createTestLogger() });
|
||||
serviceProxy.registerWorkspaceService({
|
||||
port: 4321,
|
||||
workspaceId: "ws-10",
|
||||
projectSlug: "paseo",
|
||||
branchName: "old-branch",
|
||||
scriptName: "app",
|
||||
});
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
@@ -444,12 +445,12 @@ describe("workspace git watch targets", () => {
|
||||
});
|
||||
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy,
|
||||
onRoutesChanged: vi.fn(),
|
||||
});
|
||||
const { session, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(
|
||||
{
|
||||
scriptRouteStore: routeStore,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore: runtimeStore,
|
||||
onBranchChanged: handleBranchChange,
|
||||
},
|
||||
@@ -474,18 +475,19 @@ describe("workspace git watch targets", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(routeStore.listRoutesForWorkspace("ws-10")).toEqual([
|
||||
expect(serviceProxy.getWorkspaceHealthTargets("ws-10")).toEqual([
|
||||
expect.objectContaining({
|
||||
hostname: "app.new-branch.paseo.localhost",
|
||||
projectSlug: "paseo",
|
||||
hostname: "app--new-branch--paseo.localhost",
|
||||
scriptName: "app",
|
||||
}),
|
||||
]);
|
||||
expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([
|
||||
expect.objectContaining({
|
||||
scriptName: "app",
|
||||
hostname: "app.new-branch.paseo.localhost",
|
||||
proxyUrl: "http://app.new-branch.paseo.localhost:6767",
|
||||
hostname: "app--new-branch--paseo.localhost",
|
||||
localProxyUrl: "http://app--new-branch--paseo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://app--new-branch--paseo.localhost:6767",
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ interface TestPaseoDaemonOptions {
|
||||
dictationFinalTimeoutMs?: number;
|
||||
auth?: PaseoDaemonConfig["auth"];
|
||||
pushNotificationSender?: PushNotificationSender;
|
||||
serviceProxy?: PaseoDaemonConfig["serviceProxy"];
|
||||
}
|
||||
|
||||
export interface TestPaseoDaemon {
|
||||
@@ -161,6 +162,7 @@ async function prepareTestDaemonConfig(
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
auth: options.auth,
|
||||
pushNotificationSender: options.pushNotificationSender,
|
||||
serviceProxy: options.serviceProxy,
|
||||
openai: options.openai,
|
||||
speech: options.speech,
|
||||
voiceLlmProvider: options.voiceLlmProvider ?? null,
|
||||
|
||||
@@ -92,10 +92,10 @@ export function asWorkspaceGitService(stub: {
|
||||
return createStub<SessionOptions["workspaceGitService"]>(stub);
|
||||
}
|
||||
|
||||
export function asScriptRouteStore(stub: {
|
||||
[K in keyof SessionOptions["scriptRouteStore"]]?: unknown;
|
||||
}): SessionOptions["scriptRouteStore"] {
|
||||
return createStub<SessionOptions["scriptRouteStore"]>(stub);
|
||||
export function asServiceProxy(stub: {
|
||||
[K in keyof SessionOptions["serviceProxy"]]?: unknown;
|
||||
}): SessionOptions["serviceProxy"] {
|
||||
return createStub<SessionOptions["serviceProxy"]>(stub);
|
||||
}
|
||||
|
||||
export function asWorkspaceScriptRuntimeStore(stub: {
|
||||
|
||||
@@ -35,7 +35,7 @@ import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
|
||||
import type { ScriptHealthState } from "./script-health-monitor.js";
|
||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
||||
@@ -350,16 +350,18 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly workspaceGitService: WorkspaceGitService;
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly paseoHome: string;
|
||||
private readonly worktreesRoot: string | undefined;
|
||||
private readonly daemonConfigStore: DaemonConfigStore;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
private readonly pushNotificationSender: PushNotificationSender;
|
||||
private readonly mcpBaseUrl: string | null;
|
||||
private speech!: SpeechService | null;
|
||||
private terminalManager!: TerminalManager | null;
|
||||
private scriptRouteStore!: ScriptRouteStore | null;
|
||||
private serviceProxy!: ServiceProxySubsystem | null;
|
||||
private scriptRuntimeStore!: WorkspaceScriptRuntimeStore | null;
|
||||
private getDaemonTcpPort!: (() => number | null) | null;
|
||||
private getDaemonTcpHost!: (() => string | null) | null;
|
||||
private serviceProxyPublicBaseUrl!: string | null;
|
||||
private resolveScriptHealth!: ((hostname: string) => ScriptHealthState | null) | null;
|
||||
private dictation!: {
|
||||
finalTimeoutMs?: number;
|
||||
@@ -403,7 +405,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
loopService?: LoopService,
|
||||
scheduleService?: ScheduleService,
|
||||
checkoutDiffManager?: CheckoutDiffManager,
|
||||
scriptRouteStore?: ScriptRouteStore | null,
|
||||
serviceProxy?: ServiceProxySubsystem | null,
|
||||
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null,
|
||||
onBranchChanged?: (
|
||||
workspaceId: string,
|
||||
@@ -419,6 +421,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
providerSnapshotManager?: ProviderSnapshotManager,
|
||||
daemonRuntimeConfig?: {
|
||||
listen: string | null;
|
||||
worktreesRoot?: string;
|
||||
relay: {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
@@ -427,6 +430,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
publicUseTls: boolean;
|
||||
};
|
||||
},
|
||||
serviceProxyPublicBaseUrl?: string | null,
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.serverId = serverId;
|
||||
@@ -453,6 +457,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService();
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.paseoHome = paseoHome;
|
||||
this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
|
||||
this.daemonConfigStore = daemonConfigStore;
|
||||
this.mcpBaseUrl = mcpBaseUrl;
|
||||
this.assignOptionalServices({
|
||||
@@ -460,11 +465,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
terminalManager,
|
||||
dictation,
|
||||
onLifecycleIntent,
|
||||
scriptRouteStore,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
onBranchChanged,
|
||||
getDaemonTcpPort,
|
||||
getDaemonTcpHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
resolveScriptHealth,
|
||||
});
|
||||
if (!providerSnapshotManager) {
|
||||
@@ -508,24 +514,26 @@ export class VoiceAssistantWebSocketServer {
|
||||
terminalManager: TerminalManager | null | undefined;
|
||||
dictation: { finalTimeoutMs?: number } | undefined;
|
||||
onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | undefined;
|
||||
scriptRouteStore: ScriptRouteStore | null | undefined;
|
||||
serviceProxy: ServiceProxySubsystem | null | undefined;
|
||||
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null | undefined;
|
||||
onBranchChanged:
|
||||
| ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void)
|
||||
| undefined;
|
||||
getDaemonTcpPort: (() => number | null) | undefined;
|
||||
getDaemonTcpHost: (() => string | null) | undefined;
|
||||
serviceProxyPublicBaseUrl: string | null | undefined;
|
||||
resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | undefined;
|
||||
}): void {
|
||||
this.speech = params.speech ?? null;
|
||||
this.terminalManager = params.terminalManager ?? null;
|
||||
this.dictation = params.dictation ?? null;
|
||||
this.onLifecycleIntent = params.onLifecycleIntent ?? null;
|
||||
this.scriptRouteStore = params.scriptRouteStore ?? null;
|
||||
this.serviceProxy = params.serviceProxy ?? null;
|
||||
this.scriptRuntimeStore = params.scriptRuntimeStore ?? null;
|
||||
this.onBranchChanged = params.onBranchChanged ?? null;
|
||||
this.getDaemonTcpPort = params.getDaemonTcpPort ?? null;
|
||||
this.getDaemonTcpHost = params.getDaemonTcpHost ?? null;
|
||||
this.serviceProxyPublicBaseUrl = params.serviceProxyPublicBaseUrl ?? null;
|
||||
this.resolveScriptHealth = params.resolveScriptHealth ?? null;
|
||||
}
|
||||
|
||||
@@ -858,6 +866,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
downloadTokenStore: this.downloadTokenStore,
|
||||
pushTokenStore: this.pushTokenStore,
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
projectRegistry: this.projectRegistry,
|
||||
@@ -875,12 +884,13 @@ export class VoiceAssistantWebSocketServer {
|
||||
tts: () => this.speech?.resolveTts() ?? null,
|
||||
terminalManager: this.terminalManager,
|
||||
providerSnapshotManager: this.providerSnapshotManager,
|
||||
scriptRouteStore: this.scriptRouteStore ?? undefined,
|
||||
serviceProxy: this.serviceProxy ?? undefined,
|
||||
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
|
||||
workspaceSetupSnapshots: this.workspaceSetupSnapshots,
|
||||
onBranchChanged: this.onBranchChanged ?? undefined,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
|
||||
getDaemonTcpHost: this.getDaemonTcpHost ?? undefined,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
resolveScriptHealth: this.resolveScriptHealth ?? undefined,
|
||||
voice: {
|
||||
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
|
||||
|
||||
153
packages/server/src/server/workspace-directory.test.ts
Normal file
153
packages/server/src/server/workspace-directory.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
import type { AgentSnapshotPayload, WorkspaceDescriptorPayload } from "./messages.js";
|
||||
import { WorkspaceDirectory } from "./workspace-directory.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
|
||||
const NOW = "2026-03-01T12:00:00.000Z";
|
||||
|
||||
class WorkspaceStatus {
|
||||
private readonly project: PersistedProjectRecord = {
|
||||
projectId: "project-1",
|
||||
rootPath: "/workspace/project",
|
||||
kind: "git",
|
||||
displayName: "project",
|
||||
customName: null,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
};
|
||||
|
||||
private readonly workspace: PersistedWorkspaceRecord = {
|
||||
workspaceId: "workspace-1",
|
||||
projectId: this.project.projectId,
|
||||
cwd: this.project.rootPath,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
};
|
||||
|
||||
private readonly agents: AgentSnapshotPayload[] = [];
|
||||
private readonly directory = new WorkspaceDirectory({
|
||||
logger: createTestLogger(),
|
||||
projectRegistry: { list: async () => [this.project] },
|
||||
workspaceRegistry: { list: async () => [this.workspace] },
|
||||
listAgentPayloads: async () => this.agents,
|
||||
isProviderVisibleToClient: () => true,
|
||||
buildWorkspaceDescriptor: async ({ workspace }) => ({
|
||||
id: workspace.workspaceId,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: "project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: this.project.rootPath,
|
||||
workspaceDirectory: workspace.cwd,
|
||||
projectKind: "git",
|
||||
workspaceKind: workspace.kind,
|
||||
name: workspace.displayName,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
gitRuntime: null,
|
||||
githubRuntime: null,
|
||||
}),
|
||||
});
|
||||
|
||||
hasRootAgent(input: AgentState): void {
|
||||
this.agents.push(createAgent({ ...input, cwd: this.workspace.cwd }));
|
||||
}
|
||||
|
||||
hasDelegatedAgent(input: AgentState): void {
|
||||
this.agents.push(
|
||||
createAgent({
|
||||
...input,
|
||||
cwd: this.workspace.cwd,
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async workspaceStatus(): Promise<WorkspaceDescriptorPayload["status"]> {
|
||||
const entries = await this.directory.listFetchEntries({
|
||||
type: "fetch_workspaces_request",
|
||||
requestId: "workspace-status",
|
||||
});
|
||||
return entries.entries[0]?.status ?? "done";
|
||||
}
|
||||
}
|
||||
|
||||
interface AgentState {
|
||||
id: string;
|
||||
status: AgentSnapshotPayload["status"];
|
||||
pendingPermissionCount?: number;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: AgentSnapshotPayload["attentionReason"];
|
||||
}
|
||||
|
||||
function createAgent(input: AgentState & { cwd: string; labels?: Record<string, string> }) {
|
||||
const pendingPermissionCount = input.pendingPermissionCount ?? 0;
|
||||
return {
|
||||
id: input.id,
|
||||
provider: "codex",
|
||||
cwd: input.cwd,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
lastUserMessageAt: null,
|
||||
status: input.status,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: Array.from({ length: pendingPermissionCount }, (_, index) => ({
|
||||
id: `permission-${input.id}-${index}`,
|
||||
provider: "codex",
|
||||
name: "tool",
|
||||
kind: "tool" as const,
|
||||
})),
|
||||
persistence: null,
|
||||
runtimeInfo: {
|
||||
provider: "codex",
|
||||
sessionId: null,
|
||||
},
|
||||
title: null,
|
||||
labels: input.labels ?? {},
|
||||
requiresAttention: input.requiresAttention ?? false,
|
||||
attentionReason: input.attentionReason ?? null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
} satisfies AgentSnapshotPayload;
|
||||
}
|
||||
|
||||
describe("WorkspaceDirectory", () => {
|
||||
test("uses root agent activity, not delegated child activity, for workspace status", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
workspace.hasRootAgent({ id: "root-agent", status: "running" });
|
||||
workspace.hasDelegatedAgent({
|
||||
id: "child-needs-input",
|
||||
status: "idle",
|
||||
pendingPermissionCount: 1,
|
||||
});
|
||||
workspace.hasDelegatedAgent({
|
||||
id: "child-error",
|
||||
status: "error",
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
});
|
||||
|
||||
await expect(workspace.workspaceStatus()).resolves.toBe("running");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
deriveAgentStateBucket,
|
||||
getWorkspaceStateBucketPriority,
|
||||
} from "@getpaseo/protocol/agent-state-bucket";
|
||||
import { isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
|
||||
import { SortablePager } from "./pagination/sortable-pager.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||
@@ -187,6 +188,9 @@ export class WorkspaceDirectory {
|
||||
if (!this.deps.isProviderVisibleToClient(agent.provider)) {
|
||||
continue;
|
||||
}
|
||||
if (isDelegatedAgent(agent)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd));
|
||||
if (workspaceId === undefined) {
|
||||
|
||||
@@ -879,6 +879,53 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
github.dispose?.();
|
||||
});
|
||||
|
||||
test("GitHub self-heal polling uses the fork PR head branch instead of the owner-prefixed local branch", async () => {
|
||||
const retainCurrentPullRequestStatusPoll = vi.fn(() => ({ unsubscribe: vi.fn() }));
|
||||
const github = {
|
||||
...createGitHubServiceStub(),
|
||||
retainCurrentPullRequestStatusPoll,
|
||||
};
|
||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) =>
|
||||
createCheckoutFacts(cwd, {
|
||||
currentBranch: "fork-owner/open-button-targets-active-file",
|
||||
branchRemoteName: "paseo-pr-1285",
|
||||
branchMergeRef: "refs/heads/open-button-targets-active-file",
|
||||
trackedOriginBranch: "paseo-pr-1285/open-button-targets-active-file",
|
||||
pullRequestLookupTarget: {
|
||||
headRef: "open-button-targets-active-file",
|
||||
headRepositoryOwner: "fork-owner",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) =>
|
||||
createCheckoutStatus(cwd, {
|
||||
currentBranch: "fork-owner/open-button-targets-active-file",
|
||||
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
||||
}),
|
||||
);
|
||||
const service = createService({
|
||||
getCheckoutSnapshotFacts,
|
||||
getCheckoutStatus,
|
||||
github,
|
||||
});
|
||||
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(retainCurrentPullRequestStatusPoll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: REPO_CWD,
|
||||
headRef: "open-button-targets-active-file",
|
||||
headRepositoryOwner: "fork-owner",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("settled GitHub self-heal reads stay on the slow poll window without refreshing git", async () => {
|
||||
let nowMs = 0;
|
||||
const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = [];
|
||||
|
||||
@@ -259,6 +259,7 @@ interface WorkspaceGitServiceDependencies {
|
||||
interface WorkspaceGitServiceOptions {
|
||||
logger: pino.Logger;
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
deps?: Partial<WorkspaceGitServiceDependencies>;
|
||||
}
|
||||
|
||||
@@ -269,7 +270,7 @@ interface WorkspaceGitTarget {
|
||||
debounceTimer: NodeJS.Timeout | null;
|
||||
selfHealTimer: NodeJS.Timeout | null;
|
||||
githubPollSubscription: { unsubscribe: () => void } | null;
|
||||
githubPollHeadRef: string | null;
|
||||
githubPollKey: string | null;
|
||||
refreshState: WorkspaceGitRefreshState;
|
||||
latestGit: WorkspaceGitRuntimeSnapshot["git"] | null;
|
||||
latestGitLoadedAtMs: number | null;
|
||||
@@ -316,6 +317,11 @@ interface WorkspaceGitAuxiliaryReadCacheEntry<T> {
|
||||
inFlight: Promise<T> | null;
|
||||
}
|
||||
|
||||
interface WorkspaceGitHubPollTarget {
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
}
|
||||
|
||||
function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies {
|
||||
return {
|
||||
watch,
|
||||
@@ -347,6 +353,7 @@ function resolveWorkspaceGitServiceDeps(
|
||||
export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly paseoHome: string;
|
||||
private readonly worktreesRoot: string | undefined;
|
||||
private readonly deps: WorkspaceGitServiceDependencies;
|
||||
private readonly snapshotUpdatedListeners = new Set<WorkspaceGitSnapshotUpdatedListener>();
|
||||
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
|
||||
@@ -385,6 +392,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
constructor(options: WorkspaceGitServiceOptions) {
|
||||
this.logger = options.logger.child({ module: "workspace-git-service" });
|
||||
this.paseoHome = options.paseoHome;
|
||||
this.worktreesRoot = options.worktreesRoot;
|
||||
this.deps = resolveWorkspaceGitServiceDeps(options.deps);
|
||||
}
|
||||
|
||||
@@ -438,6 +446,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
try {
|
||||
const status = await this.deps.getCheckoutStatus(normalizedCwd, {
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
logger: this.logger,
|
||||
});
|
||||
if (!status.isGit) {
|
||||
@@ -484,7 +493,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const normalizedOptions = this.normalizeCheckoutDiffOptions(options);
|
||||
const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions);
|
||||
return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () =>
|
||||
this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { paseoHome: this.paseoHome }),
|
||||
this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, {
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -581,6 +593,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.deps.listPaseoWorktrees({
|
||||
cwd: repoRoot,
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -788,7 +801,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
debounceTimer: null,
|
||||
selfHealTimer: null,
|
||||
githubPollSubscription: null,
|
||||
githubPollHeadRef: null,
|
||||
githubPollKey: null,
|
||||
refreshState: { status: "idle" },
|
||||
latestGit: null,
|
||||
latestGitLoadedAtMs: null,
|
||||
@@ -1148,23 +1161,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return;
|
||||
}
|
||||
|
||||
const headRef = git.currentBranch;
|
||||
const pollTarget = this.resolveGitHubPollTarget(target);
|
||||
const remoteUrl = git.remoteUrl;
|
||||
const hasGitHubRemote =
|
||||
target.cachedGitHubRemote?.remoteUrl === git.remoteUrl &&
|
||||
target.cachedGitHubRemote?.remoteUrl === remoteUrl &&
|
||||
target.cachedGitHubRemote.identity !== null;
|
||||
if (!headRef || !hasGitHubRemote) {
|
||||
if (!pollTarget || remoteUrl === null || !hasGitHubRemote) {
|
||||
this.stopGitHubPollForTarget(target);
|
||||
return;
|
||||
}
|
||||
if (target.githubPollHeadRef === headRef && target.githubPollSubscription) {
|
||||
const pollKey = buildWorkspaceGitHubPollKey(remoteUrl, pollTarget);
|
||||
if (target.githubPollKey === pollKey && target.githubPollSubscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopGitHubPollForTarget(target);
|
||||
target.githubPollHeadRef = headRef;
|
||||
target.githubPollKey = pollKey;
|
||||
target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({
|
||||
cwd: target.cwd,
|
||||
headRef,
|
||||
headRef: pollTarget.headRef,
|
||||
...(pollTarget.headRepositoryOwner
|
||||
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
|
||||
: {}),
|
||||
onStatus: (status) => {
|
||||
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||
return;
|
||||
@@ -1175,17 +1193,40 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
},
|
||||
onError: (error) => {
|
||||
this.logger.warn(
|
||||
{ err: error, cwd: target.cwd, headRef, reason: "self-heal-github" },
|
||||
{
|
||||
err: error,
|
||||
cwd: target.cwd,
|
||||
headRef: pollTarget.headRef,
|
||||
headRepositoryOwner: pollTarget.headRepositoryOwner,
|
||||
reason: "self-heal-github",
|
||||
},
|
||||
"Failed to run GitHub self-heal refresh",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private resolveGitHubPollTarget(target: WorkspaceGitTarget): WorkspaceGitHubPollTarget | null {
|
||||
const git = target.latestGit;
|
||||
if (!git?.currentBranch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lookupTarget =
|
||||
target.latestFacts?.isGit && target.latestFacts.currentBranch === git.currentBranch
|
||||
? target.latestFacts.pullRequestLookupTarget
|
||||
: null;
|
||||
if (lookupTarget) {
|
||||
return lookupTarget;
|
||||
}
|
||||
|
||||
return { headRef: git.currentBranch };
|
||||
}
|
||||
|
||||
private stopGitHubPollForTarget(target: WorkspaceGitTarget): void {
|
||||
target.githubPollSubscription?.unsubscribe();
|
||||
target.githubPollSubscription = null;
|
||||
target.githubPollHeadRef = null;
|
||||
target.githubPollKey = null;
|
||||
}
|
||||
|
||||
private addWorkingTreeWatcher(
|
||||
@@ -1566,7 +1607,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
|
||||
const cwd = target.cwd;
|
||||
const previousGitHubPollKey = this.getGitHubPollKey(target);
|
||||
const baseContext: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
|
||||
const baseContext: CheckoutContext = {
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
logger: this.logger,
|
||||
};
|
||||
const facts = await this.loadCheckoutFacts(target, {
|
||||
...baseContext,
|
||||
allowRecent: !request.force,
|
||||
@@ -1657,7 +1702,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify([git.remoteUrl, git.currentBranch]);
|
||||
const pollTarget = this.resolveGitHubPollTarget(target);
|
||||
if (!pollTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildWorkspaceGitHubPollKey(git.remoteUrl, pollTarget);
|
||||
}
|
||||
|
||||
private rememberGitHubSnapshot(
|
||||
@@ -1964,6 +2014,10 @@ function buildGitHubSnapshotFromStatus(
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkspaceGitHubPollKey(remoteUrl: string, target: WorkspaceGitHubPollTarget): string {
|
||||
return JSON.stringify([remoteUrl, target.headRef, target.headRepositoryOwner ?? null]);
|
||||
}
|
||||
|
||||
async function runGitFetch(cwd: string): Promise<void> {
|
||||
await runGitCommand(["fetch", "origin", "--prune"], {
|
||||
cwd,
|
||||
|
||||
@@ -65,9 +65,9 @@ describe("buildWorkspaceServiceEnv", () => {
|
||||
).toEqual({
|
||||
HOST: "127.0.0.1",
|
||||
PASEO_PORT: "5173",
|
||||
PASEO_URL: "http://daemon.paseo.localhost:6767",
|
||||
PASEO_URL: "http://daemon--paseo.localhost:6767",
|
||||
PASEO_SERVICE_DAEMON_PORT: "5173",
|
||||
PASEO_SERVICE_DAEMON_URL: "http://daemon.paseo.localhost:6767",
|
||||
PASEO_SERVICE_DAEMON_URL: "http://daemon--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,9 +84,9 @@ describe("buildWorkspaceServiceEnv", () => {
|
||||
).toEqual({
|
||||
HOST: "127.0.0.1",
|
||||
PASEO_PORT: "5173",
|
||||
PASEO_URL: "http://daemon.feature-x.paseo.localhost:6767",
|
||||
PASEO_URL: "http://daemon--feature-x--paseo.localhost:6767",
|
||||
PASEO_SERVICE_DAEMON_PORT: "5173",
|
||||
PASEO_SERVICE_DAEMON_URL: "http://daemon.feature-x.paseo.localhost:6767",
|
||||
PASEO_SERVICE_DAEMON_URL: "http://daemon--feature-x--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,11 +137,32 @@ describe("buildWorkspaceServiceEnv", () => {
|
||||
).toEqual({
|
||||
HOST: "127.0.0.1",
|
||||
PASEO_PORT: "5173",
|
||||
PASEO_URL: "http://web.feature-x.paseo.localhost:6767",
|
||||
PASEO_URL: "http://web--feature-x--paseo.localhost:6767",
|
||||
PASEO_SERVICE_API_PORT: "4000",
|
||||
PASEO_SERVICE_API_URL: "http://api.feature-x.paseo.localhost:6767",
|
||||
PASEO_SERVICE_API_URL: "http://api--feature-x--paseo.localhost:6767",
|
||||
PASEO_SERVICE_WEB_PORT: "5173",
|
||||
PASEO_SERVICE_WEB_URL: "http://web.feature-x.paseo.localhost:6767",
|
||||
PASEO_SERVICE_WEB_URL: "http://web--feature-x--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses public service URLs when a public base URL is configured", () => {
|
||||
expect(
|
||||
buildWorkspaceServiceEnv({
|
||||
scriptName: "web",
|
||||
projectSlug: "paseo",
|
||||
branchName: "feature-x",
|
||||
daemonPort: 6767,
|
||||
daemonListenHost: null,
|
||||
serviceProxyPublicBaseUrl: "https://services.example.com",
|
||||
peers: [
|
||||
{ scriptName: "api", port: 4000 },
|
||||
{ scriptName: "web", port: 5173 },
|
||||
],
|
||||
}),
|
||||
).toMatchObject({
|
||||
PASEO_URL: "https://web--feature-x--paseo.services.example.com",
|
||||
PASEO_SERVICE_API_URL: "https://api--feature-x--paseo.services.example.com",
|
||||
PASEO_SERVICE_WEB_URL: "https://web--feature-x--paseo.services.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import { projectServiceProxyUrls } from "./service-proxy.js";
|
||||
|
||||
export interface WorkspaceServicePeer {
|
||||
scriptName: string;
|
||||
@@ -11,6 +11,7 @@ export interface BuildWorkspaceServiceEnvOptions {
|
||||
branchName: string | null;
|
||||
daemonPort: number | null | undefined;
|
||||
daemonListenHost: string | null | undefined;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
peers: readonly WorkspaceServicePeer[];
|
||||
}
|
||||
|
||||
@@ -37,26 +38,30 @@ export function buildWorkspaceServiceEnv(
|
||||
PASEO_PORT: String(selfPeer.port),
|
||||
};
|
||||
|
||||
if (options.daemonPort !== null && options.daemonPort !== undefined) {
|
||||
env.PASEO_URL = buildServiceProxyUrl({
|
||||
projectSlug: options.projectSlug,
|
||||
branchName: options.branchName,
|
||||
scriptName: options.scriptName,
|
||||
daemonPort: options.daemonPort,
|
||||
});
|
||||
const selfProxyUrl = buildServiceProxyUrl({
|
||||
projectSlug: options.projectSlug,
|
||||
branchName: options.branchName,
|
||||
scriptName: options.scriptName,
|
||||
daemonPort: options.daemonPort,
|
||||
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
|
||||
});
|
||||
if (selfProxyUrl) {
|
||||
env.PASEO_URL = selfProxyUrl;
|
||||
}
|
||||
|
||||
for (const peer of options.peers) {
|
||||
const envName = normalizeServiceEnvName(peer.scriptName);
|
||||
env[`PASEO_SERVICE_${envName}_PORT`] = String(peer.port);
|
||||
|
||||
if (options.daemonPort !== null && options.daemonPort !== undefined) {
|
||||
env[`PASEO_SERVICE_${envName}_URL`] = buildServiceProxyUrl({
|
||||
projectSlug: options.projectSlug,
|
||||
branchName: options.branchName,
|
||||
scriptName: peer.scriptName,
|
||||
daemonPort: options.daemonPort,
|
||||
});
|
||||
const peerProxyUrl = buildServiceProxyUrl({
|
||||
projectSlug: options.projectSlug,
|
||||
branchName: options.branchName,
|
||||
scriptName: peer.scriptName,
|
||||
daemonPort: options.daemonPort,
|
||||
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
|
||||
});
|
||||
if (peerProxyUrl) {
|
||||
env[`PASEO_SERVICE_${envName}_URL`] = peerProxyUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,16 +76,18 @@ interface BuildServiceProxyUrlOptions {
|
||||
projectSlug: string;
|
||||
branchName: string | null;
|
||||
scriptName: string;
|
||||
daemonPort: number;
|
||||
daemonPort: number | null | undefined;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
}
|
||||
|
||||
function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string {
|
||||
const hostname = buildScriptHostname({
|
||||
function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string | null {
|
||||
return projectServiceProxyUrls({
|
||||
projectSlug: options.projectSlug,
|
||||
branchName: options.branchName,
|
||||
scriptName: options.scriptName,
|
||||
});
|
||||
return `http://${hostname}:${options.daemonPort}`;
|
||||
daemonPort: options.daemonPort,
|
||||
publicBaseUrl: options.serviceProxyPublicBaseUrl,
|
||||
}).proxyUrl;
|
||||
}
|
||||
|
||||
function isLoopbackListenHost(host: string | null | undefined): boolean {
|
||||
|
||||
@@ -450,7 +450,7 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
branchName: "feature-peer-env",
|
||||
scriptName,
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
}),
|
||||
@@ -465,16 +465,24 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
const apiEnv = readEnvFile(apiEnvPath);
|
||||
const webEnv = readEnvFile(webEnvPath);
|
||||
|
||||
expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767");
|
||||
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767");
|
||||
expect(apiEnv.PASEO_SERVICE_API_URL).toBe(
|
||||
"http://api--feature-peer-env--repo.localhost:6767",
|
||||
);
|
||||
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe(
|
||||
"http://web--feature-peer-env--repo.localhost:6767",
|
||||
);
|
||||
expect(apiEnv.PASEO_SERVICE_API_PORT).toEqual(expect.stringMatching(/^\d+$/));
|
||||
expect(apiEnv.PASEO_SERVICE_WEB_PORT).toEqual(expect.stringMatching(/^\d+$/));
|
||||
expect(apiEnv.PASEO_URL).toBe(apiEnv.PASEO_SERVICE_API_URL);
|
||||
expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
|
||||
expect(apiEnv).not.toHaveProperty("PORT");
|
||||
|
||||
expect(webEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767");
|
||||
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767");
|
||||
expect(webEnv.PASEO_SERVICE_API_URL).toBe(
|
||||
"http://api--feature-peer-env--repo.localhost:6767",
|
||||
);
|
||||
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe(
|
||||
"http://web--feature-peer-env--repo.localhost:6767",
|
||||
);
|
||||
expect(webEnv.PASEO_SERVICE_API_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
|
||||
expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT);
|
||||
expect(webEnv.PASEO_URL).toBe(webEnv.PASEO_SERVICE_WEB_URL);
|
||||
@@ -487,14 +495,14 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
expect(Number.isInteger(webPort)).toBe(true);
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "api.feature-peer-env.repo.localhost",
|
||||
hostname: "api--feature-peer-env--repo.localhost",
|
||||
port: apiPort,
|
||||
workspaceId: repoDir,
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
},
|
||||
{
|
||||
hostname: "web.feature-peer-env.repo.localhost",
|
||||
hostname: "web--feature-peer-env--repo.localhost",
|
||||
port: webPort,
|
||||
workspaceId: repoDir,
|
||||
projectSlug: "repo",
|
||||
|
||||
@@ -420,13 +420,13 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String));
|
||||
expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1");
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
|
||||
"http://api.feature-socket-service.repo.localhost:6767",
|
||||
"http://api--feature-socket-service--repo.localhost:6767",
|
||||
);
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_PORT).toBe(
|
||||
createTerminalCalls[0]?.env?.PASEO_PORT,
|
||||
);
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe(
|
||||
"http://api.feature-socket-service.repo.localhost:6767",
|
||||
"http://api--feature-socket-service--repo.localhost:6767",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -450,7 +450,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
String(plannedAppServerPort),
|
||||
);
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe(
|
||||
"http://app-server.feature-socket-service.repo.localhost:6767",
|
||||
"http://app-server--feature-socket-service--repo.localhost:6767",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -497,7 +497,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-socket-service",
|
||||
scriptName: "web",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
|
||||
});
|
||||
@@ -541,7 +541,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-script-exit",
|
||||
scriptName: "typecheck",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -580,7 +580,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-script-rerun",
|
||||
scriptName: "typecheck",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -600,7 +600,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-script-rerun",
|
||||
scriptName: "typecheck",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -659,7 +659,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-script-existing-terminal",
|
||||
scriptName: "typecheck",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -701,7 +701,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-script-terminal-exit",
|
||||
scriptName: "typecheck",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -738,7 +738,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-script-duplicate",
|
||||
scriptName: "typecheck",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -751,7 +751,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-script-duplicate",
|
||||
scriptName: "typecheck",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
}),
|
||||
@@ -786,7 +786,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-socket-service",
|
||||
scriptName: "api",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
|
||||
});
|
||||
@@ -794,7 +794,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(result.scriptName).toBe("api");
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "api.feature-socket-service.repo.localhost",
|
||||
hostname: "api--feature-socket-service--repo.localhost",
|
||||
port: expect.any(Number),
|
||||
workspaceId: repoDir,
|
||||
projectSlug: "repo",
|
||||
@@ -813,6 +813,60 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("spawns services with public aliases and public service URLs", async () => {
|
||||
commitPaseoScripts(
|
||||
{
|
||||
api: {
|
||||
type: "service",
|
||||
command: "npm run api",
|
||||
},
|
||||
"app-server": {
|
||||
type: "service",
|
||||
command: "npm run app",
|
||||
},
|
||||
},
|
||||
"add public service script config",
|
||||
);
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const createTerminalCalls: CreateTerminalCall[] = [];
|
||||
const terminalRecords: StubTerminalRecord[] = [];
|
||||
|
||||
const result = await spawnWorkspaceScript({
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
projectSlug: "repo",
|
||||
branchName: "feature-public-service",
|
||||
scriptName: "api",
|
||||
daemonPort: 6767,
|
||||
serviceProxyPublicBaseUrl: "https://services.example.com",
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
|
||||
});
|
||||
|
||||
expect(result.hostname).toBe("api--feature-public-service--repo.localhost");
|
||||
expect(
|
||||
routeStore.getRouteEntry("api--feature-public-service--repo.services.example.com"),
|
||||
).toMatchObject({
|
||||
hostname: "api--feature-public-service--repo.localhost",
|
||||
publicHostname: "api--feature-public-service--repo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
workspaceId: repoDir,
|
||||
scriptName: "api",
|
||||
});
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
|
||||
"https://api--feature-public-service--repo.services.example.com",
|
||||
);
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe(
|
||||
"https://api--feature-public-service--repo.services.example.com",
|
||||
);
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe(
|
||||
"https://app-server--feature-public-service--repo.services.example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes a stopped service port on respawn and updates the route", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
@@ -852,7 +906,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-respawn-service",
|
||||
scriptName: "api",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -864,7 +918,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-respawn-service",
|
||||
scriptName: "worker",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -889,7 +943,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
lifecycle: "stopped",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(routeStore.getRouteEntry("api.feature-respawn-service.repo.localhost")).toBeNull();
|
||||
expect(routeStore.getRouteEntry("api--feature-respawn-service--repo.localhost")).toBeNull();
|
||||
|
||||
const secondResult = await spawnWorkspaceScript({
|
||||
repoRoot: repoDir,
|
||||
@@ -898,7 +952,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-respawn-service",
|
||||
scriptName: "api",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -911,8 +965,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(secondPort).not.toBe(firstPort);
|
||||
expect(secondPort).toEqual(expect.any(Number));
|
||||
expect(createTerminalCalls[2]?.env?.PASEO_SERVICE_WORKER_PORT).toBe(String(workerPort));
|
||||
expect(routeStore.getRouteEntry("api.feature-respawn-service.repo.localhost")).toMatchObject({
|
||||
hostname: "api.feature-respawn-service.repo.localhost",
|
||||
expect(routeStore.getRouteEntry("api--feature-respawn-service--repo.localhost")).toMatchObject({
|
||||
hostname: "api--feature-respawn-service--repo.localhost",
|
||||
port: secondPort,
|
||||
workspaceId: repoDir,
|
||||
projectSlug: "repo",
|
||||
@@ -955,20 +1009,20 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-before-rename",
|
||||
scriptName: "api",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
|
||||
const updateRoutesForBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged: () => {},
|
||||
});
|
||||
updateRoutesForBranchChange(repoDir, "feature-before-rename", "feature-after-rename");
|
||||
|
||||
expect(routeStore.listRoutesForWorkspace(repoDir)).toEqual([
|
||||
expect.objectContaining({
|
||||
hostname: "api.feature-after-rename.repo.localhost",
|
||||
hostname: "api--feature-after-rename--repo.localhost",
|
||||
scriptName: "api",
|
||||
}),
|
||||
]);
|
||||
@@ -1020,7 +1074,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-collision-service",
|
||||
scriptName: "app-server",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls),
|
||||
}),
|
||||
@@ -1029,7 +1083,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(createTerminalCalls).toHaveLength(0);
|
||||
expect(routeStore.listRoutes()).toEqual([]);
|
||||
expect(
|
||||
routeStore.getRouteEntry("app-server.feature-collision-service.repo.localhost"),
|
||||
routeStore.getRouteEntry("app-server--feature-collision-service--repo.localhost"),
|
||||
).toBeNull();
|
||||
|
||||
writeFileSync(
|
||||
@@ -1055,7 +1109,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-collision-service",
|
||||
scriptName: "app-server",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls),
|
||||
});
|
||||
@@ -1108,7 +1162,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
scriptName: "web",
|
||||
daemonPort: 6767,
|
||||
daemonListenHost: "100.64.0.20",
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls),
|
||||
});
|
||||
@@ -1116,7 +1170,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(createTerminalCalls).toHaveLength(1);
|
||||
expect(createTerminalCalls[0]?.env?.HOST).toBe("0.0.0.0");
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
|
||||
"http://web.feature-remote-service.repo.localhost:6767",
|
||||
"http://web--feature-remote-service--repo.localhost:6767",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import type { Logger } from "pino";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
import { buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import {
|
||||
getScriptConfigs,
|
||||
getWorktreeTerminalSpecs,
|
||||
@@ -17,7 +16,7 @@ import {
|
||||
type WorktreeSetupCommandResult,
|
||||
type WorktreeRuntimeEnv,
|
||||
} from "../utils/worktree.js";
|
||||
import { findFreePort, type ScriptRouteStore } from "./script-proxy.js";
|
||||
import { findFreePort, type ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
|
||||
import {
|
||||
@@ -699,7 +698,8 @@ interface SpawnWorkspaceScriptOptions {
|
||||
scriptName: string;
|
||||
daemonPort?: number | null;
|
||||
daemonListenHost?: string | null;
|
||||
routeStore: ScriptRouteStore;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
terminalManager: TerminalManager;
|
||||
logger?: Logger;
|
||||
@@ -721,8 +721,9 @@ async function setupServiceScriptRoute(params: {
|
||||
workspaceId: string;
|
||||
daemonPort: number | null | undefined;
|
||||
daemonListenHost: string | null | undefined;
|
||||
serviceProxyPublicBaseUrl: string | null | undefined;
|
||||
existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>;
|
||||
routeStore: ScriptRouteStore;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
}): Promise<ServiceScriptSetupResult> {
|
||||
const {
|
||||
scriptConfigs,
|
||||
@@ -733,10 +734,10 @@ async function setupServiceScriptRoute(params: {
|
||||
workspaceId,
|
||||
daemonPort,
|
||||
daemonListenHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
existingRuntimeEntry,
|
||||
routeStore,
|
||||
serviceProxy,
|
||||
} = params;
|
||||
const hostname = buildScriptHostname({ projectSlug, branchName, scriptName });
|
||||
|
||||
const serviceDeclarations: Array<{ scriptName: string; port?: number }> = [];
|
||||
for (const [configuredScriptName, scriptConfig] of scriptConfigs) {
|
||||
@@ -779,17 +780,19 @@ async function setupServiceScriptRoute(params: {
|
||||
branchName,
|
||||
daemonPort,
|
||||
daemonListenHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
peers,
|
||||
});
|
||||
|
||||
routeStore.registerRoute({
|
||||
hostname,
|
||||
const registeredRoute = serviceProxy.registerWorkspaceService({
|
||||
port,
|
||||
workspaceId,
|
||||
projectSlug,
|
||||
branchName,
|
||||
scriptName,
|
||||
publicBaseUrl: serviceProxyPublicBaseUrl ?? null,
|
||||
});
|
||||
return { hostname, port, env };
|
||||
return { hostname: registeredRoute.hostname, port, env };
|
||||
}
|
||||
|
||||
async function acquireWorkspaceScriptTerminal(params: {
|
||||
@@ -828,7 +831,8 @@ export async function spawnWorkspaceScript(
|
||||
scriptName,
|
||||
daemonPort,
|
||||
daemonListenHost,
|
||||
routeStore,
|
||||
serviceProxyPublicBaseUrl,
|
||||
serviceProxy,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
logger,
|
||||
@@ -869,8 +873,9 @@ export async function spawnWorkspaceScript(
|
||||
workspaceId,
|
||||
daemonPort,
|
||||
daemonListenHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
existingRuntimeEntry,
|
||||
routeStore,
|
||||
serviceProxy,
|
||||
});
|
||||
hostname = serviceSetup.hostname;
|
||||
port = serviceSetup.port;
|
||||
@@ -907,7 +912,7 @@ export async function spawnWorkspaceScript(
|
||||
disposeLifecycleListeners = null;
|
||||
|
||||
if (input.removeRoute && hostname) {
|
||||
routeStore.removeRouteForWorkspaceScript({ workspaceId, scriptName });
|
||||
serviceProxy.removeWorkspaceService({ workspaceId, scriptName });
|
||||
}
|
||||
runtimeStore.set({
|
||||
workspaceId,
|
||||
@@ -975,7 +980,7 @@ export async function spawnWorkspaceScript(
|
||||
} catch (error) {
|
||||
disposeLifecycleListeners?.();
|
||||
if (routeRegistered && hostname) {
|
||||
routeStore.removeRoute(hostname);
|
||||
serviceProxy.removeServiceRoutesByHostnames([hostname]);
|
||||
}
|
||||
if (runtimeRegistered) {
|
||||
runtimeStore.remove({ workspaceId, scriptName });
|
||||
@@ -998,12 +1003,12 @@ export async function spawnWorkspaceScript(
|
||||
|
||||
export function teardownWorktreeScripts(options: {
|
||||
hostnames: string[];
|
||||
routeStore: ScriptRouteStore;
|
||||
serviceProxy: Pick<ServiceProxySubsystem, "removeServiceRoutesByHostnames">;
|
||||
logger: Logger;
|
||||
}): void {
|
||||
const { hostnames, routeStore, logger } = options;
|
||||
const { hostnames, serviceProxy, logger } = options;
|
||||
serviceProxy.removeServiceRoutesByHostnames(hostnames);
|
||||
for (const hostname of hostnames) {
|
||||
routeStore.removeRoute(hostname);
|
||||
logger.info({ hostname }, "Removed script proxy route");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface CreateWorktreeCoreInput {
|
||||
githubPrNumber?: number;
|
||||
firstAgentContext?: FirstAgentContext;
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
runSetup?: boolean;
|
||||
}
|
||||
|
||||
@@ -98,6 +99,7 @@ export async function createWorktreeCore(
|
||||
slug: normalizedSlug,
|
||||
repoRoot,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
});
|
||||
if (existingWorktree) {
|
||||
return { worktree: existingWorktree, intent, repoRoot, created: false };
|
||||
@@ -110,6 +112,7 @@ export async function createWorktreeCore(
|
||||
source: intent,
|
||||
runSetup: input.runSetup ?? true,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
}),
|
||||
intent,
|
||||
repoRoot,
|
||||
|
||||
@@ -107,7 +107,7 @@ function createWorkflowForRequestTest(options: {
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
scriptRouteStore: null,
|
||||
serviceProxy: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
getDaemonTcpHost: null,
|
||||
@@ -416,7 +416,7 @@ describe("create-agent worktree setup boundary", () => {
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
scriptRouteStore: null,
|
||||
serviceProxy: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
getDaemonTcpHost: null,
|
||||
@@ -1935,8 +1935,20 @@ describe("archivePaseoWorktree", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("clears archiving state and leaves workspace records active when worktree delete fails", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
test("archives the workspace record even when the teardown script fails", async () => {
|
||||
const teardownLogPath = isPlatform("win32")
|
||||
? 'Set-Content -Path (Join-Path $env:PASEO_SOURCE_CHECKOUT_PATH "teardown-start.log") -Value "started"'
|
||||
: 'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-start.log"';
|
||||
const failingTeardownCommand = isPlatform("win32")
|
||||
? 'Write-Error "boom"; exit 9'
|
||||
: "echo boom 1>&2; exit 9";
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
teardown: [teardownLogPath, failingTeardownCommand],
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
@@ -1949,12 +1961,21 @@ describe("archivePaseoWorktree", () => {
|
||||
paseoHome,
|
||||
});
|
||||
const archivingByWorkspaceId = new Map<string, string>();
|
||||
const emittedUpdates: Array<{
|
||||
kind: "upsert";
|
||||
workspaceId: string;
|
||||
archivingAt: string | null;
|
||||
}> = [];
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
const archivedWorkspaceIds = new Set<string>();
|
||||
const emittedUpdates: Array<
|
||||
| {
|
||||
kind: "upsert";
|
||||
workspaceId: string;
|
||||
archivingAt: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "remove";
|
||||
workspaceId: string;
|
||||
}
|
||||
> = [];
|
||||
const archiveWorkspaceRecord = vi.fn(async (workspaceId: string) => {
|
||||
archivedWorkspaceIds.add(workspaceId);
|
||||
});
|
||||
|
||||
await expect(
|
||||
archivePaseoWorktree(
|
||||
@@ -1973,6 +1994,13 @@ describe("archivePaseoWorktree", () => {
|
||||
archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async (workspaceIds: Iterable<string>) => {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
if (archivedWorkspaceIds.has(workspaceId)) {
|
||||
emittedUpdates.push({
|
||||
kind: "remove",
|
||||
workspaceId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
emittedUpdates.push({
|
||||
kind: "upsert",
|
||||
workspaceId,
|
||||
@@ -1996,23 +2024,23 @@ describe("archivePaseoWorktree", () => {
|
||||
},
|
||||
{
|
||||
targetPath: created.worktreePath,
|
||||
repoRoot: null,
|
||||
repoRoot: repoDir,
|
||||
requestId: "req-archive-delete-fails",
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("cwd or worktreesRoot is required to delete a Paseo worktree");
|
||||
).rejects.toThrow("Worktree teardown command failed");
|
||||
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(existsSync(path.join(repoDir, "teardown-start.log"))).toBe(true);
|
||||
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.worktreePath);
|
||||
expect(emittedUpdates[0]).toEqual({
|
||||
kind: "upsert",
|
||||
workspaceId: created.worktreePath,
|
||||
archivingAt: expect.any(String),
|
||||
});
|
||||
expect(emittedUpdates.at(-1)).toEqual({
|
||||
kind: "upsert",
|
||||
kind: "remove",
|
||||
workspaceId: created.worktreePath,
|
||||
archivingAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
getWorktreeSetupProgressResults,
|
||||
} from "./worktree-bootstrap.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import type { GitHubService } from "../services/github-service.js";
|
||||
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
|
||||
@@ -75,6 +75,7 @@ type AgentWorktreeSetupTimelineWriter = (input: {
|
||||
|
||||
interface BuildAgentSessionConfigDependencies {
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
sessionLogger: Logger;
|
||||
workspaceGitService?: WorkspaceGitService;
|
||||
createPaseoWorktree: (
|
||||
@@ -95,16 +96,18 @@ interface BuildAgentSessionConfigDependencies {
|
||||
|
||||
interface CreatePaseoWorktreeInBackgroundDependencies {
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise<void>;
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId: string, snapshot: WorkspaceSetupSnapshot) => void;
|
||||
emit: EmitSessionMessage;
|
||||
sessionLogger: Logger;
|
||||
terminalManager: TerminalManager | null;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
scriptRouteStore: ScriptRouteStore | null;
|
||||
serviceProxy: ServiceProxySubsystem | null;
|
||||
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||
getDaemonTcpPort: (() => number | null) | null;
|
||||
getDaemonTcpHost: (() => string | null) | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null;
|
||||
}
|
||||
|
||||
@@ -158,6 +161,7 @@ interface HandleWorkspaceSetupStatusRequestDependencies {
|
||||
|
||||
interface HandleCreatePaseoWorktreeRequestDependencies {
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
describeWorkspaceRecord: (
|
||||
result: CreatePaseoWorktreeResult,
|
||||
) => Promise<WorkspaceDescriptorPayload>;
|
||||
@@ -224,6 +228,7 @@ export async function buildAgentSessionConfig(
|
||||
firstAgentContext,
|
||||
runSetup: false,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: dependencies.worktreesRoot,
|
||||
},
|
||||
{
|
||||
resolveDefaultBranch: normalized.baseBranch
|
||||
@@ -491,6 +496,7 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
const commandResult = await createPaseoWorktreeCommand(
|
||||
{
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: dependencies.worktreesRoot,
|
||||
createPaseoWorktreeWorkflow: dependencies.createPaseoWorktreeWorkflow,
|
||||
},
|
||||
{
|
||||
@@ -572,6 +578,7 @@ export async function createPaseoWorktreeWorkflow(
|
||||
...input,
|
||||
runSetup: false,
|
||||
paseoHome: input.paseoHome ?? dependencies.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot ?? dependencies.worktreesRoot,
|
||||
},
|
||||
options?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface CreatePaseoWorktreeCommandDependencies<
|
||||
Result extends CreatePaseoWorktreeResult = CreatePaseoWorktreeResult,
|
||||
> {
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
createPaseoWorktreeWorkflow?: CreatePaseoWorktreeWorkflow<Result>;
|
||||
}
|
||||
|
||||
@@ -47,6 +48,7 @@ export type CreatePaseoWorktreeCommandInput = Omit<
|
||||
"paseoHome" | "runSetup"
|
||||
> & {
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
};
|
||||
|
||||
export type CreatePaseoWorktreeCommandResult<Result extends CreatePaseoWorktreeResult> =
|
||||
@@ -73,6 +75,7 @@ export async function createPaseoWorktreeCommand<Result extends CreatePaseoWorkt
|
||||
...input,
|
||||
runSetup: false,
|
||||
paseoHome: input.paseoHome ?? dependencies.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot ?? dependencies.worktreesRoot,
|
||||
});
|
||||
return { ok: true, createdWorktree };
|
||||
} catch (error) {
|
||||
@@ -118,6 +121,7 @@ export async function archivePaseoWorktreeCommand(
|
||||
const resolvedTarget = await resolveArchiveTarget(dependencies, input);
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: dependencies.worktreesRoot,
|
||||
});
|
||||
|
||||
if (!ownership.allowed) {
|
||||
@@ -134,6 +138,7 @@ export async function archivePaseoWorktreeCommand(
|
||||
targetPath: resolvedTarget.targetPath,
|
||||
repoRoot,
|
||||
worktreesRoot: ownership.worktreeRoot,
|
||||
worktreesBaseRoot: dependencies.worktreesRoot,
|
||||
requestId: input.requestId,
|
||||
});
|
||||
|
||||
@@ -184,6 +189,10 @@ async function resolveWorktreeSlugPath(
|
||||
repoRoot: string,
|
||||
worktreeSlug: string,
|
||||
): Promise<string> {
|
||||
const worktreesRoot = await getPaseoWorktreesRoot(repoRoot, dependencies.paseoHome);
|
||||
const worktreesRoot = await getPaseoWorktreesRoot(
|
||||
repoRoot,
|
||||
dependencies.paseoHome,
|
||||
dependencies.worktreesRoot,
|
||||
);
|
||||
return join(worktreesRoot, worktreeSlug);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type GitHubCommandRunner,
|
||||
type GitHubCommandRunnerOptions,
|
||||
type GitHubCurrentPullRequestStatus,
|
||||
type GitHubReadOptions,
|
||||
} from "./github-service.js";
|
||||
import { CheckoutPrStatusResponseSchema } from "@getpaseo/protocol/messages";
|
||||
|
||||
@@ -186,7 +185,7 @@ function githubStatusFacts(
|
||||
}
|
||||
|
||||
function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGitHubService>) {
|
||||
const reads: GitHubReadOptions[] = [];
|
||||
const reads: Parameters<typeof service.getCurrentPullRequestStatus>[0][] = [];
|
||||
const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service);
|
||||
service.getCurrentPullRequestStatus = vi.fn(async (options) => {
|
||||
reads.push(options);
|
||||
@@ -662,6 +661,41 @@ describe("GitHubService", () => {
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("retained fork PR status polls keep the head repository owner", async () => {
|
||||
const runner = createRunner([
|
||||
currentPullRequestJson({
|
||||
headRefName: "open-button-targets-active-file",
|
||||
headRepositoryOwner: { login: "fork-owner" },
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
});
|
||||
const reads = recordCurrentPullRequestStatusReads(service);
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/repo",
|
||||
headRef: "open-button-targets-active-file",
|
||||
headRepositoryOwner: "fork-owner",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(reads).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: "/repo",
|
||||
headRef: "open-button-targets-active-file",
|
||||
headRepositoryOwner: "fork-owner",
|
||||
reason: "self-heal-github",
|
||||
}),
|
||||
]);
|
||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("polls PR status at slow cadence after stable checks", async () => {
|
||||
let now = 0;
|
||||
const runner = createRunner([
|
||||
|
||||
@@ -642,6 +642,7 @@ export interface GitHubService {
|
||||
retainCurrentPullRequestStatusPoll?(options: {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
onStatus?: (status: GitHubCurrentPullRequestStatus | null) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
}): { unsubscribe: () => void };
|
||||
@@ -720,6 +721,7 @@ interface InFlightCacheEntry {
|
||||
interface GitHubPollTarget {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
retainCount: number;
|
||||
timer: NodeJS.Timeout | null;
|
||||
latestStatus: GitHubCurrentPullRequestStatus | null;
|
||||
@@ -813,17 +815,25 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
}
|
||||
}
|
||||
|
||||
function getPollTargetKey(target: { cwd: string; headRef: string }): string {
|
||||
function getPollTargetKey(target: {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
}): string {
|
||||
return buildCacheKey({
|
||||
cwd: target.cwd,
|
||||
method: "getCurrentPullRequestStatus",
|
||||
args: { headRef: target.headRef },
|
||||
args: {
|
||||
headRef: target.headRef,
|
||||
headRepositoryOwner: target.headRepositoryOwner,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updatePollTargetAfterSuccess(update: {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
status: GitHubCurrentPullRequestStatus | null;
|
||||
notify: boolean;
|
||||
}): void {
|
||||
@@ -872,6 +882,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
await api.getCurrentPullRequestStatus({
|
||||
cwd: target.cwd,
|
||||
headRef: target.headRef,
|
||||
headRepositoryOwner: target.headRepositoryOwner,
|
||||
reason: "self-heal-github",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1027,6 +1038,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
updatePollTargetAfterSuccess({
|
||||
cwd: input.cwd,
|
||||
headRef: input.headRef,
|
||||
headRepositoryOwner: input.headRepositoryOwner,
|
||||
status,
|
||||
notify: input.reason === "self-heal-github",
|
||||
});
|
||||
@@ -1241,6 +1253,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
target = {
|
||||
cwd: input.cwd,
|
||||
headRef: input.headRef,
|
||||
headRepositoryOwner: input.headRepositoryOwner,
|
||||
retainCount: 0,
|
||||
timer: null,
|
||||
latestStatus: null,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
mkdirSync,
|
||||
} from "fs";
|
||||
import { join } from "path";
|
||||
import { win32 } from "node:path";
|
||||
import { tmpdir } from "os";
|
||||
import {
|
||||
__resetCheckoutShortstatCacheForTests,
|
||||
@@ -2355,6 +2356,20 @@ const x = 1;
|
||||
expect(isPaseoWorktreePath("C:\\Users\\dev\\.paseo\\worktrees\\feature")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches worktrees under a custom PASEO_HOME", () => {
|
||||
const customPaseoHome = process.platform === "win32" ? "C:\\paseo" : "/var/lib/paseo";
|
||||
const worktreePath =
|
||||
process.platform === "win32"
|
||||
? win32.join(customPaseoHome, "worktrees", "project", "feature")
|
||||
: `${customPaseoHome}/worktrees/project/feature`;
|
||||
|
||||
expect(
|
||||
isPaseoWorktreePath(worktreePath, {
|
||||
paseoHome: customPaseoHome,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects paths without .paseo/worktrees segment", () => {
|
||||
expect(isPaseoWorktreePath("/home/user/repo")).toBe(false);
|
||||
expect(isPaseoWorktreePath("C:\\Users\\dev\\repo")).toBe(false);
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "../services/github-service.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { runGitCommand } from "./run-git-command.js";
|
||||
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
|
||||
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
|
||||
import { readPaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||
const READ_ONLY_GIT_ENV = {
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
@@ -779,6 +779,7 @@ export interface MergeFromBaseOptions {
|
||||
|
||||
export interface CheckoutContext {
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
logger?: Pick<Logger, "trace">;
|
||||
facts?: CheckoutSnapshotFacts | null;
|
||||
}
|
||||
@@ -882,6 +883,7 @@ export async function getMainRepoRoot(cwd: string): Promise<string> {
|
||||
async function getMainRepoRootFromCommonDir(
|
||||
cwd: string,
|
||||
commonDir: string | null,
|
||||
context?: CheckoutContext,
|
||||
): Promise<string> {
|
||||
if (!commonDir) {
|
||||
throw new Error("Not in a git repository");
|
||||
@@ -897,7 +899,14 @@ async function getMainRepoRootFromCommonDir(
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const worktrees = parseWorktreeList(worktreeOut);
|
||||
const nonBareNonPaseo = worktrees.filter((wt) => !wt.isBare && !isPaseoWorktreePath(wt.path));
|
||||
const nonBareNonPaseo = worktrees.filter(
|
||||
(wt) =>
|
||||
!wt.isBare &&
|
||||
!isPaseoWorktreePath(wt.path, {
|
||||
paseoHome: context?.paseoHome,
|
||||
worktreesRoot: context?.worktreesRoot,
|
||||
}),
|
||||
);
|
||||
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => isDescendantPath(wt.path, normalized));
|
||||
const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main");
|
||||
return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized;
|
||||
@@ -909,8 +918,14 @@ export interface GitWorktreeEntry {
|
||||
isBare?: boolean;
|
||||
}
|
||||
|
||||
/** Check whether a path contains a `.paseo/worktrees/` segment (both `/` and `\`). */
|
||||
export function isPaseoWorktreePath(p: string): boolean {
|
||||
/** Check whether a path is under Paseo's worktree root. */
|
||||
export function isPaseoWorktreePath(
|
||||
p: string,
|
||||
options?: { paseoHome?: string; worktreesRoot?: string },
|
||||
): boolean {
|
||||
if (options?.worktreesRoot || options?.paseoHome) {
|
||||
return isDescendantPath(p, resolvePaseoWorktreesBaseRoot(options));
|
||||
}
|
||||
return /[/\\]\.paseo[/\\]worktrees[/\\]/.test(p);
|
||||
}
|
||||
|
||||
@@ -1008,7 +1023,10 @@ async function getPaseoWorktreeForCwd(
|
||||
return { isPaseoOwnedWorktree: false };
|
||||
}
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(cwd, { paseoHome: context?.paseoHome });
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(cwd, {
|
||||
paseoHome: context?.paseoHome,
|
||||
worktreesRoot: context?.worktreesRoot,
|
||||
});
|
||||
if (!ownership.allowed) {
|
||||
return { isPaseoOwnedWorktree: false };
|
||||
}
|
||||
@@ -1548,9 +1566,11 @@ export async function getCheckoutSnapshotFacts(
|
||||
? readPaseoWorktreeBaseRef(inspected.paseoWorktree.worktreeRoot)
|
||||
: null;
|
||||
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd));
|
||||
const mainRepoRoot = await getMainRepoRootFromCommonDir(cwd, inspected.gitCommonDir).catch(
|
||||
() => null,
|
||||
);
|
||||
const mainRepoRoot = await getMainRepoRootFromCommonDir(
|
||||
cwd,
|
||||
inspected.gitCommonDir,
|
||||
context,
|
||||
).catch(() => null);
|
||||
let comparisonBaseRef: string | null = null;
|
||||
if (
|
||||
resolvedBaseRef &&
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildScriptHostname } from "./script-hostname.js";
|
||||
import {
|
||||
buildPublicScriptHostname,
|
||||
buildPublicScriptProxyUrl,
|
||||
buildScriptHostname,
|
||||
} from "./script-hostname.js";
|
||||
|
||||
describe("buildScriptHostname", () => {
|
||||
it("builds default branch hostnames with script and project labels", () => {
|
||||
@@ -9,7 +13,7 @@ describe("buildScriptHostname", () => {
|
||||
branchName: null,
|
||||
scriptName: "web",
|
||||
}),
|
||||
).toBe("web.paseo.localhost");
|
||||
).toBe("web--paseo.localhost");
|
||||
});
|
||||
|
||||
it("omits the branch label for main and master", () => {
|
||||
@@ -19,14 +23,14 @@ describe("buildScriptHostname", () => {
|
||||
branchName: "main",
|
||||
scriptName: "web",
|
||||
}),
|
||||
).toBe("web.paseo.localhost");
|
||||
).toBe("web--paseo.localhost");
|
||||
expect(
|
||||
buildScriptHostname({
|
||||
projectSlug: "paseo",
|
||||
branchName: "master",
|
||||
scriptName: "web",
|
||||
}),
|
||||
).toBe("web.paseo.localhost");
|
||||
).toBe("web--paseo.localhost");
|
||||
});
|
||||
|
||||
it("builds non-default branch hostnames with script, branch, and project labels", () => {
|
||||
@@ -36,7 +40,7 @@ describe("buildScriptHostname", () => {
|
||||
branchName: "feature-auth",
|
||||
scriptName: "web",
|
||||
}),
|
||||
).toBe("web.feature-auth.paseo.localhost");
|
||||
).toBe("web--feature-auth--paseo.localhost");
|
||||
});
|
||||
|
||||
it("slugifies script, default branch project, and non-default branch labels", () => {
|
||||
@@ -46,7 +50,7 @@ describe("buildScriptHostname", () => {
|
||||
branchName: "Feature/Auth Flow",
|
||||
scriptName: "Web/API @ Dev",
|
||||
}),
|
||||
).toBe("web-api-dev.feature-auth-flow.paseo-app.localhost");
|
||||
).toBe("web-api-dev--feature-auth-flow--paseo-app.localhost");
|
||||
});
|
||||
|
||||
it("accepts already slugified labels because slugify is idempotent", () => {
|
||||
@@ -56,7 +60,7 @@ describe("buildScriptHostname", () => {
|
||||
branchName: "feature-auth-flow",
|
||||
scriptName: "web-api-dev",
|
||||
}),
|
||||
).toBe("web-api-dev.feature-auth-flow.paseo-app.localhost");
|
||||
).toBe("web-api-dev--feature-auth-flow--paseo-app.localhost");
|
||||
});
|
||||
|
||||
it("uses untitled as the hostname-label fallback when labels collapse to empty", () => {
|
||||
@@ -66,6 +70,57 @@ describe("buildScriptHostname", () => {
|
||||
branchName: "***",
|
||||
scriptName: "---",
|
||||
}),
|
||||
).toBe("untitled.untitled.untitled.localhost");
|
||||
).toBe("untitled--untitled--untitled.localhost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPublicScriptHostname", () => {
|
||||
it("uses one combined service label under the configured public base host", () => {
|
||||
expect(
|
||||
buildPublicScriptHostname({
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
projectSlug: "paseo",
|
||||
branchName: "feature-auth",
|
||||
scriptName: "web",
|
||||
}),
|
||||
).toBe("web--feature-auth--paseo.services.example.com");
|
||||
});
|
||||
|
||||
it("omits default branch names from the public service label", () => {
|
||||
expect(
|
||||
buildPublicScriptHostname({
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
projectSlug: "paseo",
|
||||
branchName: "main",
|
||||
scriptName: "web",
|
||||
}),
|
||||
).toBe("web--paseo.services.example.com");
|
||||
});
|
||||
|
||||
it("caps the public service label to the DNS label length limit", () => {
|
||||
const hostname = buildPublicScriptHostname({
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
projectSlug: "project-".repeat(10),
|
||||
branchName: "branch-".repeat(10),
|
||||
scriptName: "script-".repeat(10),
|
||||
});
|
||||
const [serviceLabel] = hostname.split(".");
|
||||
|
||||
expect(serviceLabel.length).toBeLessThanOrEqual(63);
|
||||
expect(serviceLabel).toMatch(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
|
||||
expect(hostname).toBe(`${serviceLabel}.services.example.com`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPublicScriptProxyUrl", () => {
|
||||
it("preserves the configured public base protocol and port", () => {
|
||||
expect(
|
||||
buildPublicScriptProxyUrl({
|
||||
publicBaseUrl: "https://services.example.com:8443/base-is-ignored",
|
||||
projectSlug: "paseo",
|
||||
branchName: "feature-auth",
|
||||
scriptName: "web",
|
||||
}),
|
||||
).toBe("https://web--feature-auth--paseo.services.example.com:8443");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { slugify } from "./worktree.js";
|
||||
import {
|
||||
buildLocalServiceHostname,
|
||||
buildPublicServiceHostname,
|
||||
buildPublicServiceProxyUrl,
|
||||
} from "../server/service-proxy.js";
|
||||
|
||||
// Compatibility boundary for older tests/imports; new service proxy code owns hostname rules.
|
||||
|
||||
interface BuildScriptHostnameOptions {
|
||||
projectSlug: string;
|
||||
@@ -6,8 +12,8 @@ interface BuildScriptHostnameOptions {
|
||||
scriptName: string;
|
||||
}
|
||||
|
||||
function toHostnameLabel(value: string): string {
|
||||
return slugify(value) || "untitled";
|
||||
interface BuildPublicScriptHostnameOptions extends BuildScriptHostnameOptions {
|
||||
publicBaseUrl: string;
|
||||
}
|
||||
|
||||
export function buildScriptHostname({
|
||||
@@ -15,13 +21,16 @@ export function buildScriptHostname({
|
||||
branchName,
|
||||
scriptName,
|
||||
}: BuildScriptHostnameOptions): string {
|
||||
const serviceHostnameLabel = toHostnameLabel(scriptName);
|
||||
const projectHostnameLabel = toHostnameLabel(projectSlug);
|
||||
const isDefaultBranch = branchName === null || branchName === "main" || branchName === "master";
|
||||
|
||||
if (isDefaultBranch) {
|
||||
return `${serviceHostnameLabel}.${projectHostnameLabel}.localhost`;
|
||||
}
|
||||
|
||||
return `${serviceHostnameLabel}.${toHostnameLabel(branchName)}.${projectHostnameLabel}.localhost`;
|
||||
return buildLocalServiceHostname({ projectSlug, branchName, scriptName });
|
||||
}
|
||||
|
||||
export function buildPublicScriptHostname({
|
||||
publicBaseUrl,
|
||||
...script
|
||||
}: BuildPublicScriptHostnameOptions): string {
|
||||
return buildPublicServiceHostname({ publicBaseUrl, ...script });
|
||||
}
|
||||
|
||||
export function buildPublicScriptProxyUrl(options: BuildPublicScriptHostnameOptions): string {
|
||||
return buildPublicServiceProxyUrl(options);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user