mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b3ec572cb | ||
|
|
806079c7ad | ||
|
|
1f6ffa9e0e | ||
|
|
266d042528 | ||
|
|
0ba2f6b194 | ||
|
|
5b22aedaff | ||
|
|
e3b6e2dcfa | ||
|
|
0d9bda12e6 | ||
|
|
a94bc0720c | ||
|
|
7504d20b67 | ||
|
|
db39417be9 |
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [boudra]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -0,0 +1,109 @@
|
||||
# File Icons
|
||||
|
||||
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
|
||||
|
||||
Icons are inlined as SVG strings in:
|
||||
|
||||
```
|
||||
packages/app/src/components/material-file-icons.ts
|
||||
```
|
||||
|
||||
This file is auto-generated. Do not edit it by hand.
|
||||
|
||||
## How it works
|
||||
|
||||
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
|
||||
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
|
||||
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
|
||||
|
||||
## Adding a new icon
|
||||
|
||||
1. Find the icon name in the material-icon-theme manifest:
|
||||
|
||||
```bash
|
||||
node -e "
|
||||
const m = require('./node_modules/material-icon-theme/dist/material-icons.json');
|
||||
console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']);
|
||||
console.log('languageIds:', m.languageIds['YOUR_LANG']);
|
||||
"
|
||||
```
|
||||
|
||||
2. Verify the SVG exists:
|
||||
|
||||
```bash
|
||||
cat node_modules/material-icon-theme/icons/ICON_NAME.svg
|
||||
```
|
||||
|
||||
3. Add two things to `material-file-icons.ts`:
|
||||
|
||||
- The SVG string in `SVG_ICONS`:
|
||||
```ts
|
||||
"icon_name": `<svg ...>...</svg>`,
|
||||
```
|
||||
|
||||
- The extension mapping in `EXTENSION_TO_ICON`:
|
||||
```ts
|
||||
"ext": "icon_name",
|
||||
```
|
||||
|
||||
4. Run `npm run typecheck` to verify.
|
||||
|
||||
## Currently included icons
|
||||
|
||||
53 unique icons covering these extensions:
|
||||
|
||||
| Extension(s) | Icon |
|
||||
|---|---|
|
||||
| `ts` | typescript |
|
||||
| `tsx` | react_ts |
|
||||
| `js` | javascript |
|
||||
| `jsx` | react |
|
||||
| `py` | python |
|
||||
| `go` | go |
|
||||
| `rs` | rust |
|
||||
| `rb` | ruby |
|
||||
| `java` | java |
|
||||
| `kt` | kotlin |
|
||||
| `c` | c |
|
||||
| `cpp` | cpp |
|
||||
| `h` | h |
|
||||
| `hpp` | hpp |
|
||||
| `cs` | csharp |
|
||||
| `swift` | swift |
|
||||
| `dart` | dart |
|
||||
| `ex`, `exs` | elixir |
|
||||
| `erl` | erlang |
|
||||
| `hs` | haskell |
|
||||
| `clj` | clojure |
|
||||
| `scala` | scala |
|
||||
| `ml` | ocaml |
|
||||
| `r` | r |
|
||||
| `lua` | lua |
|
||||
| `zig` | zig |
|
||||
| `nix` | nix |
|
||||
| `php` | php |
|
||||
| `html` | html |
|
||||
| `css` | css |
|
||||
| `scss` | sass |
|
||||
| `less` | less |
|
||||
| `json` | json |
|
||||
| `yml`, `yaml` | yaml |
|
||||
| `xml` | xml |
|
||||
| `toml` | toml |
|
||||
| `md`, `markdown` | markdown |
|
||||
| `sql` | database |
|
||||
| `graphql`, `gql` | graphql |
|
||||
| `sh`, `bash` | console |
|
||||
| `tf` | terraform |
|
||||
| `hcl` | hcl |
|
||||
| `vue` | vue |
|
||||
| `svelte` | svelte |
|
||||
| `astro` | astro |
|
||||
| `wasm` | webassembly |
|
||||
| `svg` | svg |
|
||||
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
|
||||
| `txt` | document |
|
||||
| `conf`, `cfg`, `ini` | settings |
|
||||
| `lock` | lock |
|
||||
| `groovy` | groovy |
|
||||
| `gradle` | gradle |
|
||||
Generated
+103
@@ -14831,6 +14831,13 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/chroma-js": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz",
|
||||
"integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==",
|
||||
"dev": true,
|
||||
"license": "(BSD-3-Clause AND Apache-2.0)"
|
||||
},
|
||||
"node_modules/chrome-launcher": {
|
||||
"version": "0.15.2",
|
||||
"resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz",
|
||||
@@ -15855,6 +15862,33 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-rename-keys": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/deep-rename-keys/-/deep-rename-keys-0.2.1.tgz",
|
||||
"integrity": "sha512-RHd9ABw4Fvk+gYDWqwOftG849x0bYOySl/RgX0tLI9i27ZIeSO91mLZJEp7oPHOMFqHvpgu21YptmDt0FYD/0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"kind-of": "^3.0.2",
|
||||
"rename-keys": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-rename-keys/node_modules/kind-of": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
|
||||
"integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-buffer": "^1.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
@@ -18423,6 +18457,13 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
|
||||
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
@@ -25292,6 +25333,25 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/material-icon-theme": {
|
||||
"version": "5.32.0",
|
||||
"resolved": "https://registry.npmjs.org/material-icon-theme/-/material-icon-theme-5.32.0.tgz",
|
||||
"integrity": "sha512-SxJxCcnk6cJIbd+AxmoeghXJ24joXGmUzjiGci16sX4mXZdXprGEzM6ZZ0VHGAofxNlMqznEbExINwFLsxf8eQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chroma-js": "^3.1.2",
|
||||
"events": "^3.3.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"svgson": "^5.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.55.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/material-extensions"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -30156,6 +30216,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rename-keys": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/rename-keys/-/rename-keys-1.2.0.tgz",
|
||||
"integrity": "sha512-U7XpAktpbSgHTRSNRrjKSrjYkZKuhUukfoBlXWXUExCAqhzh1TU3BDRAfJmarcl5voKS+pbKU9MvyLWKZ4UEEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -32144,6 +32214,17 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svgson": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/svgson/-/svgson-5.3.1.tgz",
|
||||
"integrity": "sha512-qdPgvUNWb40gWktBJnbJRelWcPzkLed/ShhnRsjbayXz8OtdPOzbil9jtiZdrYvSDumAz/VNQr6JaNfPx/gvPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deep-rename-keys": "^0.2.1",
|
||||
"xml-reader": "2.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
@@ -34550,6 +34631,16 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-lexer": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/xml-lexer/-/xml-lexer-0.2.2.tgz",
|
||||
"integrity": "sha512-G0i98epIwiUEiKmMcavmVdhtymW+pCAohMRgybyIME9ygfVu8QheIi+YoQh3ngiThsT0SQzJT4R0sKDEv8Ou0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
|
||||
@@ -34560,6 +34651,17 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-reader": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/xml-reader/-/xml-reader-2.4.3.tgz",
|
||||
"integrity": "sha512-xWldrIxjeAMAu6+HSf9t50ot1uL5M+BtOidRCWHXIeewvSeIpscWCsp4Zxjk8kHHhdqFBrfK8U0EJeCcnyQ/gA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^2.0.0",
|
||||
"xml-lexer": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz",
|
||||
@@ -34947,6 +35049,7 @@
|
||||
"eas-cli": "^16.24.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"material-icon-theme": "^5.32.0",
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||
|
||||
type WorkspaceSetupDaemonClient = {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
openProject(
|
||||
cwd: string,
|
||||
): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>;
|
||||
createPaseoWorktree(
|
||||
input: { cwd: string; worktreeSlug?: string },
|
||||
): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>;
|
||||
listTerminals(
|
||||
cwd?: string,
|
||||
): Promise<{ cwd?: string; terminals: Array<{ id: string; name: string }>; requestId: string }>;
|
||||
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
|
||||
};
|
||||
|
||||
export type WorkspaceSetupProgressPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_setup_progress" }
|
||||
>["payload"];
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: { url: string; clientId: string; clientType: "cli" }) => WorkspaceSetupDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => WorkspaceSetupDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `workspace-setup-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function seedProjectForWorkspaceSetup(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<void> {
|
||||
const result = await client.openProject(repoPath);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function projectNameFromPath(repoPath: string): string {
|
||||
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
|
||||
}
|
||||
|
||||
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await expect(createWorkspaceButton(page, repoPath)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
function createWorkspaceButton(page: Page, repoPath: string) {
|
||||
return page.getByRole("button", {
|
||||
name: `Create a new workspace for ${projectNameFromPath(repoPath)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function revealWorkspaceButton(page: Page, repoPath: string): Promise<void> {
|
||||
await page.getByTestId(`sidebar-project-row-${repoPath}`).hover();
|
||||
}
|
||||
|
||||
export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise<void> {
|
||||
await revealWorkspaceButton(page, repoPath);
|
||||
await expect(createWorkspaceButton(page, repoPath)).toBeEnabled({ timeout: 30_000 });
|
||||
await createWorkspaceButton(page, repoPath).click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectSetupPanel(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-setup-panel")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectSetupStatus(
|
||||
page: Page,
|
||||
status: "Running" | "Completed" | "Failed",
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-setup-status")).toContainText(status, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectSetupLogContains(page: Page, text: string): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-setup-log")).toContainText(text, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectNoSetupMessage(page: Page): Promise<void> {
|
||||
await expect(page.getByText("No setup commands ran for this workspace.", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWorkspaceThroughDaemon(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
input: { cwd: string; worktreeSlug: string },
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const result = await client.createPaseoWorktree(input);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
|
||||
}
|
||||
return result.workspace;
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceSetupProgress(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<WorkspaceSetupProgressPayload> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error(`Timed out waiting for workspace_setup_progress after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
const unsubscribe = client.subscribeRawMessages((message) => {
|
||||
if (message.type !== "workspace_setup_progress") {
|
||||
return;
|
||||
}
|
||||
if (!predicate(message.payload)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(message.payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,11 @@ type TempRepo = {
|
||||
|
||||
export const createTempGitRepo = async (
|
||||
prefix = "paseo-e2e-",
|
||||
options?: { withRemote?: boolean },
|
||||
options?: {
|
||||
withRemote?: boolean;
|
||||
paseoConfig?: Record<string, unknown>;
|
||||
files?: Array<{ path: string; content: string }>;
|
||||
},
|
||||
): Promise<TempRepo> => {
|
||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
||||
@@ -22,7 +26,24 @@ export const createTempGitRepo = async (
|
||||
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
||||
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
||||
if (options?.paseoConfig) {
|
||||
await writeFile(
|
||||
path.join(repoPath, "paseo.json"),
|
||||
JSON.stringify(options.paseoConfig, null, 2),
|
||||
);
|
||||
}
|
||||
for (const file of options?.files ?? []) {
|
||||
const filePath = path.join(repoPath, file.path);
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, file.content);
|
||||
}
|
||||
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
|
||||
if (options?.paseoConfig) {
|
||||
execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
for (const file of options?.files ?? []) {
|
||||
execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
if (withRemote) {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
createWorkspaceFromSidebar,
|
||||
expectSetupPanel,
|
||||
expectSetupStatus,
|
||||
openHomeWithProject,
|
||||
seedProjectForWorkspaceSetup,
|
||||
} from "./helpers/workspace-setup";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composable helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Waits for the globe icon to appear on a workspace row (proves services are running). */
|
||||
async function expectGlobeIcon(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-globe-icon")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** Hovers the workspace row (by visible name) and waits for the hover card to appear. */
|
||||
async function expectHoverCard(page: Page, workspaceName: string): Promise<void> {
|
||||
const row = page.getByRole("button", { name: workspaceName }).first();
|
||||
await row.hover();
|
||||
await expect(page.getByTestId("workspace-hover-card")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/** Asserts that a service row with the given name exists in the hover card. */
|
||||
async function expectServiceInCard(page: Page, serviceName: string): Promise<void> {
|
||||
const card = page.getByTestId("workspace-hover-card");
|
||||
await expect(card.getByTestId(`hover-card-service-${serviceName}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Asserts the service status dot indicates "running". */
|
||||
async function expectServiceRunning(page: Page, serviceName: string): Promise<void> {
|
||||
const card = page.getByTestId("workspace-hover-card");
|
||||
await expect(
|
||||
card.getByTestId(`hover-card-service-status-${serviceName}`),
|
||||
).toHaveAttribute("aria-label", "Running", { timeout: 10_000 });
|
||||
}
|
||||
|
||||
/** Asserts the hover card contains the workspace name. */
|
||||
async function expectWorkspaceNameInCard(page: Page, name: string): Promise<void> {
|
||||
const card = page.getByTestId("workspace-hover-card");
|
||||
await expect(card.getByTestId("hover-card-workspace-name")).toContainText(name, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Moves the mouse away from the sidebar and asserts the hover card disappears. */
|
||||
async function expectHoverCardDismissed(page: Page): Promise<void> {
|
||||
// Move mouse to the center of the viewport (away from sidebar)
|
||||
const viewport = page.viewportSize();
|
||||
await page.mouse.move((viewport?.width ?? 1280) / 2, (viewport?.height ?? 720) / 2);
|
||||
await expect(page.getByTestId("workspace-hover-card")).not.toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Workspace hover card", () => {
|
||||
test("shows hover card with services when hovering a workspace with running services", async ({
|
||||
page,
|
||||
}) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("hovercard-svc-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
|
||||
},
|
||||
services: {
|
||||
web: {
|
||||
command:
|
||||
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await createWorkspaceFromSidebar(page, repo.path);
|
||||
|
||||
// Wait for setup to complete and workspace to be usable
|
||||
await expectSetupPanel(page);
|
||||
await expectSetupStatus(page, "Completed");
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
// Wait for the globe icon — proves services are running and client has the data
|
||||
await expectGlobeIcon(page);
|
||||
|
||||
// Read the workspace name from the page header (the mnemonic name, e.g. "upbeat-crab")
|
||||
const workspaceHeader = page.getByTestId("workspace-tabs-row");
|
||||
await expect(workspaceHeader).toBeVisible({ timeout: 10_000 });
|
||||
// The workspace name is the second workspace row button in the sidebar under the worktree project
|
||||
// We can find it by looking for the workspace row that has the globe icon next to it
|
||||
const globeIcon = page.getByTestId("workspace-globe-icon");
|
||||
const workspaceRow = page.locator('[data-testid^="sidebar-workspace-row-"]', {
|
||||
has: globeIcon,
|
||||
});
|
||||
const workspaceName =
|
||||
(await workspaceRow.locator("button").first().innerText()).trim() || "workspace";
|
||||
|
||||
// Hover the workspace row — hover card should appear
|
||||
await expectHoverCard(page, workspaceName);
|
||||
|
||||
// Assert the card shows the workspace name
|
||||
await expectWorkspaceNameInCard(page, workspaceName);
|
||||
|
||||
// Assert the "web" service entry exists in the card
|
||||
await expectServiceInCard(page, "web");
|
||||
|
||||
// Assert the status dot shows "running"
|
||||
await expectServiceRunning(page, "web");
|
||||
|
||||
// Assert the service row is a link (has role="link")
|
||||
const card = page.getByTestId("workspace-hover-card");
|
||||
const serviceLink = card.getByRole("link", { name: "web service" });
|
||||
await expect(serviceLink).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Move mouse away — card should dismiss
|
||||
await expectHoverCardDismissed(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
createWorkspaceFromSidebar,
|
||||
createWorkspaceThroughDaemon,
|
||||
expectSetupLogContains,
|
||||
expectSetupPanel,
|
||||
expectSetupStatus,
|
||||
openHomeWithProject,
|
||||
seedProjectForWorkspaceSetup,
|
||||
waitForWorkspaceSetupProgress,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
test.describe("Workspace setup streaming", () => {
|
||||
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-open-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await createWorkspaceFromSidebar(page, repo.path);
|
||||
|
||||
await expectSetupPanel(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("runs setup through the sidebar and leaves the workspace usable", async ({ page }) => {
|
||||
const setupTriggerPath = `/tmp/setup-trigger-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-ui-flow-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: [
|
||||
`sh -c 'while [ ! -f "${setupTriggerPath}" ]; do sleep 0.2; done; echo starting setup; sleep 1; echo loading dependencies; sleep 1; echo setup complete'`,
|
||||
],
|
||||
},
|
||||
},
|
||||
files: [{ path: "src/index.ts", content: "export const ready = true;\n" }],
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await createWorkspaceFromSidebar(page, repo.path);
|
||||
|
||||
await expectSetupPanel(page);
|
||||
await expectSetupStatus(page, "Running");
|
||||
await writeFile(setupTriggerPath, "start\n");
|
||||
await expectSetupLogContains(page, "starting setup");
|
||||
await expectSetupLogContains(page, "loading dependencies");
|
||||
await expectSetupStatus(page, "Completed");
|
||||
await expectSetupLogContains(page, "setup complete");
|
||||
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") {
|
||||
await explorerToggle.click();
|
||||
}
|
||||
await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("src", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await rm(setupTriggerPath, { force: true });
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("streams running and completed setup snapshots for a successful setup", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-success-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const initialRunning = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "running" && payload.detail.log === "",
|
||||
);
|
||||
const runningWithOutput = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
|
||||
);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-success",
|
||||
});
|
||||
|
||||
const initialPayload = await initialRunning;
|
||||
const runningPayload = await runningWithOutput;
|
||||
const completedPayload = await completed;
|
||||
|
||||
expect(initialPayload.detail.log).toBe("");
|
||||
expect(runningPayload.detail.log).toContain("starting setup");
|
||||
expect(completedPayload.detail.log).toContain("setup complete");
|
||||
expect(completedPayload.error).toBeNull();
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("streams a failed setup snapshot when setup fails", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-failure-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo starting setup; sleep 2; echo setup failed 1>&2; exit 1'"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const failed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-failure",
|
||||
});
|
||||
|
||||
const failedPayload = await failed;
|
||||
expect(failedPayload.detail.log).toContain("starting setup");
|
||||
expect(failedPayload.detail.log).toContain("setup failed");
|
||||
expect(failedPayload.error).toMatch(/failed/i);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-none-");
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" &&
|
||||
payload.detail.commands.length === 0 &&
|
||||
payload.detail.log === "",
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-none",
|
||||
});
|
||||
|
||||
const completedPayload = await completed;
|
||||
expect(completedPayload.error).toBeNull();
|
||||
expect(completedPayload.detail.commands).toEqual([]);
|
||||
expect(completedPayload.detail.log).toBe("");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("launches service terminals after setup completes", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-svc-ui-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
|
||||
},
|
||||
services: {
|
||||
web: {
|
||||
command:
|
||||
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await createWorkspaceFromSidebar(page, repo.path);
|
||||
|
||||
await expectSetupPanel(page);
|
||||
await expectSetupStatus(page, "Completed");
|
||||
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
// Wait for the service terminal tab to appear in the tabs bar
|
||||
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]', {
|
||||
hasText: "web",
|
||||
});
|
||||
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Click the service terminal tab
|
||||
await terminalTab.click();
|
||||
|
||||
// Verify the terminal surface rendered
|
||||
await expect(page.getByTestId("terminal-surface").first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Verify the terminal output contains "listening on" (xterm renders text in .xterm-rows)
|
||||
await expect(page.locator(".xterm-rows").first()).toContainText("listening on", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("launches workspace services after setup completes", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-services-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
|
||||
},
|
||||
services: {
|
||||
editor: {
|
||||
command: "npm run dev",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-services",
|
||||
});
|
||||
|
||||
await completed;
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const terminals = await client.listTerminals(workspace.id);
|
||||
return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null;
|
||||
})
|
||||
.toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: "editor",
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -111,6 +111,7 @@
|
||||
"eas-cli": "^16.24.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"material-icon-theme": "^5.32.0",
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
|
||||
@@ -31,6 +31,9 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
|
||||
if (openIntent.kind === "file") {
|
||||
return { kind: "file", path: openIntent.path };
|
||||
}
|
||||
if (openIntent.kind === "setup") {
|
||||
return { kind: "setup", workspaceId: openIntent.workspaceId };
|
||||
}
|
||||
return { kind: "draft", draftId: openIntent.draftId };
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { GitDiffPane } from "./git-diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
@@ -336,12 +337,13 @@ function SidebarContent({
|
||||
onOpenFile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const padding = useWindowControlsPadding("explorerSidebar");
|
||||
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
{/* Header with tabs and close button */}
|
||||
<View style={styles.header} testID="explorer-header">
|
||||
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
|
||||
<View style={styles.tabsContainer}>
|
||||
{isGit && (
|
||||
<Pressable
|
||||
|
||||
@@ -26,18 +26,17 @@ import Animated, {
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Download,
|
||||
File,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Image as ImageIcon,
|
||||
MoreVertical,
|
||||
RotateCw,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -65,7 +64,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: "size", label: "Size" },
|
||||
];
|
||||
|
||||
const INDENT_PER_LEVEL = 12;
|
||||
const INDENT_PER_LEVEL = 16;
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
@@ -384,7 +383,6 @@ export function FileExplorerPane({
|
||||
({ item }: ListRenderItemInfo<TreeRow>) => {
|
||||
const entry = item.entry;
|
||||
const depth = item.depth;
|
||||
const displayKind = getEntryDisplayKind(entry);
|
||||
const isDirectory = entry.kind === "directory";
|
||||
const isExpanded = isDirectory && expandedPaths.has(entry.path);
|
||||
const isSelected = selectedEntryPath === entry.path;
|
||||
@@ -399,16 +397,30 @@ export function FileExplorerPane({
|
||||
(hovered || pressed || isSelected) && styles.entryRowActive,
|
||||
]}
|
||||
>
|
||||
{depth > 0 &&
|
||||
Array.from({ length: depth }, (_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
styles.indentGuide,
|
||||
{
|
||||
left: theme.spacing[3] + i * INDENT_PER_LEVEL + 4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
<View style={styles.entryInfo}>
|
||||
<View style={styles.entryIcon}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" />
|
||||
{isDirectory ? (
|
||||
loading ? (
|
||||
<ActivityIndicator size="small" />
|
||||
) : (
|
||||
<View style={[styles.chevron, isExpanded && styles.chevronExpanded]}>
|
||||
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
renderEntryIcon(isDirectory ? "directory" : displayKind, {
|
||||
foreground: theme.colors.foregroundMuted,
|
||||
primary: theme.colors.primary,
|
||||
directoryOpen: isExpanded,
|
||||
})
|
||||
<SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.entryName} numberOfLines={1}>
|
||||
@@ -552,27 +564,31 @@ export function FileExplorerPane({
|
||||
) : (
|
||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<View style={styles.paneHeaderLeft} />
|
||||
<View style={styles.paneHeaderRight}>
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshFetching}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.iconButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={handleSortCycle}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.sortTrigger,
|
||||
(hovered || pressed) && styles.sortTriggerHovered,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
|
||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshFetching}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.iconButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</View>
|
||||
<FlatList
|
||||
ref={treeListRef}
|
||||
@@ -609,99 +625,6 @@ export function FileExplorerPane({
|
||||
);
|
||||
}
|
||||
|
||||
type EntryDisplayKind = "directory" | "image" | "text" | "other";
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
"txt",
|
||||
"md",
|
||||
"markdown",
|
||||
"ts",
|
||||
"tsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"json",
|
||||
"yml",
|
||||
"yaml",
|
||||
"toml",
|
||||
"py",
|
||||
"rb",
|
||||
"go",
|
||||
"rs",
|
||||
"java",
|
||||
"kt",
|
||||
"c",
|
||||
"cpp",
|
||||
"cc",
|
||||
"h",
|
||||
"hpp",
|
||||
"cs",
|
||||
"swift",
|
||||
"php",
|
||||
"html",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"xml",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
]);
|
||||
|
||||
function renderEntryIcon(
|
||||
kind: EntryDisplayKind,
|
||||
colors: { foreground: string; primary: string; directoryOpen?: boolean },
|
||||
) {
|
||||
const color = colors.foreground;
|
||||
switch (kind) {
|
||||
case "directory":
|
||||
return colors.directoryOpen ? (
|
||||
<FolderOpen size={18} color={colors.primary} />
|
||||
) : (
|
||||
<Folder size={18} color={colors.primary} />
|
||||
);
|
||||
case "image":
|
||||
return <ImageIcon size={18} color={color} />;
|
||||
case "text":
|
||||
return <FileText size={18} color={color} />;
|
||||
default:
|
||||
return <File size={18} color={color} />;
|
||||
}
|
||||
}
|
||||
|
||||
function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind {
|
||||
if (entry.kind === "directory") {
|
||||
return "directory";
|
||||
}
|
||||
|
||||
const extension = getExtension(entry.name);
|
||||
if (extension === null) {
|
||||
return "other";
|
||||
}
|
||||
|
||||
if (IMAGE_EXTENSIONS.has(extension)) {
|
||||
return "image";
|
||||
}
|
||||
|
||||
if (TEXT_EXTENSIONS.has(extension)) {
|
||||
return "text";
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
function getExtension(name: string): string | null {
|
||||
const index = name.lastIndexOf(".");
|
||||
if (index === -1 || index === name.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return name.slice(index + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
|
||||
const sorted = [...entries];
|
||||
sorted.sort((a, b) => {
|
||||
@@ -842,50 +765,38 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: 0,
|
||||
},
|
||||
paneHeader: {
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingRight: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
paneHeaderLeft: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
paneHeaderRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
previewHeaderRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
sortButton: {
|
||||
height: 28,
|
||||
sortTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
gap: theme.spacing[1],
|
||||
marginLeft: theme.spacing[3] - theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.base,
|
||||
},
|
||||
sortButtonText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
sortTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sortTriggerText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
treeList: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
entriesContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
centerState: {
|
||||
@@ -936,8 +847,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 2,
|
||||
paddingRight: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
entryRowActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
indentGuide: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 1,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
entryInfo: {
|
||||
@@ -947,6 +866,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
chevron: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
chevronExpanded: {
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
entryIcon: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
} from "@/constants/layout";
|
||||
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
left?: ReactNode;
|
||||
@@ -26,15 +25,10 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
const padding = useWindowControlsPadding("header");
|
||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const baseHorizontalPadding = theme.spacing[2];
|
||||
const collapsedSidebarInset =
|
||||
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
|
||||
? trafficLightPadding
|
||||
: { left: 0, right: 0 };
|
||||
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
@@ -45,8 +39,8 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
|
||||
style={[
|
||||
styles.row,
|
||||
{
|
||||
paddingLeft: baseHorizontalPadding + collapsedSidebarInset.left,
|
||||
paddingRight: baseHorizontalPadding + collapsedSidebarInset.right,
|
||||
paddingLeft: baseHorizontalPadding + padding.left,
|
||||
paddingRight: baseHorizontalPadding + padding.right,
|
||||
},
|
||||
borderless && styles.borderless,
|
||||
]}
|
||||
@@ -90,6 +84,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
borderless: {
|
||||
borderBottomWidth: 0,
|
||||
borderBottomColor: "transparent",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
type SidebarProjectEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
||||
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
@@ -632,7 +632,7 @@ function DesktopSidebar({
|
||||
}: DesktopSidebarProps) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
@@ -682,9 +682,7 @@ function DesktopSidebar({
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
|
||||
{trafficLightPadding.side === 'left' ? (
|
||||
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
|
||||
) : null}
|
||||
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
|
||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SessionsButton onPress={handleViewMore} />
|
||||
@@ -811,7 +809,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative",
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Auto-generated from material-icon-theme. Do not edit manually.
|
||||
|
||||
const SVG_ICONS: Record<string, string> = {
|
||||
"_default": `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m8.668 6h3.6641l-3.6641-3.668v3.668m-4.668-4.668h5.332l4 4v8c0 0.73828-0.59375 1.3359-1.332 1.3359h-8c-0.73828 0-1.332-0.59766-1.332-1.3359v-10.664c0-0.74219 0.59375-1.3359 1.332-1.3359m3.332 1.3359h-3.332v10.664h8v-6h-4.668z" fill="#90a4ae" /></svg>`,
|
||||
"astro": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M12.106 25.849c-1.262-1.156-1.63-3.586-1.105-5.346a5.18 5.18 0 0 0 3.484 1.66 9.68 9.68 0 0 0 5.882-.734c.215-.106.413-.247.648-.39a3.5 3.5 0 0 1 .16 1.555 4.26 4.26 0 0 1-1.798 3.021c-.404.3-.832.569-1.25.852a2.613 2.613 0 0 0-1.15 3.372l.048.161a3.4 3.4 0 0 1-1.5-1.285 3.6 3.6 0 0 1-.578-1.962 9 9 0 0 0-.05-1.037c-.114-.831-.504-1.204-1.238-1.225a1.45 1.45 0 0 0-1.507 1.18c-.012.056-.028.112-.046.178M4.901 20a17.75 17.75 0 0 1 7.4-2l2.913-8.38a.765.765 0 0 1 1.527 0L19.7 18a14.24 14.24 0 0 1 7.399 2S20.704 2.877 20.692 2.842C20.51 2.33 20.202 2 19.787 2h-7.619c-.415 0-.71.33-.904.842z"/></svg>`,
|
||||
"c": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M19.563 22A5.57 5.57 0 0 1 14 16.437v-2.873A5.57 5.57 0 0 1 19.563 8H24V2h-4.437A11.563 11.563 0 0 0 8 13.563v2.873A11.564 11.564 0 0 0 19.563 28H24v-6Z"/></svg>`,
|
||||
"clojure": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="#64dd17" d="M123.456 129.975a507 507 0 0 0-3.54 7.846c-4.406 9.981-9.284 22.127-11.066 29.908-.64 2.77-1.037 6.205-1.03 10.013 0 1.506.081 3.09.21 4.702a58.1 58.1 0 0 0 19.98 3.559 58.2 58.2 0 0 0 18.29-2.98c-1.352-1.237-2.642-2.554-3.816-4.038-7.796-9.942-12.146-24.512-19.028-49.01m-28.784-49.39C79.782 91.08 70.039 108.387 70.002 128c.037 19.32 9.487 36.403 24.002 46.94 3.56-14.83 12.485-28.41 25.868-55.63a219 219 0 0 0-2.714-7.083c-3.708-9.3-9.059-20.102-13.834-24.993-2.435-2.555-5.389-4.763-8.652-6.648"/><path fill="#7cb342" d="M178.532 194.535c-7.683-.963-14.023-2.124-19.57-4.081a69.4 69.4 0 0 1-30.958 7.249c-38.491 0-69.693-31.198-69.698-69.7 0-20.891 9.203-39.62 23.764-52.392-3.895-.94-7.956-1.49-12.104-1.482-20.45.193-42.037 11.51-51.025 42.075-.84 4.45-.64 7.813-.64 11.8 0 60.591 49.12 109.715 109.705 109.715 37.104 0 69.882-18.437 89.732-46.633-10.736 2.675-21.06 3.955-29.902 3.982-3.314 0-6.425-.177-9.305-.53"/><path fill="#29b6f6" d="M157.922 173.271c.678.336 2.213.884 4.35 1.49 14.375-10.553 23.717-27.552 23.754-46.764h-.005c-.055-32.03-25.974-57.945-58.011-58.009a58.2 58.2 0 0 0-18.213 2.961c11.779 13.426 17.443 32.613 22.922 53.6l.01.025c.01.017 1.752 5.828 4.743 13.538 2.97 7.7 7.203 17.231 11.818 24.178 3.03 4.655 6.363 8 8.632 8.981"/><path fill="#1e88e5" d="M128.009 18.29c-36.746 0-69.25 18.089-89.16 45.826 10.361-6.49 20.941-8.83 30.174-8.747 12.753.037 22.779 3.991 27.589 6.696a51 51 0 0 1 3.345 2.131 69.4 69.4 0 0 1 28.049-5.894c38.496.004 69.703 31.202 69.709 69.698h-.006c0 19.409-7.938 36.957-20.736 49.594 3.142.352 6.492.571 9.912.554 12.15.006 25.284-2.675 35.13-10.956 6.42-5.408 11.798-13.327 14.78-25.199.584-4.586.92-9.247.92-13.991 0-60.588-49.116-109.715-109.705-109.715"/></svg>`,
|
||||
"console": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff7043" d="M2 2a1 1 0 0 0-1 1v10c0 .554.446 1 1 1h12c.554 0 1-.446 1-1V3a1 1 0 0 0-1-1zm0 3h12v8H2zm1 2 2 2-2 2 1 1 3-3-3-3zm5 3.5V12h5v-1.5z"/></svg>`,
|
||||
"cpp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 14v-4h-2v4h-6v-4h-2v4h-4v2h4v4h2v-4h6v4h2v-4h4v-2z"/><path fill="#0288d1" d="M13.563 22A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
|
||||
"csharp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M30 14v-2h-2V8h-2v4h-2V8h-2v4h-2v2h2v2h-2v2h2v4h2v-4h2v4h2v-4h2v-2h-2v-2Zm-4 2h-2v-2h2Zm-12.437 6A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
|
||||
"css": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7e57c2" d="M20 18h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 20 22h2v2h2v-2c0-.388-.562-.851-1.254-1.034C20.356 20.34 20 18.84 20 18m-3.254 2.966C14.356 20.34 14 18.84 14 18h-2v-2h-2v8h2v-2h4v2h2v-2c0-.388-.562-.851-1.254-1.034"/><path fill="#7e57c2" d="M24 4H4v20a4 4 0 0 0 4 4h16.16A3.84 3.84 0 0 0 28 24.16V8a4 4 0 0 0-4-4m2 14h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 26 22v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2Z"/></svg>`,
|
||||
"dart": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#4fc3f7" d="M16.83 2a1.3 1.3 0 0 0-.916.377l-.013.01L7.323 7.34l8.556 8.55v.005l10.283 10.277 1.96-3.529-7.068-16.96-3.299-3.297A1.3 1.3 0 0 0 16.828 2Z"/><path fill="#01579b" d="m7.343 7.32-4.955 8.565-.01.013a1.297 1.297 0 0 0 .004 1.835l.005.005 4.106 4.107 16.064 6.314 3.632-2.015-.098-.098-.025.002L15.995 15.97h-.012z"/><path fill="#01579b" d="m7.321 7.324 8.753 8.755h.013L26.16 26.156l3.835-.73L30 14.089l-4.049-3.965a6.5 6.5 0 0 0-3.618-1.612l.002-.043L7.323 7.325Z"/><path fill="#64b5f6" d="m7.332 7.335 8.758 8.75v.013l10.079 10.071L25.436 30H14.09l-3.967-4.048a6.5 6.5 0 0 1-1.611-3.618l-.045.004Z"/></svg>`,
|
||||
"database": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="M16 24c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-8c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-12C10.477 4 6 4.895 6 6v4c0 1.1 4.475 2 10 2s10-.9 10-2V6c0-1.105-4.477-2-10-2"/></svg>`,
|
||||
"document": `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8zm4 18H6V4h7v5h5z"/></svg>`,
|
||||
"elixir": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#9575cd" d="M12.173 22.681c-3.86 0-6.99-3.64-6.99-8.13 0-3.678 2.773-8.172 4.916-10.91 1.014-1.296 2.93-2.322 2.93-2.322s-.982 5.239 1.683 7.319c2.366 1.847 4.106 4.25 4.106 6.363 0 4.232-2.784 7.68-6.645 7.68"/></svg>`,
|
||||
"erlang": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path fill="#f44336" d="M5.207 4.33q-.072.075-.143.153Q1.5 8.476 1.5 15.33c0 4.418 1.155 7.862 3.459 10.34h19.415c2.553-1.152 4.127-3.43 4.127-3.43l-3.147-2.52L23.9 21.1c-.867.773-.845.931-2.315 1.78-1.495.674-3.04.966-4.634.966-2.515 0-4.423-.909-5.723-2.059-1.286-1.15-1.985-4.511-2.096-6.68l17.458.067-.183-1.472s-.847-7.129-2.541-9.372zm8.76.846c1.565 0 3.22.535 3.961 1.471.74.937.931 1.667.973 3.524H9.11c.112-1.955.436-2.81 1.373-3.698.936-.887 2.03-1.297 3.484-1.297"/></svg>`,
|
||||
"go": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00acc1" d="M2 12h4v2H2zm-2 4h6v2H0zm4 4h2v2H4zm16.954-5H14v3h3.239a4.42 4.42 0 0 1-3.531 2 2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 15.292 13a2.73 2.73 0 0 1 1.749.584l2.962-1.185A5.6 5.6 0 0 0 15.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 6.4 6.4 0 0 0 .003-1.5"/><path fill="#00acc1" d="M26.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 5.614 5.614 0 0 0-5.659-6.5m2.681 6.137A4.515 4.515 0 0 1 24.708 20a2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 26.292 13a2.65 2.65 0 0 1 2.053.858 2.86 2.86 0 0 1 .628 2.28Z"/></svg>`,
|
||||
"gradle": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0097a7" d="M16 10v2h6c-2 0-3-2-6-2"/><path fill="#0097a7" d="M26 4h-2a4 4 0 0 0-4 4h4a1 1 0 0 1 2 0v4H16v-2h-5.317A2.683 2.683 0 0 0 8 12.683v2.634A2.683 2.683 0 0 0 10.683 18H16v2h-5.98A4.02 4.02 0 0 1 6 16v-2c-2 0-4 4-4 8 0 5 1 6 2 6h4v-4h4v4h4v-4h4v4h4v-6a2 2 0 0 0 2-2v-2a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4m-4 12h-2a2 2 0 0 1-2-2h6a2 2 0 0 1-2 2"/></svg>`,
|
||||
"graphql": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M6 20h20v2H6z"/><circle cx="7" cy="21" r="3" fill="#ec407a"/><circle cx="16" cy="27" r="3" fill="#ec407a"/><circle cx="25" cy="21" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 10h20v2H6z"/><circle cx="7" cy="11" r="3" fill="#ec407a"/><circle cx="16" cy="5" r="3" fill="#ec407a"/><circle cx="25" cy="11" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 12h2v10H6zm18-2h2v12h-2z"/><path fill="#ec407a" d="m5.014 19.41 11.674 6.866L15.674 28 4 21.134z"/><path fill="#ec407a" d="M26.688 21.724 15.014 28.59 14 26.866 25.674 20zM5.124 10.382l11.415-7.29 1.077 1.686L6.2 12.068z"/><path fill="#ec407a" d="m25.798 12.067-11.415-7.29 1.077-1.685 11.415 7.29zM6.2 19.932l11.416 7.29-1.077 1.686-11.415-7.29z"/><path fill="#ec407a" d="m26.875 21.619-11.415 7.29-1.077-1.687 11.415-7.289zM5.877 22.6 16.04 3.686l1.762.946L7.638 23.546z"/><path fill="#ec407a" d="M24.361 23.545 14.197 4.633l1.761-.947 10.165 18.913z"/></svg>`,
|
||||
"groovy": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#26c6da" d="M19.322 2a6.5 6.5 0 0 1 4.352 1.419 4.55 4.55 0 0 1 1.685 3.662 5.82 5.82 0 0 1-1.886 4.275 6.04 6.04 0 0 1-4.34 1.846 4.15 4.15 0 0 1-2.385-.649 1.91 1.91 0 0 1-.936-1.603 1.6 1.6 0 0 1 .356-1.024 1.1 1.1 0 0 1 .861-.447q.469 0 .468.504a.79.79 0 0 0 .358.693 1.43 1.43 0 0 0 .826.245 3.1 3.1 0 0 0 2.39-1.573 5.66 5.66 0 0 0 1.154-3.39 2.64 2.64 0 0 0-.891-2.064 3.28 3.28 0 0 0-2.293-.812 6.18 6.18 0 0 0-4.086 1.736 12.9 12.9 0 0 0-3.215 4.557 13.4 13.4 0 0 0-1.233 5.36 5.86 5.86 0 0 0 1.091 3.723 3.53 3.53 0 0 0 2.905 1.372q3.058 0 5.848-4.002l2.935-.388q.546-.07.545.246a8 8 0 0 1-.423 1.24q-.421 1.097-1.152 3.668A12.7 12.7 0 0 0 26 17.72v1.66a14.2 14.2 0 0 1-4.055 2.57 10.38 10.38 0 0 1-2.764 5.931 6.7 6.7 0 0 1-4.806 2.11 3.3 3.3 0 0 1-2.012-.55 1.8 1.8 0 0 1-.718-1.514q0-2.685 5.634-5.212.532-1.766 1.152-3.507a8.6 8.6 0 0 1-2.853 2.323 7.4 7.4 0 0 1-3.48 1.01 5.46 5.46 0 0 1-4.366-2.093A8.1 8.1 0 0 1 6 15.122a11.6 11.6 0 0 1 1.966-6.426 14.7 14.7 0 0 1 5.162-4.862A12.44 12.44 0 0 1 19.322 2m-2.407 22.17q-4.055 1.875-4.054 3.695a.87.87 0 0 0 .999.97q1.964 0 3.055-4.665"/></svg>`,
|
||||
"h": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M18.5 11a5.49 5.49 0 0 0-4.5 2.344V4H8v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
|
||||
"haskell": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><g stroke-width="2.422"><path fill="#ef5350" d="m23.928 240.5 59.94-89.852-59.94-89.855h44.955l59.94 89.855-59.94 89.852z"/><path fill="#ffa726" d="m83.869 240.5 59.94-89.852-59.94-89.855h44.955l119.88 179.71h-44.95l-37.46-56.156-37.468 56.156z"/><path fill="#ffee58" d="m228.72 188.08-19.98-29.953h69.93v29.956h-49.95zm-29.97-44.924-19.98-29.953h99.901v29.953z"/></g></svg>`,
|
||||
"hcl": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#eceff1" d="M18 1.2V14h-4v-4l-4 2v16.37l4 2.43V18h4v4l4-2V3.63z"/><path fill="#eceff1" d="M14 1.2 2 8.49v15.02l4 2.43v-15.2l8-4.86zm12 4.86v15.2l-8 4.86v4.68l12-7.29V8.49z"/></svg>`,
|
||||
"hpp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 6V2h-2v4h-6V2h-2v4h-4v2h4v4h2V8h6v4h2V8h4V6zm-15.5 5A5.49 5.49 0 0 0 8 13.344V4H2v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
|
||||
"html": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#e65100" d="m4 4 2 22 10 2 10-2 2-22Zm19.72 7H11.28l.29 3h11.86l-.802 9.335L15.99 25l-6.635-1.646L8.93 19h3.02l.19 2 3.86.77 3.84-.77.29-4H8.84L8 8h16Z"/></svg>`,
|
||||
"image": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#26a69a" d="M8.5 6h4l-4-4zM3.875 1H9.5l4 4v8.6c0 .773-.616 1.4-1.375 1.4h-8.25c-.76 0-1.375-.627-1.375-1.4V2.4c0-.777.612-1.4 1.375-1.4M4 13.6h8V8l-2.625 2.8L8 9.4zm1.25-7.7c-.76 0-1.375.627-1.375 1.4s.616 1.4 1.375 1.4c.76 0 1.375-.627 1.375-1.4S6.009 5.9 5.25 5.9"/></svg>`,
|
||||
"java": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="M4 26h24v2H4zM28 4H7a1 1 0 0 0-1 1v13a4 4 0 0 0 4 4h10a4 4 0 0 0 4-4v-4h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2m0 8h-4V6h4Z"/></svg>`,
|
||||
"javascript": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M2 2v12h12V2zm6 6h1v4a1.003 1.003 0 0 1-1 1H7a1.003 1.003 0 0 1-1-1v-1h1v1h1zm3 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
|
||||
"json": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="#f9a825" d="M560-160v-80h120q17 0 28.5-11.5T720-280v-80q0-38 22-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50 0 85 35t35 85v80q0 17 11.5 28.5T840-560h40v160h-40q-17 0-28.5 11.5T800-360v80q0 50-35 85t-85 35zm-280 0q-50 0-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17 0 28.5-11.5T160-600v-80q0-50 35-85t85-35h120v80H280q-17 0-28.5 11.5T240-680v80q0 38-22 69t-58 44v14q36 13 58 44t22 69v80q0 17 11.5 28.5T280-240h120v80z"/></svg>`,
|
||||
"kotlin": `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><defs><linearGradient id="a" x1="1.725" x2="22.185" y1="22.67" y2="1.982" gradientTransform="translate(1.306 1.129)scale(.89324)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7c4dff"/><stop offset=".5" stop-color="#d500f9"/><stop offset="1" stop-color="#ef5350"/></linearGradient></defs><path fill="url(#a)" d="M2.975 2.976v18.048h18.05v-.03l-4.478-4.511-4.48-4.515 4.48-4.515 4.443-4.477z"/></svg>`,
|
||||
"less": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0277bd" d="M8 3a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2H3v2h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h2v-2H8v-5a2 2 0 0 0-2-2 2 2 0 0 0 2-2V5h2V3m6 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3z"/></svg>`,
|
||||
"lock": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffd54f" d="M25 12h-3V8a6 6 0 0 0-12 0v4H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V13a1 1 0 0 0-1-1M14 8a2 2 0 0 1 4 0v4h-4Zm2 17a4 4 0 1 1 4-4 4 4 0 0 1-4 4"/></svg>`,
|
||||
"lua": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="M30 6a3.86 3.86 0 0 1-1.167 2.833 4.024 4.024 0 0 1-5.666 0A3.86 3.86 0 0 1 22 6a3.86 3.86 0 0 1 1.167-2.833 4.024 4.024 0 0 1 5.666 0A3.86 3.86 0 0 1 30 6m-9.208 5.208A10.6 10.6 0 0 0 13 8a10.6 10.6 0 0 0-7.792 3.208A10.6 10.6 0 0 0 2 19a10.6 10.6 0 0 0 3.208 7.792A10.6 10.6 0 0 0 13 30a10.6 10.6 0 0 0 7.792-3.208A10.6 10.6 0 0 0 24 19a10.6 10.6 0 0 0-3.208-7.792m-1.959 7.625a4.024 4.024 0 0 1-5.666 0 4.024 4.024 0 0 1 0-5.666 4.024 4.024 0 0 1 5.666 0 4.024 4.024 0 0 1 0 5.666"/></svg>`,
|
||||
"markdown": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="m14 10-4 3.5L6 10H4v12h4v-6l2 2 2-2v6h4V10zm12 6v-6h-4v6h-4l6 8 6-8z"/></svg>`,
|
||||
"nix": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><g stroke-width=".395"><path fill="#1976d2" d="M133.347 451.499c0-.295-2.752-5.283-6.116-11.084s-6.116-10.776-6.116-11.055 9.514-16.889 21.143-36.912c11.629-20.022 21.323-36.798 21.542-37.279.346-.76-1.608-4.363-14.896-27.466-8.412-14.625-15.294-26.785-15.294-27.023 0-.5 24.46-43.501 25.206-44.31.414-.45.592-.384 1.078.395.32.513 16.876 29.256 36.791 63.87 62.62 108.85 74.852 130.01 75.41 130.46.3.242.544.554.544.694s-11.836.21-26.302.154c-23.023-.09-26.313-.175-26.393-.694-.11-.714-27.662-48.825-28.86-50.392-.746-.978-.906-1.035-1.426-.51-.688.696-28.954 49.323-29.49 50.733l-.364.96h-13.23c-10.895 0-13.228-.095-13.228-.538zm167.58-125.61c-.134-.216 1.189-2.863 2.939-5.882 6.924-11.944 84.29-145.75 96.49-166.88 7.143-12.371 13.143-22.465 13.334-22.433.362.062 25.86 43.105 25.86 43.655 0 .174-6.761 11.952-15.025 26.173-8.46 14.557-14.932 26.104-14.81 26.421.185.483 4.563.564 30.213.564h29.996l.957 1.48c.527.814 3.296 5.547 6.155 10.518s5.45 9.29 5.757 9.597c.705.705.703.724-.16 1.572-.396.388-3.36 5.323-6.588 10.965-3.228 5.643-6.056 10.387-6.285 10.543s-19.695.171-43.256.034l-42.84-.249-.803 1.15c-.442.632-7.505 12.736-15.696 26.897l-14.892 25.747h-15.486c-8.518 0-20.015.116-25.551.259-6.55.168-10.15.121-10.308-.135zm-133.75-157.86c-56.373-.055-102.5-.182-102.5-.282s5.617-10.132 12.481-22.294L89.64 123.34h30.332c27.113 0 30.332-.065 30.332-.611 0-.336-6.659-12.228-14.797-26.427s-14.797-25.917-14.797-26.04 2.682-4.853 5.96-10.51 6.003-10.578 6.056-10.934c.086-.586 1.375-.648 13.572-.648 7.412 0 13.463.143 13.446.317-.018.174.22.707.53 1.184.31.476 9.763 16.937 21.007 36.578 11.244 19.64 20.71 36.022 21.036 36.4.554.647 2.549.691 31.428.691h30.837l12.896 22.145c7.093 12.18 12.8 22.301 12.682 22.492-.117.19-4.776.303-10.352.249-5.575-.054-56.26-.143-112.63-.198z"/><path fill="#64b5f6" d="M23.046 238.939c-6.098 10.563-6.69 11.711-6.224 12.078.282.224 3.18 5.044 6.44 10.712s6.016 10.355 6.123 10.417c.106.061 13.585.153 29.95.204 16.367.052 29.994.23 30.285.399.473.273-1.08 3.094-14.637 26.574l-15.166 26.269 12.907 21.865c7.1 12.026 12.982 21.906 13.068 21.956s23.257-39.831 51.492-88.624c11.352-19.617 21.214-36.64 30.37-52.442 23.308-40.452 30.68-53.468 30.73-54.132-1.096-.11-6.141-.187-13.006-.216-3.945-.01-7.82-.02-12.75-.002l-25.341.092-15.42 26.706c-14.256 24.693-15.445 26.663-16.278 26.86l-.023.037c-.012.003-1.622-.001-1.826 0-4.29.062-20.453.063-40.226-.01-22.632-.082-41.615-.125-42.183-.096-.567.03-1.147-.03-1.29-.132-.141-.102-3.29 5.066-6.996 11.485zm205.16-190.3c-.123.149 5.62 10.392 12.761 22.763 12.2 21.131 89.393 155.03 96.276 167 1.503 2.613 2.92 4.803 3.443 5.348.9-1.249 3.532-5.63 7.954-13.219a1343 1343 0 0 1 10.05-17.76l6.606-11.443c.691-1.403.753-1.818.652-2.117-.161-.48-6.903-12.332-14.982-26.337-8.078-14.005-14.824-25.849-14.99-26.32a.73.73 0 0 1-.01-.366l-.426-.913 21.636-36.976c3.69-6.307 6.425-11.042 9.471-16.29 9.158-15.948 12.036-21.189 11.895-21.55-.126-.324-2.7-4.83-5.72-10.017-3.021-5.185-5.845-10.148-6.275-11.026-.483-.987-.734-1.364-1.1-1.456-.054.014-.083.018-.144.035-.42.112-5.455.195-11.19.185s-11.22.024-12.187.073l-1.76.089-14.998 25.978c-12.824 22.212-15.084 25.964-15.595 25.883-.024-.004-.15-.189-.235-.301-.109.066-.2.09-.271.05-.256-.148-7.144-11.902-15.306-26.119L279.4 48.817c-.116-.186-.444-.744-.458-.752-.476-.275-50.502.287-50.737.57zm-18.646 283.09c-.047.109-.026.262.043.48.328 1.05 25.338 43.735 25.772 43.985.206.119 14.178.239 31.05.266 26.65.044 30.749.152 31.234.832.307.43 9.987 17.214 21.513 37.296s21.152 36.627 21.394 36.767 5.926.243 12.633.23c6.705-.013 12.4.099 12.657.246.131.076.381-.141.851-.795l6.008-10.406c5.234-9.065 6.62-11.684 6.294-11.888-.575-.36-15.597-26.643-23.859-41.482-3.09-5.45-5.37-9.516-5.44-9.774-.196-.712-.066-.822 1.155-.98 1.956-.252 57.397-.057 58.071.205.237.092.79-.569 2.593-3.497 1.866-3.067 5.03-8.524 11.001-18.866 7.22-12.505 13.043-22.784 12.941-22.843s-.77-.051-1.489.016l-.046.001c-4.451.204-33.918.203-149.74.025-38.96-.06-69.786-.09-71.912-.072-1.12.01-2.095.076-2.66.172a.3.3 0 0 0-.062.083z"/></g></svg>`,
|
||||
"ocaml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m12.019 15.021.003-.008c-.005-.021-.006-.026-.003.008"/><path fill="#ff9800" d="M4.51 3.273a2.523 2.523 0 0 0-2.524 2.523V11.3c.361-.13.88-.898 1.043-1.085.285-.327.337-.743.478-1.006C3.83 8.612 3.886 8.2 4.62 8.2c.342 0 .478.08.71.39.16.216.438.615.568.882.15.307.396.724.503.808q.122.095.233.137c.119.044.218-.037.297-.1.102-.082.145-.247.24-.467.135-.317.283-.697.367-.83.146-.23.195-.501.352-.633.232-.195.535-.208.618-.225.466-.092.677.225.907.43.15.133.355.403.5.765.114.283.26.544.32.707.059.158.203.41.289.713.077.275.286.486.365.616 0 0 .121.34.858.65.16.067.482.176.674.246.32.116.63.101 1.025.054.281 0 .434-.408.562-.734.075-.193.148-.745.197-.902.048-.153-.064-.27.031-.405.112-.156.178-.164.242-.368.138-.436.936-.458 1.384-.458.374 0 .327.363.96.239.364-.072.714.046 1.1.149.324.086.63.184.812.398.119.139.412.834.113.863.029.035.05.099.104.134-.067.262-.357.075-.518.041-.217-.045-.37.007-.583.101-.363.162-.894.143-1.21.407-.27.223-.269.721-.394 1 0 0-.348.895-1.106 1.443-.194.14-.574.477-1.4.605a5.3 5.3 0 0 1-1.1.043c-.186-.009-.362-.018-.549-.02-.11-.002-.48-.013-.461.022l-.041.103.024.138c.015.083.019.149.022.225.006.157-.013.32-.005.478.017.328.138.627.154.958.017.368.199.758.375 1.059.067.114.169.128.213.269.052.161.003.333.028.505.1.668.292 1.366.592 1.97l.008.014c.371-.062.743-.196 1.226-.267.885-.132 2.115-.064 2.906-.138 2-.188 3.085.82 4.882.407V5.796a2.523 2.523 0 0 0-2.523-2.523zm-.907 11.144q-.022 0-.046.003c-.159.025-.313.08-.412.24-.08.13-.108.355-.164.505-.064.175-.176.338-.274.505-.18.305-.504.581-.644.879-.028.06-.053.13-.076.2v3.402c.163.028.333.062.524.113 1.407.375 1.75.407 3.13.25l.13-.018c.105-.22.187-.968.255-1.2.054-.178.127-.32.155-.5.026-.173-.003-.337-.017-.493-.04-.393.285-.533.44-.87.14-.304.22-.651.336-.963.11-.298.284-.721.579-.872-.036-.041-.617-.06-.772-.076a5 5 0 0 1-.5-.07c-.314-.064-.656-.126-.965-.2a10 10 0 0 1-.947-.328c-.298-.138-.503-.497-.732-.507m5.737.83c-.74.149-.97.876-1.32 1.451-.192.319-.396.59-.548.928-.14.312-.128.657-.368.924a2.55 2.55 0 0 0-.528.922c-.023.067-.088.776-.158.943l1.101-.078c1.026.07.73.464 2.332.378l2.529-.078a7 7 0 0 0-.228-.588c-.07-.147-.16-.434-.218-.56a3.5 3.5 0 0 0-.309-.526c-.184-.215-.227-.23-.28-.503-.095-.473-.344-1.33-.637-1.923-.151-.306-.403-.562-.634-.784-.2-.195-.655-.522-.734-.505z"/></svg>`,
|
||||
"php": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1e88e5" d="M12 18.08c-6.63 0-12-2.72-12-6.08s5.37-6.08 12-6.08S24 8.64 24 12s-5.37 6.08-12 6.08m-5.19-7.95c.54 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.58 1.09q-.42.33-1.29.33h-.87l.53-2.76zm-3.5 5.55h1.44l.34-1.75h1.23c.54 0 .98-.06 1.33-.17.35-.12.67-.31.96-.58.24-.22.43-.46.58-.73.15-.26.26-.56.31-.88.16-.78.05-1.39-.33-1.82-.39-.44-.99-.65-1.82-.65H4.59zm7.25-8.33-1.28 6.58h1.42l.74-3.77h1.14c.36 0 .6.06.71.18s.13.34.07.66l-.57 2.93h1.45l.59-3.07c.13-.62.03-1.07-.27-1.36-.3-.27-.85-.4-1.65-.4h-1.27L12 7.35zM18 10.13c.55 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.57 1.09-.29.22-.72.33-1.3.33h-.85l.5-2.76zm-3.5 5.55h1.44l.34-1.75h1.22c.55 0 1-.06 1.35-.17.35-.12.65-.31.95-.58.24-.22.44-.46.58-.73.15-.26.26-.56.32-.88.15-.78.04-1.39-.34-1.82-.36-.44-.99-.65-1.82-.65h-2.75z"/></svg>`,
|
||||
"python": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0288d1" d="M9.86 2A2.86 2.86 0 0 0 7 4.86v1.68h4.29c.39 0 .71.57.71.96H4.86A2.86 2.86 0 0 0 2 10.36v3.781a2.86 2.86 0 0 0 2.86 2.86h1.18v-2.68a2.85 2.85 0 0 1 2.85-2.86h5.25c1.58 0 2.86-1.271 2.86-2.851V4.86A2.86 2.86 0 0 0 14.14 2zm-.72 1.61c.4 0 .72.12.72.71s-.32.891-.72.891c-.39 0-.71-.3-.71-.89s.32-.711.71-.711"/><path fill="#fdd835" d="M17.959 7v2.68a2.85 2.85 0 0 1-2.85 2.859H9.86A2.85 2.85 0 0 0 7 15.389v3.75a2.86 2.86 0 0 0 2.86 2.86h4.28A2.86 2.86 0 0 0 17 19.14v-1.68h-4.291c-.39 0-.709-.57-.709-.96h7.14A2.86 2.86 0 0 0 22 13.64V9.86A2.86 2.86 0 0 0 19.14 7zM8.32 11.513l-.004.004.038-.004zm6.54 7.276c.39 0 .71.3.71.89a.71.71 0 0 1-.71.71c-.4 0-.72-.12-.72-.71s.32-.89.72-.89"/></svg>`,
|
||||
"r": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1976d2" d="M11.956 4.05c-5.694 0-10.354 3.106-10.354 6.947 0 3.396 3.686 6.212 8.531 6.813v2.205h3.53V17.82c.88-.093 1.699-.259 2.475-.497l1.43 2.692h3.996l-2.402-4.048c1.936-1.263 3.147-3.034 3.147-4.97 0-3.841-4.659-6.947-10.354-6.947m1.584 2.712c4.349 0 7.558 1.45 7.558 4.753 0 1.77-.952 3.013-2.505 3.779a1 1 0 0 1-.228-.156c-.373-.165-.994-.352-.994-.352s3.085-.227 3.085-3.302-3.23-3.127-3.23-3.127h-7.092v7.413c-2.64-.766-4.462-2.392-4.462-4.255 0-2.63 3.52-4.753 7.868-4.753m.156 4.12h2.143s.983-.05.983.974c0 1.004-.983 1.004-.983 1.004h-2.143v-1.977m-.031 4.566h.952c.186 0 .28.052.445.207.135.103.28.3.404.476-.57.073-1.17.104-1.801.104z"/></svg>`,
|
||||
"react": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00bcd4" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#00bcd4" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#00bcd4" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#00bcd4" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
|
||||
"react_ts": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#0288d1" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#0288d1" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#0288d1" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
|
||||
"ruby": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#f44336" d="M18.041 3.177c2.24.382 2.879 1.919 2.843 3.527V6.67l-1.013 13.266-13.132.897h.008c-1.093-.044-3.518-.151-3.634-3.545l1.217-2.222 2.462 5.74 2.097-6.77-.045.009.018-.018 6.85 2.186L13.945 9.3l6.53-.409-5.144-4.212 2.71-1.51v.009M3.113 17.252v.017zM6.916 6.874c2.63-2.622 6.033-4.168 7.34-2.844 1.297 1.306-.072 4.523-2.702 7.135-2.666 2.613-6.015 4.248-7.322 2.933-1.306-1.324.036-4.612 2.675-7.224z"/></svg>`,
|
||||
"rust": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ff7043" d="m30 12-4-2V6h-4l-2-4-4 2-4-2-2 4H6v4l-4 2 2 4-2 4 4 2v4h4l2 4 4-2 4 2 2-4h4v-4l4-2-2-4ZM6 16a9.9 9.9 0 0 1 .842-4H10v8H6.842A9.9 9.9 0 0 1 6 16m10 10a9.98 9.98 0 0 1-7.978-4H16v-2h-2v-2h4c.819.819.297 2.308 1.179 3.37a1.89 1.89 0 0 0 1.46.63h3.34A9.98 9.98 0 0 1 16 26m-2-12v-2h4a1 1 0 0 1 0 2Zm11.158 6H24a2.006 2.006 0 0 1-2-2 2 2 0 0 0-2-2 3 3 0 0 0 3-3q0-.08-.004-.161A3.115 3.115 0 0 0 19.83 10H8.022a9.986 9.986 0 0 1 17.136 10"/></svg>`,
|
||||
"sass": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M27.837 5.673a4.33 4.33 0 0 0-2.293-2.701c-2.362-1.261-6.11-1.298-9.548-.092a26.3 26.3 0 0 0-8.76 4.966c-2.752 2.542-3.438 4.925-3.189 6.194.523 2.668 3.274 4.539 5.485 6.042.418.284.822.559 1.175.816-1.429.76-4.261 2.444-5.088 4.248a3.88 3.88 0 0 0-.118 3.332A2.37 2.37 0 0 0 6.869 29.8a5.6 5.6 0 0 0 1.49.2 6.35 6.35 0 0 0 5.19-2.856 6.74 6.74 0 0 0 .864-5.382 7.3 7.3 0 0 1 2.044-.03 3.92 3.92 0 0 1 2.816 1.311 1.82 1.82 0 0 1 .423 1.262 1.55 1.55 0 0 1-.772 1.05c-.234.14-.586.355-.504.803.036.194.198.633.894.512a2.93 2.93 0 0 0 2.145-2.651 4 4 0 0 0-1.197-2.904 5.94 5.94 0 0 0-4.396-1.626 10.6 10.6 0 0 0-2.672.304 20 20 0 0 0-2.203-1.846c-1.712-1.3-3.33-2.529-3.235-4.26.125-2.263 2.468-4.532 6.964-6.744 4.016-1.976 7.254-2.037 8.944-1.438a2 2 0 0 1 1.204.883 2.77 2.77 0 0 1-.36 2.47 9.71 9.71 0 0 1-7.425 4.304 3.86 3.86 0 0 1-3.238-.757c-.278-.302-.593-.645-1.074-.383q-.565.31-.225 1.189a3.9 3.9 0 0 0 2.407 1.92 11.7 11.7 0 0 0 7.128-.671c3.527-1.35 6.681-5.202 5.756-8.787M11.895 24.475a4 4 0 0 1-.192.468 4.5 4.5 0 0 1-.753 1.081 2.83 2.83 0 0 1-2.533 1.107c-.056-.032-.078-.146-.085-.193a3.28 3.28 0 0 1 1.076-2.284 11.3 11.3 0 0 1 2.644-1.933 3.85 3.85 0 0 1-.157 1.754"/></svg>`,
|
||||
"scala": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="m6.457 9.894 12.523 5.163-.456 1.211L6 11.105Zm7.02-3.091L26 11.966l-.457 1.21L13.02 8.015ZM6.465 18.885l12.524 5.163-.457 1.21L6.01 20.097Zm7.007-3.086 12.524 5.163-.456 1.21-12.524-5.162Z"/><path fill="#f44336" d="M6 24.07V30l19.997-3.106V20.96zM6 5.11v5.99l20-3.11V2zm0 9.96v5.03l20-3.11v-5.03z"/></svg>`,
|
||||
"settings": `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.6.6 0 0 0-.18-.03c-.17 0-.34.09-.43.25l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1q.09.03.18.03c.17 0 .34-.09.43-.25l2-3.46c.12-.22.07-.49-.12-.64zm-1.98-1.71c.04.31.05.52.05.73s-.02.43-.05.73l-.14 1.13.89.7 1.08.84-.7 1.21-1.27-.51-1.04-.42-.9.68c-.43.32-.84.56-1.25.73l-1.06.43-.16 1.13-.2 1.35h-1.4l-.19-1.35-.16-1.13-1.06-.43c-.43-.18-.83-.41-1.23-.71l-.91-.7-1.06.43-1.27.51-.7-1.21 1.08-.84.89-.7-.14-1.13c-.03-.31-.05-.54-.05-.74s.02-.43.05-.73l.14-1.13-.89-.7-1.08-.84.7-1.21 1.27.51 1.04.42.9-.68c.43-.32.84-.56 1.25-.73l1.06-.43.16-1.13.2-1.35h1.39l.19 1.35.16 1.13 1.06.43c.43.18.83.41 1.23.71l.91.7 1.06-.43 1.27-.51.7 1.21-1.07.85-.89.7zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4m0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2"/></svg>`,
|
||||
"svelte": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><path fill="#ff5722" d="M175.94 24.328c-13.037.252-26.009 3.872-37.471 11.174L79.912 72.818a67.13 67.13 0 0 0-30.355 44.906 70.8 70.8 0 0 0 6.959 45.445 67.2 67.2 0 0 0-10.035 25.102 71.54 71.54 0 0 0 12.236 54.156c23.351 33.41 69.468 43.311 102.81 22.07l58.559-37.158a67.36 67.36 0 0 0 30.355-44.906 70.77 70.77 0 0 0-6.982-45.422 67.65 67.65 0 0 0 10.059-25.102 71.63 71.63 0 0 0-12.236-54.156v-.18c-15.324-21.925-40.453-33.727-65.342-33.246zm5.137 28.68a46.5 46.5 0 0 1 36.09 19.969 42.98 42.98 0 0 1 7.365 32.557 45 45 0 0 1-1.393 5.455l-1.123 3.37-2.986-2.247a75.9 75.9 0 0 0-22.902-11.45l-2.244-.651.201-2.246a13.16 13.16 0 0 0-2.379-8.711 13.99 13.99 0 0 0-14.953-5.412 12.8 12.8 0 0 0-3.594 1.572l-58.578 37.25a12.24 12.24 0 0 0-5.502 8.15 13.1 13.1 0 0 0 2.246 9.834 14.03 14.03 0 0 0 14.93 5.569 13.5 13.5 0 0 0 3.594-1.573l22.453-14.234a41.8 41.8 0 0 1 11.898-5.232 46.48 46.48 0 0 1 49.914 18.502 43.02 43.02 0 0 1 7.363 32.557 40.42 40.42 0 0 1-18.254 27.078l-58.58 37.316a43 43 0 0 1-11.898 5.23A46.545 46.545 0 0 1 82.81 227.14a42.98 42.98 0 0 1-7.341-32.557 38 38 0 0 1 1.39-5.41l1.102-3.37 3.008 2.246a75.9 75.9 0 0 0 22.836 11.361l2.244.65-.201 2.247a13.25 13.25 0 0 0 2.447 8.644 14.03 14.03 0 0 0 15.043 5.569 13.1 13.1 0 0 0 3.592-1.573l58.467-37.316a12.17 12.17 0 0 0 5.502-8.173 12.96 12.96 0 0 0-2.246-9.811 14.03 14.03 0 0 0-15.043-5.568 12.8 12.8 0 0 0-3.592 1.57l-22.453 14.258a42.9 42.9 0 0 1-11.877 5.209 46.52 46.52 0 0 1-49.846-18.5 43.02 43.02 0 0 1-7.297-32.557A40.42 40.42 0 0 1 96.798 96.98l58.646-37.316a42.8 42.8 0 0 1 11.811-5.21 46.5 46.5 0 0 1 13.822-1.444z"/></svg>`,
|
||||
"svg": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffb300" d="M29.168 14.03a2.7 2.7 0 0 0-1.968-.83 2.51 2.51 0 0 0-1.929.8h-4.443l3.078-3.078a2.835 2.835 0 0 0 2.857-2.842 2.6 2.6 0 0 0-.831-1.969 2.82 2.82 0 0 0-2.014-.788 2.67 2.67 0 0 0-1.968.788 2.36 2.36 0 0 0-.812 1.922L18 11.17V6.726a2.51 2.51 0 0 0 .8-1.929 2.7 2.7 0 0 0-.832-1.968 2.745 2.745 0 0 0-3.936 0 2.7 2.7 0 0 0-.832 1.968 2.51 2.51 0 0 0 .8 1.93v4.443l-3.138-3.138a2.36 2.36 0 0 0-.812-1.922 2.66 2.66 0 0 0-1.968-.788 2.83 2.83 0 0 0-2.014.788 2.6 2.6 0 0 0-.831 1.969 2.74 2.74 0 0 0 .831 2.013 2.8 2.8 0 0 0 2.026.829l3.078 3.078H6.729a2.51 2.51 0 0 0-1.929-.8 2.7 2.7 0 0 0-1.968.831 2.745 2.745 0 0 0 0 3.937 2.7 2.7 0 0 0 1.968.832 2.51 2.51 0 0 0 1.929-.8h4.443l-3.078 3.077a2.835 2.835 0 0 0-2.857 2.842 2.6 2.6 0 0 0 .831 1.969 2.82 2.82 0 0 0 2.014.788 2.67 2.67 0 0 0 1.968-.788 2.36 2.36 0 0 0 .812-1.922L14 20.827v4.444a2.51 2.51 0 0 0-.8 1.929 2.784 2.784 0 0 0 4.768 1.968A2.7 2.7 0 0 0 18.8 27.2a2.51 2.51 0 0 0-.8-1.929v-4.444l3.138 3.138a2.36 2.36 0 0 0 .812 1.922 2.66 2.66 0 0 0 1.968.788 2.83 2.83 0 0 0 2.014-.788 2.6 2.6 0 0 0 .831-1.969 2.74 2.74 0 0 0-.831-2.013 2.8 2.8 0 0 0-2.026-.829L20.828 18h4.443a2.51 2.51 0 0 0 1.93.8 2.784 2.784 0 0 0 1.967-4.769Z"/></svg>`,
|
||||
"swift": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff6e40" d="M17.087 19.721c-2.36 1.36-5.59 1.5-8.86.1a13.8 13.8 0 0 1-6.23-5.32c.67.55 1.46 1 2.3 1.4 3.37 1.57 6.73 1.46 9.1 0-3.37-2.59-6.24-5.96-8.37-8.71-.45-.45-.78-1.01-1.12-1.51 8.28 6.05 7.92 7.59 2.41-1.01 4.89 4.94 9.43 7.74 9.43 7.74.16.09.25.16.36.22.1-.25.19-.51.26-.78.79-2.85-.11-6.12-2.08-8.81 4.55 2.75 7.25 7.91 6.12 12.24-.03.11-.06.22-.05.39 2.24 2.83 1.64 5.78 1.35 5.22-1.21-2.39-3.48-1.65-4.62-1.17"/></svg>`,
|
||||
"terraform": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#5c6bc0" d="m2 10 8 4V6L2 2zm10 5 8 4v-8l-8-4zm0 11 8 4v-8l-8-4zm10-14v8l8-4V8z"/></svg>`,
|
||||
"toml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#cfd8dc" d="M4 6V4h8v2H9v7H7V6z"/><path fill="#ef5350" d="M4 1v1H2v12h2v1H1V1zm8 0v1h2v12h-2v1h3V1z"/></svg>`,
|
||||
"typescript": `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 16 16"><path fill="#0288d1" d="M2 2v12h12V2zm4 6h3v1H8v4H7V9H6zm5 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
|
||||
"vue": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#41b883" d="M1.791 3.851 12 21.471 22.209 3.936V3.85H18.24l-6.18 10.616L5.906 3.851z"/><path fill="#35495e" d="m5.907 3.851 6.152 10.617L18.24 3.851h-3.723L12.084 8.03 9.66 3.85z"/></svg>`,
|
||||
"webassembly": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M22 18h4v4h-4z"/><path fill="#7c4dff" d="M20 2a4 4 0 0 1-8 0H2v28h28V2Zm-2 24h-2v2h-4v-2h-2v2H6v-2H4V16h2v10h4V16h2v10h4V16h2Zm10 2h-2v-4h-4v4h-2V18h2v-2h4v2h2Z"/></svg>`,
|
||||
"xml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#8bc34a" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m.12 13.5 3.74 3.74 1.42-1.41-2.33-2.33 2.33-2.33-1.42-1.41zm11.16 0-3.74-3.74-1.42 1.41 2.33 2.33-2.33 2.33 1.42 1.41z"/></svg>`,
|
||||
"yaml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff5252" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12c0 1.1-.9 2-2 2H6c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2m12 16v-2H9v2zm-4-4v-2H6v2z"/></svg>`,
|
||||
"zig": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f9a825" d="M2 8h6v4H2zm8 0h12v4H10zm0 12h12v4H10zm14 0h2v4h-2zM8 20l-3 4H2V12h4v8zm14-8h-6l-6 8h6z"/><path fill="#f9a825" d="M16 20h-6l-6 8m12-16h6l6-8m2 4v16h-4V12h-2l3-4z"/></svg>`,
|
||||
};
|
||||
|
||||
const EXTENSION_TO_ICON: Record<string, string> = {
|
||||
"astro": "astro",
|
||||
"bash": "console",
|
||||
"c": "c",
|
||||
"cfg": "settings",
|
||||
"clj": "clojure",
|
||||
"conf": "settings",
|
||||
"cpp": "cpp",
|
||||
"cs": "csharp",
|
||||
"css": "css",
|
||||
"dart": "dart",
|
||||
"erl": "erlang",
|
||||
"ex": "elixir",
|
||||
"exs": "elixir",
|
||||
"gif": "image",
|
||||
"go": "go",
|
||||
"gql": "graphql",
|
||||
"gradle": "gradle",
|
||||
"graphql": "graphql",
|
||||
"groovy": "groovy",
|
||||
"h": "h",
|
||||
"hcl": "hcl",
|
||||
"hpp": "hpp",
|
||||
"hs": "haskell",
|
||||
"html": "html",
|
||||
"ico": "image",
|
||||
"ini": "settings",
|
||||
"java": "java",
|
||||
"jpeg": "image",
|
||||
"jpg": "image",
|
||||
"js": "javascript",
|
||||
"json": "json",
|
||||
"jsx": "react",
|
||||
"kt": "kotlin",
|
||||
"less": "less",
|
||||
"lock": "lock",
|
||||
"lua": "lua",
|
||||
"markdown": "markdown",
|
||||
"md": "markdown",
|
||||
"ml": "ocaml",
|
||||
"nix": "nix",
|
||||
"php": "php",
|
||||
"png": "image",
|
||||
"py": "python",
|
||||
"r": "r",
|
||||
"rb": "ruby",
|
||||
"rs": "rust",
|
||||
"scala": "scala",
|
||||
"scss": "sass",
|
||||
"sh": "console",
|
||||
"sql": "database",
|
||||
"svelte": "svelte",
|
||||
"svg": "svg",
|
||||
"swift": "swift",
|
||||
"tf": "terraform",
|
||||
"toml": "toml",
|
||||
"ts": "typescript",
|
||||
"tsx": "react_ts",
|
||||
"txt": "document",
|
||||
"vue": "vue",
|
||||
"wasm": "webassembly",
|
||||
"webp": "image",
|
||||
"xml": "xml",
|
||||
"yaml": "yaml",
|
||||
"yml": "yaml",
|
||||
"zig": "zig",
|
||||
};
|
||||
|
||||
export function getFileIconSvg(fileName: string): string {
|
||||
const ext = getExtension(fileName);
|
||||
if (ext) {
|
||||
const iconName = EXTENSION_TO_ICON[ext];
|
||||
if (iconName && SVG_ICONS[iconName]) return SVG_ICONS[iconName];
|
||||
}
|
||||
return SVG_ICONS["_default"];
|
||||
}
|
||||
|
||||
function getExtension(name: string): string | null {
|
||||
const idx = name.lastIndexOf(".");
|
||||
if (idx === -1 || idx === name.length - 1) return null;
|
||||
return name.slice(idx + 1).toLowerCase();
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
FolderPlus,
|
||||
FolderGit2,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
Monitor,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
@@ -87,6 +88,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
|
||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||
if (!icon) {
|
||||
@@ -695,7 +697,7 @@ function ProjectHeaderRow({
|
||||
prepareWorkspaceTab({
|
||||
serverId: serverId!,
|
||||
workspaceId: workspace.id,
|
||||
target: { kind: "draft", draftId: "new" },
|
||||
target: { kind: "setup", workspaceId: workspace.id },
|
||||
}) as any,
|
||||
);
|
||||
},
|
||||
@@ -855,122 +857,132 @@ function WorkspaceRowInner({
|
||||
onPress();
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
|
||||
const isDesktop = !isMobile;
|
||||
const showGlobe = isDesktop && workspace.hasRunningServices;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.workspaceRowContainer}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Pressable
|
||||
disabled={isArchiving}
|
||||
style={({ pressed }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
<WorkspaceHoverCard workspace={workspace} isDragging={isDragging}>
|
||||
<View
|
||||
style={styles.workspaceRowContainer}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<View style={styles.workspaceRowMain}>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
>
|
||||
<WorkspaceStatusIndicator
|
||||
bucket={workspace.statusBucket}
|
||||
workspaceKind={workspace.workspaceKind}
|
||||
loading={isArchiving || isCreating}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.workspaceBranchText,
|
||||
isHovered && styles.workspaceBranchTextHovered,
|
||||
isCreating && styles.workspaceBranchTextCreating,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
<Pressable
|
||||
disabled={isArchiving}
|
||||
style={({ pressed }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<View style={styles.workspaceRowMain}>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||
{onArchive && (isHovered || isMobile) ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={({ hovered = false }) => [
|
||||
styles.kebabButton,
|
||||
hovered && styles.kebabButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace actions"
|
||||
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<MoreVertical
|
||||
size={14}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260}>
|
||||
{onCopyPath ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
||||
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
<WorkspaceStatusIndicator
|
||||
bucket={workspace.statusBucket}
|
||||
workspaceKind={workspace.workspaceKind}
|
||||
loading={isArchiving || isCreating}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.workspaceBranchText,
|
||||
isHovered && styles.workspaceBranchTextHovered,
|
||||
isCreating && styles.workspaceBranchTextCreating,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{showGlobe ? (
|
||||
<View testID="workspace-globe-icon" accessibilityLabel="Services available">
|
||||
<Globe size={12} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||
{onArchive && (isHovered || isMobile) ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={({ hovered = false }) => [
|
||||
styles.kebabButton,
|
||||
hovered && styles.kebabButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace actions"
|
||||
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
|
||||
>
|
||||
{archiveLabel ?? "Archive"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : workspace.diffStat ? (
|
||||
<View style={styles.diffStatRow}>
|
||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{({ hovered }) => (
|
||||
<MoreVertical
|
||||
size={14}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260}>
|
||||
{onCopyPath ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
||||
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
>
|
||||
{archiveLabel ?? "Archive"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : workspace.diffStat ? (
|
||||
<View style={styles.diffStatRow}>
|
||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{prHint ? (
|
||||
<View style={styles.workspacePrBadgeRow}>
|
||||
<WorkspacePrBadge hint={prHint} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
{prHint ? (
|
||||
<View style={styles.workspacePrBadgeRow}>
|
||||
<WorkspacePrBadge hint={prHint} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
</WorkspaceHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useTrafficLightPadding } from "@/utils/desktop-window";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import {
|
||||
computeTabDropPreview,
|
||||
type TabDropPreview,
|
||||
@@ -809,8 +809,7 @@ function SplitPaneView({
|
||||
const { theme } = useUnistyles();
|
||||
const paneRef = useRef<View | null>(null);
|
||||
const stableOnFocusPane = useStableEvent(onFocusPane);
|
||||
const isFocusModeEnabled = usePanelStore((s) => s.desktop.focusModeEnabled);
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
const padding = useWindowControlsPadding("tabRow");
|
||||
const paneState = useMemo(
|
||||
() =>
|
||||
deriveWorkspacePaneState({
|
||||
@@ -885,11 +884,7 @@ function SplitPaneView({
|
||||
<View
|
||||
style={[
|
||||
styles.paneTabs,
|
||||
isFocusModeEnabled &&
|
||||
trafficLightPadding.side && {
|
||||
paddingLeft: trafficLightPadding.left,
|
||||
paddingRight: trafficLightPadding.right,
|
||||
},
|
||||
{ paddingLeft: padding.left, paddingRight: padding.right },
|
||||
]}
|
||||
>
|
||||
<WorkspaceDesktopTabsRow
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { Dimensions, Platform, Text, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ExternalLink, FolderGit2, GitPullRequest, Monitor } from "lucide-react-native";
|
||||
import { Pressable } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function measureElement(element: View): Promise<Rect> {
|
||||
return new Promise((resolve) => {
|
||||
element.measureInWindow((x, y, width, height) => {
|
||||
resolve({ x, y, width, height });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function computeHoverCardPosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
displayArea,
|
||||
offset,
|
||||
}: {
|
||||
triggerRect: Rect;
|
||||
contentSize: { width: number; height: number };
|
||||
displayArea: Rect;
|
||||
offset: number;
|
||||
}): { x: number; y: number } {
|
||||
let x = triggerRect.x + triggerRect.width + offset;
|
||||
let y = triggerRect.y;
|
||||
|
||||
// If it overflows right, try left
|
||||
if (x + contentSize.width > displayArea.width - 8) {
|
||||
x = triggerRect.x - contentSize.width - offset;
|
||||
}
|
||||
|
||||
// Constrain to screen
|
||||
const padding = 8;
|
||||
x = Math.max(padding, Math.min(displayArea.width - contentSize.width - padding, x));
|
||||
y = Math.max(
|
||||
displayArea.y + padding,
|
||||
Math.min(displayArea.y + displayArea.height - contentSize.height - padding, y),
|
||||
);
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
const HOVER_GRACE_MS = 100;
|
||||
const HOVER_CARD_WIDTH = 260;
|
||||
|
||||
interface WorkspaceHoverCardProps {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
export function WorkspaceHoverCard({
|
||||
workspace,
|
||||
isDragging,
|
||||
children,
|
||||
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
|
||||
// Desktop-only: skip on non-web platforms
|
||||
if (Platform.OS !== "web") {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceHoverCardDesktop workspace={workspace} isDragging={isDragging}>
|
||||
{children}
|
||||
</WorkspaceHoverCardDesktop>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceHoverCardDesktop({
|
||||
workspace,
|
||||
isDragging,
|
||||
children,
|
||||
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
|
||||
const triggerRef = useRef<View>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const triggerHoveredRef = useRef(false);
|
||||
const contentHoveredRef = useRef(false);
|
||||
|
||||
const hasServices = workspace.services.length > 0;
|
||||
|
||||
const clearGraceTimer = useCallback(() => {
|
||||
if (graceTimerRef.current) {
|
||||
clearTimeout(graceTimerRef.current);
|
||||
graceTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
clearGraceTimer();
|
||||
graceTimerRef.current = setTimeout(() => {
|
||||
if (!triggerHoveredRef.current && !contentHoveredRef.current) {
|
||||
setOpen(false);
|
||||
}
|
||||
graceTimerRef.current = null;
|
||||
}, HOVER_GRACE_MS);
|
||||
}, [clearGraceTimer]);
|
||||
|
||||
const handleTriggerEnter = useCallback(() => {
|
||||
triggerHoveredRef.current = true;
|
||||
clearGraceTimer();
|
||||
if (!isDragging && hasServices) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [clearGraceTimer, isDragging, hasServices]);
|
||||
|
||||
const handleTriggerLeave = useCallback(() => {
|
||||
triggerHoveredRef.current = false;
|
||||
scheduleClose();
|
||||
}, [scheduleClose]);
|
||||
|
||||
const handleContentEnter = useCallback(() => {
|
||||
contentHoveredRef.current = true;
|
||||
clearGraceTimer();
|
||||
}, [clearGraceTimer]);
|
||||
|
||||
const handleContentLeave = useCallback(() => {
|
||||
contentHoveredRef.current = false;
|
||||
scheduleClose();
|
||||
}, [scheduleClose]);
|
||||
|
||||
// Close when drag starts
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
clearGraceTimer();
|
||||
setOpen(false);
|
||||
}
|
||||
}, [isDragging, clearGraceTimer]);
|
||||
|
||||
// When hasServices becomes true while trigger is already hovered, open the card.
|
||||
useEffect(() => {
|
||||
if (!hasServices || isDragging) return;
|
||||
if (triggerHoveredRef.current) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [hasServices, isDragging]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearGraceTimer();
|
||||
};
|
||||
}, [clearGraceTimer]);
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={triggerRef}
|
||||
collapsable={false}
|
||||
onPointerEnter={handleTriggerEnter}
|
||||
onPointerLeave={handleTriggerLeave}
|
||||
>
|
||||
{children}
|
||||
{open && hasServices ? (
|
||||
<WorkspaceHoverCardContent
|
||||
workspace={workspace}
|
||||
triggerRef={triggerRef}
|
||||
onContentEnter={handleContentEnter}
|
||||
onContentLeave={handleContentLeave}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
|
||||
open: "Open",
|
||||
merged: "Merged",
|
||||
closed: "Closed",
|
||||
};
|
||||
|
||||
function HoverCardStatusIndicator({
|
||||
workspace,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
}): ReactElement | null {
|
||||
const { theme } = useUnistyles();
|
||||
const showSyncedLoader = shouldRenderSyncedStatusLoader({ bucket: workspace.statusBucket });
|
||||
|
||||
if (showSyncedLoader) {
|
||||
return <SyncedLoader size={11} color={theme.colors.palette.amber[500]} />;
|
||||
}
|
||||
|
||||
const KindIcon =
|
||||
workspace.workspaceKind === "local_checkout"
|
||||
? Monitor
|
||||
: workspace.workspaceKind === "worktree"
|
||||
? FolderGit2
|
||||
: null;
|
||||
if (!KindIcon) return null;
|
||||
|
||||
const dotColor = getStatusDotColor({ theme, bucket: workspace.statusBucket, showDoneAsInactive: false });
|
||||
|
||||
return (
|
||||
<View style={styles.hoverStatusIcon}>
|
||||
<KindIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
{dotColor ? (
|
||||
<View
|
||||
style={[
|
||||
styles.hoverStatusDotOverlay,
|
||||
{
|
||||
backgroundColor: dotColor,
|
||||
borderColor: theme.colors.surface1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceHoverCardContent({
|
||||
workspace,
|
||||
triggerRef,
|
||||
onContentEnter,
|
||||
onContentLeave,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
onContentEnter: () => void;
|
||||
onContentLeave: () => void;
|
||||
}): ReactElement | null {
|
||||
const { theme } = useUnistyles();
|
||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const prHint = useWorkspacePrHint({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspace.workspaceId,
|
||||
enabled: workspace.workspaceKind !== "directory",
|
||||
});
|
||||
|
||||
// Measure trigger — same pattern as tooltip.tsx
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
measureElement(triggerRef.current).then((rect) => {
|
||||
if (cancelled) return;
|
||||
setTriggerRect(rect);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [triggerRef]);
|
||||
|
||||
// Compute position when both measurements are available
|
||||
useEffect(() => {
|
||||
if (!triggerRect || !contentSize) return;
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||
const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight };
|
||||
const result = computeHoverCardPosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
displayArea,
|
||||
offset: 4,
|
||||
});
|
||||
setPosition(result);
|
||||
}, [triggerRect, contentSize]);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setContentSize({ width, height });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<View pointerEvents="box-none" style={styles.portalOverlay}>
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
onLayout={handleLayout}
|
||||
onPointerEnter={onContentEnter}
|
||||
onPointerLeave={onContentLeave}
|
||||
accessibilityRole="menu"
|
||||
accessibilityLabel="Workspace services"
|
||||
testID="workspace-hover-card"
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
width: HOVER_CARD_WIDTH,
|
||||
position: "absolute",
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<HoverCardStatusIndicator workspace={workspace} />
|
||||
<Text style={styles.cardTitle} numberOfLines={1} testID="hover-card-workspace-name">
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
{workspace.diffStat ? (
|
||||
<View style={styles.cardMetaRow}>
|
||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{prHint ? (
|
||||
<Pressable
|
||||
style={styles.cardMetaRow}
|
||||
onPress={() => void openExternalUrl(prHint.url)}
|
||||
>
|
||||
<GitPullRequest size={12} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.prBadgeText} numberOfLines={1}>
|
||||
#{prHint.number} · {GITHUB_PR_STATE_LABELS[prHint.state]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<View style={styles.separator} />
|
||||
<View style={styles.serviceList} testID="hover-card-service-list">
|
||||
{workspace.services.map((service) => (
|
||||
<Pressable
|
||||
key={service.hostname}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`${service.serviceName} service`}
|
||||
testID={`hover-card-service-${service.serviceName}`}
|
||||
style={({ hovered }) => [
|
||||
styles.serviceRow,
|
||||
hovered && styles.serviceRowHovered,
|
||||
]}
|
||||
onPress={() => {
|
||||
if (service.url) {
|
||||
void openExternalUrl(service.url);
|
||||
}
|
||||
}}
|
||||
disabled={!service.url}
|
||||
>
|
||||
<View
|
||||
testID={`hover-card-service-status-${service.serviceName}`}
|
||||
accessibilityLabel={service.status === "running" ? "Running" : "Stopped"}
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor:
|
||||
service.status === "running"
|
||||
? theme.colors.palette.green[500]
|
||||
: theme.colors.palette.red[500],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.serviceName} numberOfLines={1}>
|
||||
{service.serviceName}
|
||||
</Text>
|
||||
{service.url ? (
|
||||
<ExternalLink size={12} color={theme.colors.foregroundMuted} />
|
||||
) : null}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
portalOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: 1000,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingVertical: theme.spacing[2],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
zIndex: 1000,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
cardTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
cardMetaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
diffStatAdditions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
},
|
||||
diffStatDeletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
prBadgeText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
hoverStatusIcon: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
},
|
||||
hoverStatusDotOverlay: {
|
||||
position: "absolute",
|
||||
bottom: -1,
|
||||
right: -1,
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
borderWidth: 1,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
serviceList: {
|
||||
paddingTop: theme.spacing[1],
|
||||
},
|
||||
serviceRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
minHeight: 32,
|
||||
},
|
||||
serviceRowHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
flexShrink: 0,
|
||||
},
|
||||
serviceName: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
}));
|
||||
@@ -60,14 +60,3 @@ export function getIsDesktop(): boolean {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get traffic light padding values (only non-zero on desktop macOS)
|
||||
export function getTrafficLightPadding(): { left: number; top: number } {
|
||||
if (!getIsDesktopMac()) {
|
||||
return { left: 0, top: 0 };
|
||||
}
|
||||
return {
|
||||
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceServicePayload } from "@server/shared/messages";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { patchWorkspaceServices } from "./session-workspace-services";
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
services?: WorkspaceDescriptor["services"];
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "running",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
services: input.services ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const runningService: WorkspaceServicePayload = {
|
||||
serviceName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
status: "running",
|
||||
};
|
||||
|
||||
describe("patchWorkspaceServices", () => {
|
||||
it("patches only the matching workspace services", () => {
|
||||
const other = workspace({ id: "/repo/other", services: [] });
|
||||
const current = new Map<string, WorkspaceDescriptor>([
|
||||
["/repo/main", workspace({ id: "/repo/main", services: [] })],
|
||||
[other.id, other],
|
||||
]);
|
||||
|
||||
const next = patchWorkspaceServices(current, {
|
||||
workspaceId: "/repo/main",
|
||||
services: [runningService],
|
||||
});
|
||||
|
||||
expect(next).not.toBe(current);
|
||||
expect(next.get("/repo/main")?.services).toEqual([runningService]);
|
||||
expect(next.get("/repo/other")).toBe(other);
|
||||
});
|
||||
|
||||
it("ignores updates for unknown workspaces", () => {
|
||||
const current = new Map<string, WorkspaceDescriptor>([
|
||||
["/repo/main", workspace({ id: "/repo/main", services: [] })],
|
||||
]);
|
||||
|
||||
const next = patchWorkspaceServices(current, {
|
||||
workspaceId: "/repo/missing",
|
||||
services: [runningService],
|
||||
});
|
||||
|
||||
expect(next).toBe(current);
|
||||
expect(next.get("/repo/main")?.services).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
@@ -50,6 +51,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { patchWorkspaceServices } from "@/contexts/session-workspace-services";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -159,6 +161,10 @@ type WorkspaceUpdatePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_update" }
|
||||
>["payload"];
|
||||
type WorkspaceSetupProgressPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_setup_progress" }
|
||||
>["payload"];
|
||||
|
||||
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
|
||||
update.kind === "remove" ? update.agentId : update.agent.id;
|
||||
@@ -264,6 +270,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
|
||||
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
|
||||
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||
const removeWorkspaceSetup = useWorkspaceSetupStore((state) => state.removeWorkspace);
|
||||
const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer);
|
||||
|
||||
// Track focused agent for heartbeat
|
||||
const focusedAgentId = useSessionStore(
|
||||
@@ -748,6 +757,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
],
|
||||
);
|
||||
|
||||
const applyWorkspaceSetupProgress = useCallback(
|
||||
(payload: WorkspaceSetupProgressPayload) => {
|
||||
upsertWorkspaceSetupProgress({ serverId, payload });
|
||||
},
|
||||
[serverId, upsertWorkspaceSetupProgress],
|
||||
);
|
||||
|
||||
const requestCanonicalCatchUp = useCallback(
|
||||
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
|
||||
void client
|
||||
@@ -1090,12 +1106,34 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
removeWorkspaceSetup({ serverId, workspaceId: message.payload.id });
|
||||
removeWorkspace(serverId, message.payload.id);
|
||||
return;
|
||||
}
|
||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
||||
});
|
||||
|
||||
const unsubServiceStatusUpdate = client.on("service_status_update", (message) => {
|
||||
if (message.type !== "service_status_update") return;
|
||||
setWorkspaces(serverId, (prev) => patchWorkspaceServices(prev, message.payload));
|
||||
});
|
||||
|
||||
const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => {
|
||||
if (message.type !== "workspace_setup_progress") return;
|
||||
applyWorkspaceSetupProgress(message.payload);
|
||||
});
|
||||
|
||||
const unsubWorkspaceSetupStatusResponse = client.on(
|
||||
"workspace_setup_status_response",
|
||||
(message) => {
|
||||
if (message.type !== "workspace_setup_status_response") return;
|
||||
const { workspaceId, snapshot } = message.payload;
|
||||
if (snapshot) {
|
||||
applyWorkspaceSetupProgress({ workspaceId, ...snapshot });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const unsubStatus = client.on("status", (message) => {
|
||||
if (message.type !== "status") return;
|
||||
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
||||
@@ -1444,6 +1482,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
unsubAgentStream();
|
||||
unsubAgentTimeline();
|
||||
unsubWorkspaceUpdate();
|
||||
unsubServiceStatusUpdate();
|
||||
unsubWorkspaceSetupProgress();
|
||||
unsubWorkspaceSetupStatusResponse();
|
||||
unsubStatus();
|
||||
unsubPermissionRequest();
|
||||
unsubPermissionResolved();
|
||||
@@ -1469,8 +1510,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setAgentTimelineCursor,
|
||||
setInitializingAgents,
|
||||
setAgents,
|
||||
setWorkspaces,
|
||||
mergeWorkspaces,
|
||||
removeWorkspace,
|
||||
removeWorkspaceSetup,
|
||||
setAgentLastActivity,
|
||||
setPendingPermissions,
|
||||
setHasHydratedAgents,
|
||||
@@ -1478,6 +1521,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
notifyAgentAttention,
|
||||
requestCanonicalCatchUp,
|
||||
applyAgentUpdatePayload,
|
||||
applyWorkspaceSetupProgress,
|
||||
applyTimelineResponse,
|
||||
voiceRuntime,
|
||||
voiceAudioEngine,
|
||||
@@ -1681,9 +1725,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearWorkspaceSetupServer(serverId);
|
||||
clearSession(serverId);
|
||||
};
|
||||
}, [clearSession, serverId]);
|
||||
}, [clearSession, clearWorkspaceSetupServer, serverId]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ServiceStatusUpdateMessage } from "@server/shared/messages";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
export function patchWorkspaceServices(
|
||||
workspaces: Map<string, WorkspaceDescriptor>,
|
||||
update: ServiceStatusUpdateMessage["payload"],
|
||||
): Map<string, WorkspaceDescriptor> {
|
||||
const existing = workspaces.get(update.workspaceId);
|
||||
if (!existing) {
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
const next = new Map(workspaces);
|
||||
next.set(update.workspaceId, {
|
||||
...existing,
|
||||
services: update.services.map((s) => ({ ...s })),
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceServicePayload } from "@server/shared/messages";
|
||||
import {
|
||||
appendMissingOrderKeys,
|
||||
applyStoredOrdering,
|
||||
@@ -19,7 +20,7 @@ function workspace(
|
||||
Partial<
|
||||
Pick<
|
||||
WorkspaceDescriptor,
|
||||
"projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind"
|
||||
"projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind" | "services"
|
||||
>
|
||||
>,
|
||||
): WorkspaceDescriptor {
|
||||
@@ -34,9 +35,26 @@ function workspace(
|
||||
status: input.status,
|
||||
activityAt: input.activityAt,
|
||||
diffStat: null,
|
||||
services: input.services ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const runningService: WorkspaceServicePayload = {
|
||||
serviceName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
status: "running",
|
||||
};
|
||||
|
||||
const stoppedService: WorkspaceServicePayload = {
|
||||
serviceName: "api",
|
||||
hostname: "main.api.localhost",
|
||||
port: 3001,
|
||||
url: "http://main.api.localhost:6767",
|
||||
status: "stopped",
|
||||
};
|
||||
|
||||
describe("applyStoredOrdering", () => {
|
||||
it("keeps unknown items on the baseline while applying stored order", () => {
|
||||
const result = applyStoredOrdering({
|
||||
@@ -117,6 +135,27 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
|
||||
expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed");
|
||||
});
|
||||
|
||||
it("threads services into workspace rows and derives hasRunningServices", () => {
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: "srv",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "/repo/main",
|
||||
projectId: "project-1",
|
||||
name: "main",
|
||||
status: "running",
|
||||
activityAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
services: [runningService, stoppedService],
|
||||
}),
|
||||
],
|
||||
projectOrder: [],
|
||||
workspaceOrderByScope: {},
|
||||
});
|
||||
|
||||
expect(projects[0]?.workspaces[0]?.services).toEqual([runningService, stoppedService]);
|
||||
expect(projects[0]?.workspaces[0]?.hasRunningServices).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves stored project order even when activity changes", () => {
|
||||
const initialWorkspaces: WorkspaceDescriptor[] = [
|
||||
workspace({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
@@ -20,6 +21,8 @@ export interface SidebarWorkspaceEntry {
|
||||
activityAt: Date | null;
|
||||
statusBucket: SidebarStateBucket;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
services: WorkspaceDescriptor["services"];
|
||||
hasRunningServices: boolean;
|
||||
}
|
||||
|
||||
export interface SidebarProjectEntry {
|
||||
@@ -135,6 +138,8 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
activityAt: workspace.activityAt,
|
||||
statusBucket: workspace.status,
|
||||
diffStat: workspace.diffStat,
|
||||
services: workspace.services,
|
||||
hasRunningServices: workspace.services.some((service) => service.status === "running"),
|
||||
};
|
||||
|
||||
project.workspaces.push(row);
|
||||
@@ -257,8 +262,12 @@ function toWorkspaceDescriptor(payload: {
|
||||
name: string;
|
||||
status: WorkspaceDescriptor["status"];
|
||||
activityAt: string | null;
|
||||
services?: WorkspaceDescriptorPayload["services"];
|
||||
}): WorkspaceDescriptor {
|
||||
return normalizeWorkspaceDescriptor(payload);
|
||||
return normalizeWorkspaceDescriptor({
|
||||
...payload,
|
||||
services: payload.services ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
export function useSidebarWorkspacesList(options?: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { agentPanelRegistration } from "@/panels/agent-panel";
|
||||
import { draftPanelRegistration } from "@/panels/draft-panel";
|
||||
import { filePanelRegistration } from "@/panels/file-panel";
|
||||
import { registerPanel } from "@/panels/panel-registry";
|
||||
import { setupPanelRegistration } from "@/panels/setup-panel";
|
||||
import { terminalPanelRegistration } from "@/panels/terminal-panel";
|
||||
|
||||
let panelsRegistered = false;
|
||||
@@ -12,6 +13,7 @@ export function ensurePanelsRegistered(): void {
|
||||
}
|
||||
registerPanel(draftPanelRegistration);
|
||||
registerPanel(agentPanelRegistration);
|
||||
registerPanel(setupPanelRegistration);
|
||||
registerPanel(terminalPanelRegistration);
|
||||
registerPanel(filePanelRegistration);
|
||||
panelsRegistered = true;
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
|
||||
function useSetupPanelDescriptor(
|
||||
target: { kind: "setup"; workspaceId: string },
|
||||
context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor {
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: context.serverId,
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null));
|
||||
|
||||
if (snapshot?.status === "completed") {
|
||||
return {
|
||||
label: "Setup",
|
||||
subtitle: "Setup completed",
|
||||
titleState: "ready",
|
||||
icon: CheckCircle2,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (snapshot?.status === "failed") {
|
||||
return {
|
||||
label: "Setup",
|
||||
subtitle: "Setup failed",
|
||||
titleState: "ready",
|
||||
icon: CircleAlert,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: "Setup",
|
||||
subtitle: "Workspace setup",
|
||||
titleState: "ready",
|
||||
icon: SquareTerminal,
|
||||
statusBucket: snapshot?.status === "running" ? "running" : null,
|
||||
};
|
||||
}
|
||||
|
||||
type CommandStatus = "running" | "completed" | "failed";
|
||||
|
||||
function CommandStatusIcon({ status }: { status: CommandStatus }) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (status === "running") {
|
||||
return <ActivityIndicator size={14} color={theme.colors.foreground} />;
|
||||
}
|
||||
if (status === "completed") {
|
||||
return <CheckCircle2 size={14} color={theme.colors.palette.green[500]} />;
|
||||
}
|
||||
return <CircleAlert size={14} color={theme.colors.palette.red[500]} />;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process carriage returns in log text so progress-bar output renders cleanly.
|
||||
* Splits on \r, keeps only the last segment per CR-delimited group (unless followed by \n).
|
||||
*/
|
||||
function processCarriageReturns(text: string): string {
|
||||
if (!text.includes("\r")) return text;
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (!line.includes("\r")) return line;
|
||||
const segments = line.split("\r");
|
||||
return segments[segments.length - 1];
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function SetupPanel() {
|
||||
const { theme } = useUnistyles();
|
||||
const { serverId, target } = usePaneContext();
|
||||
invariant(target.kind === "setup", "SetupPanel requires setup target");
|
||||
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId,
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null));
|
||||
const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||
|
||||
// On mount, if no snapshot in the store, request cached status from server
|
||||
const requestedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (snapshot || requestedRef.current || !client) return;
|
||||
requestedRef.current = true;
|
||||
client
|
||||
.fetchWorkspaceSetupStatus(target.workspaceId)
|
||||
.then((response) => {
|
||||
if (response.snapshot) {
|
||||
upsertProgress({
|
||||
serverId,
|
||||
payload: { workspaceId: response.workspaceId, ...response.snapshot },
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Server may not support this yet — ignore
|
||||
});
|
||||
}, [client, snapshot, serverId, target.workspaceId, upsertProgress]);
|
||||
|
||||
const commands = snapshot?.detail.commands ?? [];
|
||||
const log = snapshot?.detail.log ?? "";
|
||||
const hasNoSetupCommands =
|
||||
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
|
||||
const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0);
|
||||
|
||||
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(new Set());
|
||||
const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<number>>(new Set());
|
||||
|
||||
const toggleExpanded = useCallback((index: number, isAutoExpanded: boolean) => {
|
||||
setExpandedIndices((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index) || isAutoExpanded) {
|
||||
next.delete(index);
|
||||
// If this was auto-expanded, record that the user manually collapsed it
|
||||
if (isAutoExpanded) {
|
||||
setManuallyCollapsed((mc) => new Set(mc).add(index));
|
||||
}
|
||||
} else {
|
||||
next.add(index);
|
||||
// If the user re-expands, remove from manually collapsed
|
||||
setManuallyCollapsed((mc) => {
|
||||
const next = new Set(mc);
|
||||
next.delete(index);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Determine which command should auto-expand (running or last completed).
|
||||
const autoExpandIndex = (() => {
|
||||
const running = commands.find((c) => c.status === "running");
|
||||
if (running) return running.index;
|
||||
if (commands.length > 0) return commands[commands.length - 1].index;
|
||||
return null;
|
||||
})();
|
||||
|
||||
const statusLabel = snapshot?.status === "running"
|
||||
? "Running"
|
||||
: snapshot?.status === "completed"
|
||||
? "Completed"
|
||||
: snapshot?.status === "failed"
|
||||
? "Failed"
|
||||
: "Waiting for setup output";
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
testID="workspace-setup-panel"
|
||||
>
|
||||
{/* Hidden element for status — preserves testID for E2E */}
|
||||
<Text
|
||||
style={styles.hiddenStatus}
|
||||
testID="workspace-setup-status"
|
||||
>{statusLabel}</Text>
|
||||
|
||||
{isWaiting ? (
|
||||
<View style={styles.waitingContainer}>
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.waitingText}>Setting up workspace...</Text>
|
||||
</View>
|
||||
) : hasNoSetupCommands ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text
|
||||
style={styles.emptyText}
|
||||
accessible
|
||||
accessibilityLabel="No setup commands ran for this workspace"
|
||||
>
|
||||
No setup commands ran for this workspace.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.commandList}>
|
||||
{commands.map((command) => {
|
||||
const isExpanded = expandedIndices.has(command.index);
|
||||
const hasError = command.status === "failed" && snapshot?.error;
|
||||
|
||||
// Per-command log: use command.log if available, fall back to detail.log for the auto-expand target
|
||||
const commandLog = (() => {
|
||||
if ("log" in command && typeof command.log === "string") {
|
||||
return command.log;
|
||||
}
|
||||
// Fallback: show detail.log on the auto-expand target command
|
||||
if (command.index === autoExpandIndex) return log;
|
||||
return "";
|
||||
})();
|
||||
const hasLog = commandLog.trim().length > 0;
|
||||
|
||||
// All non-running commands are expandable (completed/failed)
|
||||
const isExpandable = command.status !== "running" || hasLog || !!hasError;
|
||||
|
||||
// Auto-expand the active command unless the user manually collapsed it
|
||||
const isAutoExpanded =
|
||||
command.index === autoExpandIndex && !manuallyCollapsed.has(command.index);
|
||||
const showDetail = isExpanded || isAutoExpanded;
|
||||
|
||||
const processedLog = hasLog ? processCarriageReturns(commandLog) : "";
|
||||
|
||||
return (
|
||||
<View key={`${command.index}:${command.command}`} style={styles.commandItem}>
|
||||
<Pressable
|
||||
onPress={() => toggleExpanded(command.index, isAutoExpanded)}
|
||||
style={({ pressed }) => [
|
||||
styles.commandRow,
|
||||
showDetail && styles.commandRowExpanded,
|
||||
pressed && styles.commandRowPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: showDetail }}
|
||||
>
|
||||
<View style={styles.commandStatusIcon}>
|
||||
<CommandStatusIcon status={command.status} />
|
||||
</View>
|
||||
<Text style={styles.commandText} numberOfLines={1}>
|
||||
{command.command}
|
||||
</Text>
|
||||
{command.durationMs != null ? (
|
||||
<Text style={styles.commandDuration}>
|
||||
{formatDuration(command.durationMs)}
|
||||
</Text>
|
||||
) : null}
|
||||
<ChevronRight
|
||||
size={14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={[
|
||||
styles.chevron,
|
||||
showDetail && styles.chevronExpanded,
|
||||
]}
|
||||
/>
|
||||
</Pressable>
|
||||
{showDetail ? (
|
||||
<View style={styles.commandDetail}>
|
||||
{hasLog ? (
|
||||
<ScrollView
|
||||
style={styles.logScroll}
|
||||
contentContainerStyle={styles.logScrollContent}
|
||||
horizontal={false}
|
||||
showsVerticalScrollIndicator
|
||||
testID="workspace-setup-log"
|
||||
accessible
|
||||
accessibilityLabel="Workspace setup log"
|
||||
>
|
||||
<Text selectable style={styles.logText}>
|
||||
{processedLog}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View
|
||||
style={styles.logScrollContent}
|
||||
testID="workspace-setup-log"
|
||||
accessible
|
||||
accessibilityLabel="Workspace setup log"
|
||||
>
|
||||
<Text style={styles.emptyLogText}>No output</Text>
|
||||
</View>
|
||||
)}
|
||||
{hasError ? (
|
||||
<View style={styles.errorCard}>
|
||||
<Text selectable style={styles.errorText}>
|
||||
{snapshot.error}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* If there's log but no commands yet, or log without a target command, show standalone */}
|
||||
{commands.length === 0 && log.trim().length > 0 ? (
|
||||
<ScrollView
|
||||
style={styles.logScroll}
|
||||
contentContainerStyle={styles.logScrollContent}
|
||||
showsVerticalScrollIndicator
|
||||
testID="workspace-setup-log"
|
||||
accessible
|
||||
accessibilityLabel="Workspace setup log"
|
||||
>
|
||||
<Text selectable style={styles.logText}>
|
||||
{log}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
|
||||
{/* Show error at top level if no commands failed but there's a setup error */}
|
||||
{snapshot?.error && !commands.some((c) => c.status === "failed") ? (
|
||||
<View style={styles.errorCard}>
|
||||
<Text selectable style={styles.errorText}>
|
||||
{snapshot.error}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
export const setupPanelRegistration: PanelRegistration<"setup"> = {
|
||||
kind: "setup",
|
||||
component: SetupPanel,
|
||||
useDescriptor: useSetupPanelDescriptor,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
contentContainer: {
|
||||
padding: theme.spacing[4],
|
||||
flexGrow: 1,
|
||||
},
|
||||
hiddenStatus: {
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
overflow: "hidden",
|
||||
opacity: 0,
|
||||
},
|
||||
waitingContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
waitingText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
commandList: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
commandItem: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
commandRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
commandRowExpanded: {
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
commandRowPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
commandStatusIcon: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
commandText: {
|
||||
flex: 1,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
commandDuration: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
},
|
||||
chevron: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
chevronExpanded: {
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
commandDetail: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
logScroll: {
|
||||
maxHeight: 400,
|
||||
},
|
||||
logScrollContent: {
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
logText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 20,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
emptyLogText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
errorCard: {
|
||||
padding: theme.spacing[3],
|
||||
backgroundColor: theme.colors.palette.red[100],
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.palette.red[800],
|
||||
},
|
||||
}));
|
||||
@@ -26,9 +26,9 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
}, [isMobile, openAgentList]);
|
||||
|
||||
return (
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<View style={styles.container}>
|
||||
<MenuHeader borderless />
|
||||
<View style={styles.content}>
|
||||
<View style={styles.content} {...dragHandlers}>
|
||||
<View style={styles.logo}>
|
||||
<PaseoLogo size={56} />
|
||||
</View>
|
||||
|
||||
@@ -79,6 +79,9 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
}
|
||||
if (tab.target.kind === "setup") {
|
||||
return "Setup";
|
||||
}
|
||||
if (tab.target.kind === "terminal") {
|
||||
return "Terminal";
|
||||
}
|
||||
|
||||
@@ -139,6 +139,9 @@ function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
}
|
||||
if (tab.target.kind === "setup") {
|
||||
return "Setup";
|
||||
}
|
||||
if (tab.target.kind === "terminal") {
|
||||
return "Terminal";
|
||||
}
|
||||
@@ -152,6 +155,9 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
}
|
||||
if (tab.target.kind === "setup") {
|
||||
return "Workspace setup";
|
||||
}
|
||||
if (tab.target.kind === "agent") {
|
||||
return "Agent";
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ describe("workspace source of truth consumption", () => {
|
||||
status: "running",
|
||||
activityAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
diffStat: null,
|
||||
services: [],
|
||||
};
|
||||
|
||||
const header = resolveWorkspaceHeader({ workspace });
|
||||
|
||||
@@ -76,6 +76,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return `workspace-draft-close-${tab.target.draftId}`;
|
||||
}
|
||||
if (tab.target.kind === "setup") {
|
||||
return `workspace-setup-close-${encodeFilePathForPathSegment(tab.target.workspaceId)}`;
|
||||
}
|
||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "./session-store";
|
||||
|
||||
function workspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name ?? "main",
|
||||
status: input.status ?? "done",
|
||||
activityAt: input.activityAt ?? null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
services: input.services ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.getState().clearSession("test-server");
|
||||
});
|
||||
|
||||
describe("normalizeWorkspaceDescriptor", () => {
|
||||
it("normalizes workspace services and invalid activity timestamps", () => {
|
||||
const services = [
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
status: "running" as const,
|
||||
},
|
||||
];
|
||||
const workspace = normalizeWorkspaceDescriptor({
|
||||
id: "/repo/main",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "running",
|
||||
activityAt: "not-a-date",
|
||||
diffStat: null,
|
||||
services,
|
||||
});
|
||||
|
||||
expect(workspace.activityAt).toBeNull();
|
||||
expect(workspace.services).toEqual([
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
expect(workspace.services).not.toBe(services);
|
||||
});
|
||||
|
||||
it("defaults missing services to an empty array", () => {
|
||||
const payload = {
|
||||
id: "/repo/main",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
} as WorkspaceDescriptorPayload;
|
||||
|
||||
const workspace = normalizeWorkspaceDescriptor(payload);
|
||||
|
||||
expect(workspace.services).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWorkspaces", () => {
|
||||
it("preserves services on merged workspace entries", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession("test-server", null as unknown as DaemonClient);
|
||||
store.setWorkspaces(
|
||||
"test-server",
|
||||
new Map([["/repo/main", workspace({ id: "/repo/main", services: [] })]]),
|
||||
);
|
||||
|
||||
store.mergeWorkspaces("test-server", [
|
||||
workspace({
|
||||
id: "/repo/main",
|
||||
services: [
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.services).toEqual([
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -119,6 +119,7 @@ export interface WorkspaceDescriptor {
|
||||
status: WorkspaceDescriptorPayload["status"];
|
||||
activityAt: Date | null;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
services: WorkspaceDescriptorPayload["services"];
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceDescriptor(
|
||||
@@ -136,6 +137,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
status: payload.status,
|
||||
activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
|
||||
diffStat: payload.diffStat ?? null,
|
||||
services: (payload.services ?? []).map((s) => ({ ...s })),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||
import { create } from "zustand";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
|
||||
export type WorkspaceSetupProgressPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_setup_progress" }
|
||||
>["payload"];
|
||||
|
||||
export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface WorkspaceSetupStoreState {
|
||||
snapshots: Record<string, WorkspaceSetupSnapshot>;
|
||||
upsertProgress: (input: { serverId: string; payload: WorkspaceSetupProgressPayload }) => void;
|
||||
removeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
|
||||
clearServer: (serverId: string) => void;
|
||||
}
|
||||
|
||||
function buildWorkspaceSetupKey(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): string | null {
|
||||
return buildWorkspaceTabPersistenceKey(input);
|
||||
}
|
||||
|
||||
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) => ({
|
||||
snapshots: {},
|
||||
upsertProgress: ({ serverId, payload }) => {
|
||||
const key = buildWorkspaceSetupKey({ serverId, workspaceId: payload.workspaceId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
snapshots: {
|
||||
...state.snapshots,
|
||||
[key]: {
|
||||
...payload,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
removeWorkspace: ({ serverId, workspaceId }) => {
|
||||
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
if (!(key in state.snapshots)) {
|
||||
return state;
|
||||
}
|
||||
const next = { ...state.snapshots };
|
||||
delete next[key];
|
||||
return { snapshots: next };
|
||||
});
|
||||
},
|
||||
clearServer: (serverId) => {
|
||||
set((state) => {
|
||||
const nextEntries = Object.entries(state.snapshots).filter(
|
||||
([key]) => !key.startsWith(`${serverId}:`),
|
||||
);
|
||||
if (nextEntries.length === Object.keys(state.snapshots).length) {
|
||||
return state;
|
||||
}
|
||||
return { snapshots: Object.fromEntries(nextEntries) };
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -200,4 +200,19 @@ describe("workspace-tabs-store retargetTab", () => {
|
||||
expect(reopenedFileTabId).toBe(fileTabId);
|
||||
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(fileTabId);
|
||||
});
|
||||
|
||||
it("builds a deterministic setup tab keyed by workspace id", () => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
const workspaceKey = key as string;
|
||||
|
||||
const tabId = useWorkspaceTabsStore.getState().openOrFocusTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "setup", workspaceId: WORKSPACE_ID },
|
||||
});
|
||||
|
||||
expect(tabId).toBe(`setup_${WORKSPACE_ID}`);
|
||||
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ export type WorkspaceTabTarget =
|
||||
| { kind: "draft"; draftId: string }
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "file"; path: string };
|
||||
| { kind: "file"; path: string }
|
||||
| { kind: "setup"; workspaceId: string };
|
||||
|
||||
export type WorkspaceTab = {
|
||||
tabId: string;
|
||||
@@ -60,6 +61,10 @@ function normalizeTabTarget(
|
||||
const path = trimNonEmpty(value.path);
|
||||
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
||||
}
|
||||
if (value.kind === "setup") {
|
||||
const workspaceId = trimNonEmpty(value.workspaceId);
|
||||
return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -79,6 +84,9 @@ function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): b
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
if (left.kind === "setup" && right.kind === "setup") {
|
||||
return left.workspaceId === right.workspaceId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -92,6 +100,9 @@ function buildDeterministicTabId(target: WorkspaceTabTarget): string {
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
if (target.kind === "setup") {
|
||||
return `setup_${target.workspaceId}`;
|
||||
}
|
||||
return `file_${target.path}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/constants/layout";
|
||||
import { getDesktopWindow } from "@/desktop/electron/window";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
|
||||
|
||||
export async function toggleMaximize() {
|
||||
@@ -123,7 +124,15 @@ export function useDesktopDragHandlers(): DesktopDragViewProps {
|
||||
}, [isActive]);
|
||||
}
|
||||
|
||||
export function useTrafficLightPadding(): { left: number; right: number; top: number; side: 'left' | 'right' | null } {
|
||||
type RawWindowControlsPadding = {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
type WindowControlsPaddingRole = "sidebar" | "header" | "tabRow" | "explorerSidebar";
|
||||
|
||||
function useRawWindowControlsPadding(): RawWindowControlsPadding {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -178,23 +187,52 @@ export function useTrafficLightPadding(): { left: number; right: number; top: nu
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!getIsDesktop() || isFullscreen) {
|
||||
return { left: 0, right: 0, top: 0, side: null };
|
||||
}
|
||||
return useMemo((): RawWindowControlsPadding => {
|
||||
if (!getIsDesktop() || isFullscreen) {
|
||||
return { left: 0, right: 0, top: 0 };
|
||||
}
|
||||
|
||||
if (getIsDesktopMac()) {
|
||||
return {
|
||||
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
right: 0,
|
||||
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
if (getIsDesktopMac()) {
|
||||
return {
|
||||
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
right: 0,
|
||||
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
side: 'left',
|
||||
left: 0,
|
||||
right: DESKTOP_WINDOW_CONTROLS_WIDTH,
|
||||
top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
||||
};
|
||||
}, [isFullscreen]);
|
||||
}
|
||||
|
||||
export function useWindowControlsPadding(
|
||||
role: WindowControlsPaddingRole,
|
||||
): { left: number; right: number; top: number } {
|
||||
const sidebarOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const explorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const focusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
const rawPadding = useRawWindowControlsPadding();
|
||||
const sidebarClosed = !sidebarOpen;
|
||||
|
||||
let left = 0;
|
||||
let right = 0;
|
||||
let top = 0;
|
||||
|
||||
if (role === "sidebar") {
|
||||
left = rawPadding.left;
|
||||
top = rawPadding.top;
|
||||
} else if (role === "header") {
|
||||
left = sidebarClosed ? rawPadding.left : 0;
|
||||
right = explorerOpen ? 0 : rawPadding.right;
|
||||
} else if (role === "tabRow") {
|
||||
left = sidebarClosed && focusModeEnabled ? rawPadding.left : 0;
|
||||
right = focusModeEnabled && !explorerOpen ? rawPadding.right : 0;
|
||||
} else if (role === "explorerSidebar") {
|
||||
right = rawPadding.right;
|
||||
}
|
||||
|
||||
return {
|
||||
left: 0,
|
||||
right: DESKTOP_WINDOW_CONTROLS_WIDTH,
|
||||
top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
||||
side: 'right',
|
||||
};
|
||||
return useMemo(() => ({ left, right, top }), [left, right, top]);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ describe("workspace route parsing", () => {
|
||||
kind: "file",
|
||||
path: "src/index.ts",
|
||||
});
|
||||
expect(parseWorkspaceOpenIntent("setup:L3RtcC9yZXBv")).toEqual({
|
||||
kind: "setup",
|
||||
workspaceId: "/tmp/repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the plain workspace route when workspace context is provided", () => {
|
||||
|
||||
@@ -99,7 +99,8 @@ export type WorkspaceOpenIntent =
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "file"; path: string }
|
||||
| { kind: "draft"; draftId: string };
|
||||
| { kind: "draft"; draftId: string }
|
||||
| { kind: "setup"; workspaceId: string };
|
||||
|
||||
export function parseWorkspaceOpenIntent(
|
||||
value: string | null | undefined,
|
||||
@@ -136,6 +137,13 @@ export function parseWorkspaceOpenIntent(
|
||||
}
|
||||
return { kind: "file", path: decodedPath };
|
||||
}
|
||||
if (kind === "setup") {
|
||||
const workspaceId = decodeWorkspaceIdFromPathSegment(payload);
|
||||
if (!workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "setup", workspaceId };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
diffStat: null,
|
||||
services: [],
|
||||
hasRunningServices: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -87,8 +89,8 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("flattens git projects with a single workspace and keeps the new worktree action", () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
it("keeps single-workspace git projects as sections with a new worktree action", () => {
|
||||
const workspaceEntry = workspace({
|
||||
workspaceId: "/repo/main",
|
||||
workspaceKind: "local_checkout",
|
||||
});
|
||||
@@ -96,16 +98,14 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "git",
|
||||
workspaces: [flattenedWorkspace],
|
||||
workspaces: [workspaceEntry],
|
||||
}),
|
||||
collapsed: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "workspace_link",
|
||||
workspace: flattenedWorkspace,
|
||||
selected: false,
|
||||
chevron: null,
|
||||
kind: "project_section",
|
||||
chevron: "expand",
|
||||
trailingAction: "new_worktree",
|
||||
});
|
||||
});
|
||||
@@ -131,10 +131,10 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
});
|
||||
|
||||
describe("isSidebarProjectFlattened", () => {
|
||||
it("returns true for single-workspace projects regardless of kind", () => {
|
||||
it("returns true only for single-workspace non-git projects", () => {
|
||||
expect(
|
||||
isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSidebarProjectFlattened(project({ projectKind: "non_git", workspaces: [workspace()] })),
|
||||
).toBe(true);
|
||||
|
||||
@@ -16,6 +16,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
diffStat: null,
|
||||
services: [],
|
||||
hasRunningServices: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,7 +78,7 @@ describe("buildSidebarShortcutModel", () => {
|
||||
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
|
||||
});
|
||||
|
||||
it("ignores collapsed state for flattened single-workspace projects", () => {
|
||||
it("still hides collapsed single-workspace git projects because they are not flattened", () => {
|
||||
const projects = [project("p1", [workspace("s1", "/repo/main")])];
|
||||
|
||||
const model = buildSidebarShortcutModel({
|
||||
@@ -84,7 +86,7 @@ describe("buildSidebarShortcutModel", () => {
|
||||
collapsedProjectKeys: new Set<string>(["p1"]),
|
||||
});
|
||||
|
||||
expect(model.visibleTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
|
||||
expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
|
||||
expect(model.visibleTargets).toEqual([]);
|
||||
expect(model.shortcutTargets).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,6 +96,7 @@ describe("tool-call-display", () => {
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||
log: "",
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ function workspace(
|
||||
status: input.status ?? "done",
|
||||
activityAt: input.activityAt ?? null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
services: input.services ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ export function normalizeWorkspaceTabTarget(
|
||||
const path = trimNonEmpty(value.path);
|
||||
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
||||
}
|
||||
if (value.kind === "setup") {
|
||||
const workspaceId = trimNonEmpty(value.workspaceId);
|
||||
return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -44,6 +48,9 @@ export function workspaceTabTargetsEqual(
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
if (left.kind === "setup" && right.kind === "setup") {
|
||||
return left.workspaceId === right.workspaceId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -57,6 +64,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
if (target.kind === "setup") {
|
||||
return `setup_${target.workspaceId}`;
|
||||
}
|
||||
return `file_${target.path}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createPermitCommand } from "./commands/permit/index.js";
|
||||
import { createProviderCommand } from "./commands/provider/index.js";
|
||||
import { createScheduleCommand } from "./commands/schedule/index.js";
|
||||
import { createSpeechCommand } from "./commands/speech/index.js";
|
||||
import { createTerminalCommand } from "./commands/terminal/index.js";
|
||||
import { createWorktreeCommand } from "./commands/worktree/index.js";
|
||||
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
|
||||
import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js";
|
||||
@@ -143,6 +144,9 @@ export function createCli(): Command {
|
||||
// Chat commands
|
||||
program.addCommand(createChatCommand());
|
||||
|
||||
// Terminal commands
|
||||
program.addCommand(createTerminalCommand());
|
||||
|
||||
// Loop commands
|
||||
program.addCommand(createLoopCommand());
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
|
||||
interface SpeechDownloadRow {
|
||||
modelId: string;
|
||||
status: "downloaded";
|
||||
}
|
||||
|
||||
const speechDownloadSchema: OutputSchema<SpeechDownloadRow> = {
|
||||
idField: "modelId",
|
||||
columns: [
|
||||
{ header: "MODEL", field: "modelId", width: 36 },
|
||||
{ header: "STATUS", field: "status", width: 12, color: () => "green" },
|
||||
],
|
||||
};
|
||||
|
||||
export type SpeechDownloadResult = ListResult<SpeechDownloadRow>;
|
||||
|
||||
export interface SpeechDownloadOptions extends CommandOptions {
|
||||
host?: string;
|
||||
model?: string[];
|
||||
}
|
||||
|
||||
export async function runSpeechDownloadCommand(
|
||||
options: SpeechDownloadOptions,
|
||||
_command: Command,
|
||||
): Promise<SpeechDownloadResult> {
|
||||
const client = await connectToDaemon({ host: options.host });
|
||||
try {
|
||||
const response = await client.downloadSpeechModels({
|
||||
modelIds: options.model && options.model.length > 0 ? options.model : undefined,
|
||||
});
|
||||
if (response.error) {
|
||||
const commandError: CommandError = {
|
||||
code: "SPEECH_MODELS_DOWNLOAD_FAILED",
|
||||
message: response.error,
|
||||
};
|
||||
throw commandError;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "list",
|
||||
data: response.downloadedModelIds.map((modelId) => ({
|
||||
modelId,
|
||||
status: "downloaded" as const,
|
||||
})),
|
||||
schema: speechDownloadSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
if (typeof error === "object" && error && "code" in error && "message" in error) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const commandError: CommandError = {
|
||||
code: "SPEECH_MODELS_DOWNLOAD_FAILED",
|
||||
message: `Failed to download speech models: ${message}`,
|
||||
};
|
||||
throw commandError;
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { runSpeechModelsCommand } from "./models.js";
|
||||
import { runSpeechDownloadCommand } from "./download.js";
|
||||
import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js";
|
||||
|
||||
export function createSpeechCommand(): Command {
|
||||
const speech = new Command("speech").description("Manage local speech models");
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
speech.command("models").description("List local speech model download status"),
|
||||
).action(withOutput(runSpeechModelsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
speech
|
||||
.command("download")
|
||||
.description("Download local speech models")
|
||||
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, []),
|
||||
).action(withOutput(runSpeechDownloadCommand));
|
||||
|
||||
return speech;
|
||||
return new Command("speech").description("Speech commands");
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
|
||||
interface SpeechModelListItem {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: "downloaded" | "missing";
|
||||
modelDir: string;
|
||||
missingFiles: string;
|
||||
}
|
||||
|
||||
const speechModelsSchema: OutputSchema<SpeechModelListItem> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "MODEL", field: "id", width: 36 },
|
||||
{ header: "KIND", field: "kind", width: 12 },
|
||||
{
|
||||
header: "STATUS",
|
||||
field: "status",
|
||||
width: 12,
|
||||
color: (value) => (value === "downloaded" ? "green" : "yellow"),
|
||||
},
|
||||
{ header: "MODEL DIR", field: "modelDir", width: 44 },
|
||||
{ header: "MISSING FILES", field: "missingFiles", width: 40 },
|
||||
],
|
||||
};
|
||||
|
||||
export type SpeechModelsResult = ListResult<SpeechModelListItem>;
|
||||
|
||||
export interface SpeechModelsOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export async function runSpeechModelsCommand(
|
||||
options: SpeechModelsOptions,
|
||||
_command: Command,
|
||||
): Promise<SpeechModelsResult> {
|
||||
const client = await connectToDaemon({ host: options.host });
|
||||
try {
|
||||
const response = await client.listSpeechModels();
|
||||
const rows: SpeechModelListItem[] = response.models
|
||||
.slice()
|
||||
.sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id))
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
kind: model.kind,
|
||||
status: model.isDownloaded ? "downloaded" : "missing",
|
||||
modelDir: model.modelDir,
|
||||
missingFiles: model.missingFiles?.join(", ") ?? "",
|
||||
}));
|
||||
return {
|
||||
type: "list",
|
||||
data: rows,
|
||||
schema: speechModelsSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const commandError: CommandError = {
|
||||
code: "SPEECH_MODELS_LIST_FAILED",
|
||||
message: `Failed to list speech models: ${message}`,
|
||||
};
|
||||
throw commandError;
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Command } from "commander";
|
||||
import { renderError, toCommandError } from "../../output/render.js";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
resolveTerminalId,
|
||||
toTerminalCommandError,
|
||||
type TerminalCommandOptions,
|
||||
} from "./shared.js";
|
||||
|
||||
export interface TerminalCaptureOptions extends TerminalCommandOptions {
|
||||
start?: string;
|
||||
end?: string;
|
||||
scrollback?: boolean;
|
||||
ansi?: boolean;
|
||||
}
|
||||
|
||||
export async function runCaptureCommand(
|
||||
terminalId: string,
|
||||
_options: TerminalCaptureOptions,
|
||||
command: Command,
|
||||
): Promise<void> {
|
||||
const options = command.optsWithGlobals() as TerminalCaptureOptions;
|
||||
|
||||
try {
|
||||
const payload = await executeCaptureCommand(terminalId, options);
|
||||
if (options.json) {
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{
|
||||
terminalId: payload.terminalId,
|
||||
lines: payload.lines,
|
||||
totalLines: payload.totalLines,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.lines.length > 0) {
|
||||
process.stdout.write(payload.lines.join("\n") + "\n");
|
||||
}
|
||||
} catch (err) {
|
||||
const output = renderError(toCommandError(err), {
|
||||
format: options.json ? "json" : "table",
|
||||
noColor: options.color === false,
|
||||
});
|
||||
process.stderr.write(output + "\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCaptureCommand(
|
||||
terminalId: string,
|
||||
options: TerminalCaptureOptions,
|
||||
): Promise<{ terminalId: string; lines: string[]; totalLines: number }> {
|
||||
const { client } = await connectTerminalClient(options.host);
|
||||
|
||||
try {
|
||||
const resolvedId = await resolveTerminalId(client, terminalId);
|
||||
if (!resolvedId) {
|
||||
throw {
|
||||
code: "TERMINAL_NOT_FOUND",
|
||||
message: `No terminal found matching: ${terminalId}`,
|
||||
details: "Use `paseo terminal ls --all` to list available terminals.",
|
||||
};
|
||||
}
|
||||
|
||||
const start = options.scrollback ? 0 : parseLineNumber("--start", options.start);
|
||||
const end = parseLineNumber("--end", options.end);
|
||||
|
||||
return await client.captureTerminal(resolvedId, {
|
||||
...(start === undefined ? {} : { start }),
|
||||
...(end === undefined ? {} : { end }),
|
||||
stripAnsi: !options.ansi,
|
||||
});
|
||||
} catch (err) {
|
||||
throw toTerminalCommandError("TERMINAL_CAPTURE_FAILED", "capture terminal output", err);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function parseLineNumber(flag: string, value?: string): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw {
|
||||
code: "INVALID_LINE_NUMBER",
|
||||
message: `Invalid ${flag} value: ${value}`,
|
||||
details: "Use an integer line number.",
|
||||
};
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Command } from "commander";
|
||||
import type { SingleResult, CommandError } from "../../output/index.js";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
toTerminalCommandError,
|
||||
type TerminalCommandOptions,
|
||||
} from "./shared.js";
|
||||
import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js";
|
||||
|
||||
export interface TerminalCreateOptions extends TerminalCommandOptions {
|
||||
cwd?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export async function runCreateCommand(
|
||||
options: TerminalCreateOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<TerminalRow>> {
|
||||
const { client } = await connectTerminalClient(options.host);
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
|
||||
try {
|
||||
const payload = await client.createTerminal(cwd, options.name);
|
||||
if (!payload.terminal) {
|
||||
const error: CommandError = {
|
||||
code: "TERMINAL_CREATE_FAILED",
|
||||
message: payload.error ?? "Failed to create terminal",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
type: "single",
|
||||
data: toTerminalRow(payload.terminal),
|
||||
schema: terminalSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
throw toTerminalCommandError("TERMINAL_CREATE_FAILED", "create terminal", err);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Command } from "commander";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addDaemonHostOption, addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
import { runCaptureCommand } from "./capture.js";
|
||||
import { runCreateCommand } from "./create.js";
|
||||
import { runKillCommand } from "./kill.js";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runSendKeysCommand } from "./send-keys.js";
|
||||
|
||||
export function createTerminalCommand(): Command {
|
||||
const terminal = new Command("terminal").description("Manage workspace terminals");
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
terminal
|
||||
.command("ls")
|
||||
.description("List terminals")
|
||||
.option("--all", "List terminals across all workspaces")
|
||||
.option("--cwd <path>", "Workspace directory"),
|
||||
).action(withOutput(runLsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
terminal
|
||||
.command("create")
|
||||
.description("Create a terminal")
|
||||
.option("--cwd <path>", "Workspace directory")
|
||||
.option("--name <name>", "Terminal name"),
|
||||
).action(withOutput(runCreateCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
terminal
|
||||
.command("kill")
|
||||
.description("Kill a terminal")
|
||||
.argument("<terminal-id>", "Terminal ID, ID prefix, or name"),
|
||||
).action(withOutput(runKillCommand));
|
||||
|
||||
addDaemonHostOption(
|
||||
terminal
|
||||
.command("capture")
|
||||
.description("Capture terminal output")
|
||||
.argument("<terminal-id>", "Terminal ID, ID prefix, or name")
|
||||
.option("--start <n>", "Capture start line")
|
||||
.option("--end <n>", "Capture end line")
|
||||
.option("-S, --scrollback", "Capture from the beginning of scrollback")
|
||||
.option("--ansi", "Preserve ANSI escape codes")
|
||||
.option("--json", "Output in JSON format"),
|
||||
).action(runCaptureCommand);
|
||||
|
||||
addDaemonHostOption(
|
||||
terminal
|
||||
.command("send-keys")
|
||||
.description("Send keys to a terminal")
|
||||
.argument("<terminal-id>", "Terminal ID, ID prefix, or name")
|
||||
.argument("<keys...>", "Keys to send")
|
||||
.option("-l, --literal", "Send raw keys without interpreting special tokens")
|
||||
.option("--json", "Output in JSON format"),
|
||||
).action(runSendKeysCommand);
|
||||
|
||||
return terminal;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CommandError, SingleResult } from "../../output/index.js";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
resolveTerminalId,
|
||||
toTerminalCommandError,
|
||||
type TerminalCommandOptions,
|
||||
} from "./shared.js";
|
||||
import { terminalKillSchema, type TerminalKillRow } from "./schema.js";
|
||||
|
||||
export async function runKillCommand(
|
||||
terminalId: string,
|
||||
options: TerminalCommandOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<TerminalKillRow>> {
|
||||
const { client } = await connectTerminalClient(options.host);
|
||||
|
||||
try {
|
||||
const resolvedId = await requireTerminalId(client, terminalId);
|
||||
const payload = await client.killTerminal(resolvedId);
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
terminalId: payload.terminalId,
|
||||
success: payload.success,
|
||||
},
|
||||
schema: terminalKillSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
throw toTerminalCommandError("TERMINAL_KILL_FAILED", "kill terminal", err);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function requireTerminalId(
|
||||
client: Awaited<ReturnType<typeof connectTerminalClient>>["client"],
|
||||
terminalId: string,
|
||||
): Promise<string> {
|
||||
const resolvedId = await resolveTerminalId(client, terminalId);
|
||||
if (resolvedId) {
|
||||
return resolvedId;
|
||||
}
|
||||
|
||||
const error: CommandError = {
|
||||
code: "TERMINAL_NOT_FOUND",
|
||||
message: `No terminal found matching: ${terminalId}`,
|
||||
details: "Use `paseo terminal ls --all` to list available terminals.",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Command } from "commander";
|
||||
import type { ListResult } from "../../output/index.js";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
toTerminalCommandError,
|
||||
type TerminalCommandOptions,
|
||||
} from "./shared.js";
|
||||
import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js";
|
||||
|
||||
export interface TerminalLsOptions extends TerminalCommandOptions {
|
||||
all?: boolean;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export async function runLsCommand(
|
||||
options: TerminalLsOptions,
|
||||
_command: Command,
|
||||
): Promise<ListResult<TerminalRow>> {
|
||||
const { client } = await connectTerminalClient(options.host);
|
||||
const cwd = options.all ? undefined : (options.cwd ?? process.cwd());
|
||||
|
||||
try {
|
||||
const payload = cwd === undefined ? await client.listTerminals() : await client.listTerminals(cwd);
|
||||
return {
|
||||
type: "list",
|
||||
data: payload.terminals.map((terminal) => toTerminalRow(terminal, payload.cwd ?? cwd)),
|
||||
schema: terminalSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
throw toTerminalCommandError("TERMINAL_LIST_FAILED", "list terminals", err);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { OutputSchema } from "../../output/index.js";
|
||||
|
||||
export interface TerminalRow {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface TerminalKillRow {
|
||||
terminalId: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export const terminalSchema: OutputSchema<TerminalRow> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "ID", field: (row) => row.id.slice(0, 8), width: 8 },
|
||||
{ header: "NAME", field: "name", width: 24 },
|
||||
{ header: "CWD", field: "cwd", width: 48 },
|
||||
],
|
||||
};
|
||||
|
||||
export const terminalKillSchema: OutputSchema<TerminalKillRow> = {
|
||||
idField: "terminalId",
|
||||
columns: [
|
||||
{ header: "ID", field: (row) => row.terminalId.slice(0, 8), width: 8 },
|
||||
{ header: "SUCCESS", field: "success", width: 8 },
|
||||
],
|
||||
};
|
||||
|
||||
export function toTerminalRow(
|
||||
terminal: {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd?: string;
|
||||
},
|
||||
cwd?: string,
|
||||
): TerminalRow {
|
||||
return {
|
||||
id: terminal.id,
|
||||
name: terminal.name,
|
||||
cwd: terminal.cwd ?? cwd ?? "-",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Command } from "commander";
|
||||
import { renderError, toCommandError } from "../../output/render.js";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
resolveTerminalId,
|
||||
toTerminalCommandError,
|
||||
type TerminalCommandOptions,
|
||||
} from "./shared.js";
|
||||
|
||||
export interface TerminalSendKeysOptions extends TerminalCommandOptions {
|
||||
literal?: boolean;
|
||||
}
|
||||
|
||||
export async function runSendKeysCommand(
|
||||
terminalId: string,
|
||||
keys: string[],
|
||||
_options: TerminalSendKeysOptions,
|
||||
command: Command,
|
||||
): Promise<void> {
|
||||
const options = command.optsWithGlobals() as TerminalSendKeysOptions;
|
||||
|
||||
try {
|
||||
const payload = await executeSendKeysCommand(terminalId, keys, options);
|
||||
if (options.json) {
|
||||
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
||||
}
|
||||
} catch (err) {
|
||||
const output = renderError(toCommandError(err), {
|
||||
format: options.json ? "json" : "table",
|
||||
noColor: options.color === false,
|
||||
});
|
||||
process.stderr.write(output + "\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeSendKeysCommand(
|
||||
terminalId: string,
|
||||
keys: string[],
|
||||
options: TerminalSendKeysOptions,
|
||||
): Promise<{ terminalId: string; keysSent: number }> {
|
||||
const { client } = await connectTerminalClient(options.host);
|
||||
|
||||
try {
|
||||
const resolvedId = await resolveTerminalId(client, terminalId);
|
||||
if (!resolvedId) {
|
||||
throw {
|
||||
code: "TERMINAL_NOT_FOUND",
|
||||
message: `No terminal found matching: ${terminalId}`,
|
||||
details: "Use `paseo terminal ls --all` to list available terminals.",
|
||||
};
|
||||
}
|
||||
|
||||
const data = keys.map((key) => resolveKeyToken(key, options.literal === true)).join("");
|
||||
client.sendTerminalInput(resolvedId, { type: "input", data });
|
||||
|
||||
return {
|
||||
terminalId: resolvedId,
|
||||
keysSent: data.length,
|
||||
};
|
||||
} catch (err) {
|
||||
throw toTerminalCommandError("TERMINAL_SEND_KEYS_FAILED", "send terminal keys", err);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveKeyToken(key: string, literal: boolean): string {
|
||||
if (literal) {
|
||||
return key;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "Enter":
|
||||
return "\r";
|
||||
case "Tab":
|
||||
return "\t";
|
||||
case "Escape":
|
||||
return "\u001b";
|
||||
case "Space":
|
||||
return " ";
|
||||
case "BSpace":
|
||||
return "\u007f";
|
||||
case "C-c":
|
||||
return "\u0003";
|
||||
case "C-d":
|
||||
return "\u0004";
|
||||
case "C-z":
|
||||
return "\u001a";
|
||||
case "C-l":
|
||||
return "\u000c";
|
||||
case "C-a":
|
||||
return "\u0001";
|
||||
case "C-e":
|
||||
return "\u0005";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandError, CommandOptions } from "../../output/index.js";
|
||||
|
||||
export interface TerminalCommandOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
interface TerminalLike {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export async function connectTerminalClient(host?: string) {
|
||||
const daemonHost = getDaemonHost({ host });
|
||||
try {
|
||||
const client = await connectToDaemon({ host });
|
||||
return { client, daemonHost };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${daemonHost}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function toTerminalCommandError(code: string, action: string, err: unknown): CommandError {
|
||||
if (err && typeof err === "object" && "code" in err && "message" in err) {
|
||||
return err as CommandError;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const rpcCode =
|
||||
typeof err === "object" && err !== null && "code" in err && typeof err.code === "string"
|
||||
? err.code
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
code: rpcCode ?? code,
|
||||
message: `Failed to ${action}: ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveTerminalId(
|
||||
client: Awaited<ReturnType<typeof connectToDaemon>>,
|
||||
idOrName: string,
|
||||
): Promise<string | null> {
|
||||
const payload = await client.listTerminals();
|
||||
return resolveTerminalIdentifier(idOrName, payload.terminals);
|
||||
}
|
||||
|
||||
function resolveTerminalIdentifier(idOrName: string, terminals: TerminalLike[]): string | null {
|
||||
if (!idOrName || terminals.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = idOrName.toLowerCase();
|
||||
|
||||
const exactMatch = terminals.find((terminal) => terminal.id === idOrName);
|
||||
if (exactMatch) {
|
||||
return exactMatch.id;
|
||||
}
|
||||
|
||||
const prefixMatches = terminals.filter((terminal) => terminal.id.toLowerCase().startsWith(query));
|
||||
if (prefixMatches.length === 1 && prefixMatches[0]) {
|
||||
return prefixMatches[0].id;
|
||||
}
|
||||
if (prefixMatches.length > 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nameMatches = terminals.filter((terminal) => terminal.name?.toLowerCase() === query);
|
||||
if (nameMatches.length === 1 && nameMatches[0]) {
|
||||
return nameMatches[0].id;
|
||||
}
|
||||
|
||||
const partialNameMatches = terminals.filter((terminal) =>
|
||||
terminal.name?.toLowerCase().includes(query),
|
||||
);
|
||||
if (partialNameMatches.length === 1 && partialNameMatches[0]) {
|
||||
return partialNameMatches[0].id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -213,6 +213,82 @@ describe("DaemonClient", () => {
|
||||
expect(client.getConnectionState().status).toBe("disposed");
|
||||
});
|
||||
|
||||
test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const events: Array<Parameters<Parameters<typeof client.subscribe>[0]>[0]> = [];
|
||||
client.subscribe((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "workspace_setup_progress",
|
||||
payload: {
|
||||
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
log: "phase-one\n",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
log: "phase-one\n",
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "workspace_setup_progress",
|
||||
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
payload: {
|
||||
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
log: "phase-one\n",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
log: "phase-one\n",
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("sends explicit shutdown_server_request via shutdownServer", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -36,16 +36,16 @@ import type {
|
||||
ProjectIconResponse,
|
||||
OpenProjectResponseMessage,
|
||||
ArchiveWorkspaceResponseMessage,
|
||||
WorkspaceSetupStatusResponseMessage,
|
||||
ListCommandsResponse,
|
||||
ListProviderModelsResponseMessage,
|
||||
ListAvailableProvidersResponse,
|
||||
SpeechModelsListResponse,
|
||||
SpeechModelsDownloadResponse,
|
||||
ListTerminalsResponse,
|
||||
CreateTerminalResponse,
|
||||
SubscribeTerminalResponse,
|
||||
TerminalState,
|
||||
KillTerminalResponse,
|
||||
CaptureTerminalResponse,
|
||||
TerminalInput,
|
||||
SessionInboundMessage,
|
||||
SessionOutboundMessage,
|
||||
@@ -124,6 +124,11 @@ export type DaemonEvent =
|
||||
workspaceId: string;
|
||||
payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
|
||||
}
|
||||
| {
|
||||
type: "workspace_setup_progress";
|
||||
workspaceId: string;
|
||||
payload: Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }>["payload"];
|
||||
}
|
||||
| {
|
||||
type: "agent_stream";
|
||||
agentId: string;
|
||||
@@ -217,8 +222,6 @@ type FileExplorerPayload = FileExplorerResponse["payload"];
|
||||
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
||||
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
||||
type SpeechModelsListPayload = SpeechModelsListResponse["payload"];
|
||||
type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"];
|
||||
type ListCommandsPayload = ListCommandsResponse["payload"];
|
||||
type ListCommandsDraftConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
@@ -241,6 +244,7 @@ type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
type CreateTerminalPayload = CreateTerminalResponse["payload"];
|
||||
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
|
||||
type KillTerminalPayload = KillTerminalResponse["payload"];
|
||||
type CaptureTerminalPayload = CaptureTerminalResponse["payload"];
|
||||
type ChatCreatePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "chat/create/response" }
|
||||
@@ -459,6 +463,7 @@ export type InspectScheduleOptions = {
|
||||
};
|
||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
||||
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
|
||||
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
|
||||
|
||||
export type FetchAgentResult = {
|
||||
agent: AgentSnapshotPayload;
|
||||
@@ -1315,6 +1320,21 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async fetchWorkspaceSetupStatus(
|
||||
workspaceId: string,
|
||||
requestId?: string,
|
||||
): Promise<WorkspaceSetupStatusPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "workspace_setup_status_request",
|
||||
workspaceId,
|
||||
},
|
||||
responseType: "workspace_setup_status_response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
@@ -2487,32 +2507,6 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listSpeechModels(requestId?: string): Promise<SpeechModelsListPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "speech_models_list_request",
|
||||
},
|
||||
responseType: "speech_models_list_response",
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async downloadSpeechModels(options?: {
|
||||
modelIds?: string[];
|
||||
requestId?: string;
|
||||
}): Promise<SpeechModelsDownloadPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: options?.requestId,
|
||||
message: {
|
||||
type: "speech_models_download_request",
|
||||
modelIds: options?.modelIds,
|
||||
},
|
||||
responseType: "speech_models_download_response",
|
||||
timeout: 30 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
async listCommands(agentId: string, requestId?: string): Promise<ListCommandsPayload>;
|
||||
async listCommands(agentId: string, options?: ListCommandsOptions): Promise<ListCommandsPayload>;
|
||||
async listCommands(
|
||||
@@ -2739,11 +2733,11 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listTerminals(cwd: string, requestId?: string): Promise<ListTerminalsPayload> {
|
||||
async listTerminals(cwd?: string, requestId?: string): Promise<ListTerminalsPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "list_terminals_request",
|
||||
cwd,
|
||||
...(cwd === undefined ? {} : { cwd }),
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendCorrelatedRequest({
|
||||
@@ -2857,6 +2851,29 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async captureTerminal(
|
||||
terminalId: string,
|
||||
options?: { start?: number; end?: number; stripAnsi?: boolean },
|
||||
requestId?: string,
|
||||
): Promise<CaptureTerminalPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "capture_terminal_request",
|
||||
terminalId,
|
||||
...(options?.start === undefined ? {} : { start: options.start }),
|
||||
...(options?.end === undefined ? {} : { end: options.end }),
|
||||
...(options?.stripAnsi === undefined ? {} : { stripAnsi: options.stripAnsi }),
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendCorrelatedRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
responseType: "capture_terminal_response",
|
||||
timeout: 10000,
|
||||
options: { skipQueue: true },
|
||||
});
|
||||
}
|
||||
|
||||
async createChatRoom(options: CreateChatRoomOptions): Promise<ChatCreatePayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: options.requestId,
|
||||
@@ -3528,6 +3545,12 @@ export class DaemonClient {
|
||||
workspaceId: msg.payload.kind === "upsert" ? msg.payload.workspace.id : msg.payload.id,
|
||||
payload: msg.payload,
|
||||
};
|
||||
case "workspace_setup_progress":
|
||||
return {
|
||||
type: "workspace_setup_progress",
|
||||
workspaceId: msg.payload.workspaceId,
|
||||
payload: msg.payload,
|
||||
};
|
||||
case "agent_stream":
|
||||
return {
|
||||
type: "agent_stream",
|
||||
|
||||
@@ -48,11 +48,14 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
import { expandUserPath } from "../path-utils.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
||||
import type { ServiceRouteStore } from "../service-proxy.js";
|
||||
|
||||
export interface AgentManagementMcpOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
terminalManager?: TerminalManager | null;
|
||||
serviceRouteStore?: ServiceRouteStore;
|
||||
getDaemonTcpPort?: () => number | null;
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
}
|
||||
@@ -296,21 +299,25 @@ export async function createAgentManagementMcpServer(
|
||||
};
|
||||
|
||||
let resolvedCwd = expandUserPath(cwd);
|
||||
let worktreeConfig: WorktreeConfig | undefined;
|
||||
let worktreeBootstrap:
|
||||
| {
|
||||
worktree: WorktreeConfig;
|
||||
shouldBootstrap: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (worktreeName) {
|
||||
if (!baseBranch) {
|
||||
throw new Error("baseBranch is required when creating a worktree");
|
||||
}
|
||||
const worktree = await createAgentWorktree({
|
||||
worktreeBootstrap = await createAgentWorktree({
|
||||
branchName: worktreeName,
|
||||
cwd: resolvedCwd,
|
||||
baseBranch,
|
||||
worktreeSlug: worktreeName,
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
resolvedCwd = worktree.worktreePath;
|
||||
worktreeConfig = worktree;
|
||||
resolvedCwd = worktreeBootstrap.worktree.worktreePath;
|
||||
}
|
||||
|
||||
const provider: AgentProvider = agentType ?? "claude";
|
||||
@@ -322,10 +329,11 @@ export async function createAgentManagementMcpServer(
|
||||
title: normalizedTitle ?? undefined,
|
||||
});
|
||||
|
||||
if (worktreeConfig) {
|
||||
if (worktreeBootstrap) {
|
||||
void runAsyncWorktreeBootstrap({
|
||||
agentId: snapshot.id,
|
||||
worktree: worktreeConfig,
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: options.terminalManager ?? null,
|
||||
appendTimelineItem: (item) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
@@ -339,6 +347,8 @@ export async function createAgentManagementMcpServer(
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
serviceRouteStore: options.serviceRouteStore,
|
||||
daemonPort: options.getDaemonTcpPort?.() ?? null,
|
||||
logger: childLogger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ export type ToolCallDetail =
|
||||
index: number;
|
||||
command: string;
|
||||
cwd: string;
|
||||
log: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
exitCode: number | null;
|
||||
durationMs?: number;
|
||||
|
||||
@@ -28,11 +28,14 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
|
||||
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
||||
import type { ServiceRouteStore } from "../service-proxy.js";
|
||||
|
||||
export interface AgentMcpServerOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
terminalManager?: TerminalManager | null;
|
||||
serviceRouteStore?: ServiceRouteStore;
|
||||
getDaemonTcpPort?: () => number | null;
|
||||
paseoHome?: string;
|
||||
/**
|
||||
* ID of the agent that is connecting to this MCP server.
|
||||
@@ -428,6 +431,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
let resolvedCwd: string;
|
||||
let resolvedMode: string | undefined;
|
||||
let worktreeConfig: WorktreeConfig | undefined;
|
||||
let shouldBootstrapWorktree: boolean | undefined;
|
||||
|
||||
if (callerAgentId) {
|
||||
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
|
||||
@@ -464,15 +468,16 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
if (!baseBranch) {
|
||||
throw new Error("baseBranch is required when creating a worktree");
|
||||
}
|
||||
const worktree = await createAgentWorktree({
|
||||
const worktreeBootstrap = await createAgentWorktree({
|
||||
branchName: worktreeName,
|
||||
cwd: resolvedCwd,
|
||||
baseBranch,
|
||||
worktreeSlug: worktreeName,
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
resolvedCwd = worktree.worktreePath;
|
||||
worktreeConfig = worktree;
|
||||
resolvedCwd = worktreeBootstrap.worktree.worktreePath;
|
||||
worktreeConfig = worktreeBootstrap.worktree;
|
||||
shouldBootstrapWorktree = worktreeBootstrap.shouldBootstrap;
|
||||
}
|
||||
|
||||
resolvedMode = initialMode;
|
||||
@@ -497,6 +502,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
void runAsyncWorktreeBootstrap({
|
||||
agentId: snapshot.id,
|
||||
worktree: worktreeConfig,
|
||||
shouldBootstrap: shouldBootstrapWorktree,
|
||||
terminalManager: terminalManager ?? null,
|
||||
appendTimelineItem: (item) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
@@ -510,6 +516,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
serviceRouteStore: options.serviceRouteStore,
|
||||
daemonPort: options.getDaemonTcpPort?.() ?? null,
|
||||
logger: childLogger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
||||
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
|
||||
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
||||
import { initializeSpeechRuntime } from "./speech/speech-runtime.js";
|
||||
import { createSpeechService } from "./speech/speech-runtime.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
||||
@@ -113,6 +113,13 @@ import { resolveDaemonVersion } from "./daemon-version.js";
|
||||
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
||||
import {
|
||||
ServiceRouteStore,
|
||||
createServiceProxyMiddleware,
|
||||
createServiceProxyUpgradeHandler,
|
||||
} from "./service-proxy.js";
|
||||
import { ServiceHealthMonitor } from "./service-health-monitor.js";
|
||||
import { createServiceStatusEmitter } from "./service-status-projection.js";
|
||||
import {
|
||||
createVoiceMcpSocketBridgeManager,
|
||||
type VoiceMcpSocketBridgeManager,
|
||||
@@ -189,6 +196,7 @@ export interface PaseoDaemon {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
terminalManager: TerminalManager;
|
||||
serviceRouteStore: ServiceRouteStore;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
getListenTarget(): ListenTarget | null;
|
||||
@@ -218,6 +226,20 @@ export async function createPaseoDaemon(
|
||||
const app = express();
|
||||
let boundListenTarget: ListenTarget | null = null;
|
||||
|
||||
const serviceRouteStore = new ServiceRouteStore();
|
||||
let wsServer: VoiceAssistantWebSocketServer | null = null;
|
||||
const serviceHealthMonitor = new ServiceHealthMonitor({
|
||||
routeStore: serviceRouteStore,
|
||||
onChange: createServiceStatusEmitter({
|
||||
sessions: () =>
|
||||
wsServer?.listActiveSessions().map((session) => ({
|
||||
emit: (message) => session.emitServerMessage(message),
|
||||
})) ?? [],
|
||||
routeStore: serviceRouteStore,
|
||||
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
}),
|
||||
});
|
||||
|
||||
// Host allowlist / DNS rebinding protection (vite-like semantics).
|
||||
// For non-TCP (unix sockets), skip host validation.
|
||||
if (listenTarget.type === "tcp") {
|
||||
@@ -231,6 +253,14 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
}
|
||||
|
||||
// Service proxy — intercepts requests for registered *.localhost hostnames
|
||||
// and forwards them to the corresponding local service port. Placed after
|
||||
// the host allowlist (*.localhost is already allowed) but before CORS and
|
||||
// the rest of the routes so proxied requests skip unnecessary middleware.
|
||||
app.use(
|
||||
createServiceProxyMiddleware({ routeStore: serviceRouteStore, logger }),
|
||||
);
|
||||
|
||||
// CORS - allow same-origin + configured origins
|
||||
const allowedOrigins = new Set([
|
||||
...config.corsAllowedOrigins,
|
||||
@@ -352,6 +382,16 @@ export async function createPaseoDaemon(
|
||||
|
||||
const httpServer = createHTTPServer(app);
|
||||
|
||||
// Service proxy WebSocket upgrade handler — must be registered before the
|
||||
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
|
||||
// service-bound upgrades are forwarded first. The handler is a no-op for
|
||||
// requests that don't match a registered service route.
|
||||
const serviceProxyUpgradeHandler = createServiceProxyUpgradeHandler({
|
||||
routeStore: serviceRouteStore,
|
||||
logger,
|
||||
});
|
||||
httpServer.on("upgrade", serviceProxyUpgradeHandler);
|
||||
|
||||
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(config.paseoHome, "projects", "projects.json"),
|
||||
@@ -424,9 +464,7 @@ export async function createPaseoDaemon(
|
||||
"Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)",
|
||||
);
|
||||
logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime");
|
||||
let wsServer: VoiceAssistantWebSocketServer | null = null;
|
||||
let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | null = null;
|
||||
let unsubscribeSpeechReadiness: (() => void) | null = null;
|
||||
|
||||
// Create in-memory transport for Session's Agent MCP client (voice assistant tools)
|
||||
const createInMemoryAgentMcpTransport = async (): Promise<InMemoryTransport> => {
|
||||
@@ -434,6 +472,9 @@ export async function createPaseoDaemon(
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
serviceRouteStore,
|
||||
getDaemonTcpPort: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||
paseoHome: config.paseoHome,
|
||||
enableVoiceTools: false,
|
||||
resolveSpeakHandler: (callerAgentId) =>
|
||||
@@ -460,6 +501,9 @@ export async function createPaseoDaemon(
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
serviceRouteStore,
|
||||
getDaemonTcpPort: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||
paseoHome: config.paseoHome,
|
||||
callerAgentId,
|
||||
enableVoiceTools: false,
|
||||
@@ -580,6 +624,9 @@ export async function createPaseoDaemon(
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
serviceRouteStore,
|
||||
getDaemonTcpPort: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||
paseoHome: config.paseoHome,
|
||||
callerAgentId,
|
||||
voiceOnly: true,
|
||||
@@ -589,21 +636,12 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
},
|
||||
});
|
||||
const {
|
||||
resolveVoiceTurnDetection,
|
||||
resolveVoiceStt,
|
||||
resolveVoiceTts,
|
||||
resolveDictationStt,
|
||||
getSpeechReadiness,
|
||||
subscribeSpeechReadiness,
|
||||
cleanup: cleanupSpeechRuntime,
|
||||
localModelConfig,
|
||||
} = await initializeSpeechRuntime({
|
||||
const speechService = createSpeechService({
|
||||
logger,
|
||||
openaiConfig: config.openai,
|
||||
speechConfig: config.speech,
|
||||
});
|
||||
logger.info({ elapsed: elapsed() }, "Speech runtime initialized");
|
||||
logger.info({ elapsed: elapsed() }, "Speech service created");
|
||||
|
||||
wsServer = new VoiceAssistantWebSocketServer(
|
||||
httpServer,
|
||||
@@ -615,7 +653,7 @@ export async function createPaseoDaemon(
|
||||
config.paseoHome,
|
||||
createInMemoryAgentMcpTransport,
|
||||
{ allowedOrigins, allowedHosts: config.allowedHosts },
|
||||
{ turnDetection: resolveVoiceTurnDetection, stt: resolveVoiceStt, tts: resolveVoiceTts },
|
||||
speechService,
|
||||
terminalManager,
|
||||
{
|
||||
voiceAgentMcpStdio: {
|
||||
@@ -633,9 +671,6 @@ export async function createPaseoDaemon(
|
||||
},
|
||||
{
|
||||
finalTimeoutMs: config.dictationFinalTimeoutMs,
|
||||
stt: resolveDictationStt,
|
||||
localModels: localModelConfig ?? undefined,
|
||||
getSpeechReadiness,
|
||||
},
|
||||
config.agentProviderSettings,
|
||||
daemonVersion,
|
||||
@@ -652,10 +687,10 @@ export async function createPaseoDaemon(
|
||||
loopService,
|
||||
scheduleService,
|
||||
checkoutDiffManager,
|
||||
serviceRouteStore,
|
||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
(hostname) => serviceHealthMonitor.getStatusForHostname(hostname),
|
||||
);
|
||||
unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
|
||||
wsServer?.publishSpeechReadiness(snapshot);
|
||||
});
|
||||
|
||||
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
|
||||
|
||||
@@ -741,9 +776,15 @@ export async function createPaseoDaemon(
|
||||
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();
|
||||
serviceHealthMonitor.start();
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
serviceHealthMonitor.stop();
|
||||
await closeAllAgents(logger, agentManager);
|
||||
await agentManager.flush().catch(() => undefined);
|
||||
detachAgentStoragePersistence();
|
||||
@@ -752,9 +793,7 @@ export async function createPaseoDaemon(
|
||||
runtimeSettings: config.agentProviderSettings,
|
||||
});
|
||||
terminalManager.killAll();
|
||||
unsubscribeSpeechReadiness?.();
|
||||
unsubscribeSpeechReadiness = null;
|
||||
cleanupSpeechRuntime();
|
||||
speechService.stop();
|
||||
await scheduleService.stop().catch(() => undefined);
|
||||
await relayTransport?.stop().catch(() => undefined);
|
||||
if (wsServer) {
|
||||
@@ -777,6 +816,7 @@ export async function createPaseoDaemon(
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
serviceRouteStore,
|
||||
start,
|
||||
stop,
|
||||
getListenTarget: () => boundListenTarget,
|
||||
|
||||
@@ -730,7 +730,7 @@ describe("daemon E2E", () => {
|
||||
});
|
||||
|
||||
describe("archivePaseoWorktree", () => {
|
||||
test("archives worktree by running destroy commands and shutting down worktree terminals", async () => {
|
||||
test("archives worktree by running teardown commands and shutting down worktree terminals", async () => {
|
||||
const repoRoot = tmpCwd();
|
||||
|
||||
const { execSync } = await import("child_process");
|
||||
@@ -749,7 +749,7 @@ describe("daemon E2E", () => {
|
||||
});
|
||||
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
const destroyMarkerPath = path.join(repoRoot, "destroy-marker.txt");
|
||||
const teardownMarkerPath = path.join(repoRoot, "teardown-marker.txt");
|
||||
writeFileSync(
|
||||
path.join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({
|
||||
@@ -760,12 +760,12 @@ describe("daemon E2E", () => {
|
||||
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
|
||||
},
|
||||
],
|
||||
destroy: [`echo "$PASEO_WORKTREE_PATH" > "${destroyMarkerPath}"`],
|
||||
teardown: [`echo "$PASEO_WORKTREE_PATH" > "${teardownMarkerPath}"`],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + destroy'", {
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + teardown'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
@@ -813,8 +813,8 @@ describe("daemon E2E", () => {
|
||||
expect(archive.removedAgents).toContain(agent.id);
|
||||
|
||||
expect(existsSync(agent.cwd)).toBe(false);
|
||||
expect(existsSync(destroyMarkerPath)).toBe(true);
|
||||
expect(readFileSync(destroyMarkerPath, "utf8").trim()).toBe(agent.cwd);
|
||||
expect(existsSync(teardownMarkerPath)).toBe(true);
|
||||
expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd);
|
||||
|
||||
const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories();
|
||||
expect(afterArchiveDirectories).not.toContain(agent.cwd);
|
||||
|
||||
@@ -415,12 +415,17 @@ async function subscribeRawTerminal(
|
||||
|
||||
describe("daemon E2E terminal", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
let tempDirs: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
tempDirs = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
@@ -1055,4 +1060,161 @@ describe("daemon E2E terminal", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 40000);
|
||||
|
||||
describe("capture", () => {
|
||||
test("captures visible terminal output as plain text", async () => {
|
||||
const cwd = tmpCwd();
|
||||
tempDirs.push(cwd);
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
await ctx.client.subscribeTerminal(terminalId);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "echo hello world\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("hello world"), 15000);
|
||||
|
||||
const capture = await ctx.client.captureTerminal(terminalId);
|
||||
|
||||
expect(capture.lines.join("\n")).toContain("hello world");
|
||||
expect(capture.totalLines).toBeGreaterThan(0);
|
||||
}, 15000);
|
||||
|
||||
test("captures with start/end line range", async () => {
|
||||
const cwd = tmpCwd();
|
||||
tempDirs.push(cwd);
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
await ctx.client.subscribeTerminal(terminalId);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "echo line1\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line1"), 15000);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "echo line2\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line2"), 15000);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "echo line3\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line3"), 15000);
|
||||
|
||||
const fullCapture = await ctx.client.captureTerminal(terminalId);
|
||||
const rangedCapture = await ctx.client.captureTerminal(terminalId, {
|
||||
start: 0,
|
||||
end: 2,
|
||||
});
|
||||
|
||||
expect(rangedCapture.lines).toHaveLength(3);
|
||||
expect(rangedCapture.totalLines).toBe(fullCapture.totalLines);
|
||||
}, 15000);
|
||||
|
||||
test("supports negative line indices", async () => {
|
||||
const cwd = tmpCwd();
|
||||
tempDirs.push(cwd);
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
await ctx.client.subscribeTerminal(terminalId);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "echo alpha\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("alpha"), 15000);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "echo beta\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("beta"), 15000);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "echo gamma\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("gamma"), 15000);
|
||||
|
||||
const capture = await ctx.client.captureTerminal(terminalId, {
|
||||
start: -3,
|
||||
});
|
||||
|
||||
expect(capture.lines).toHaveLength(3);
|
||||
}, 15000);
|
||||
|
||||
test("strips ANSI by default", async () => {
|
||||
const cwd = tmpCwd();
|
||||
tempDirs.push(cwd);
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
await ctx.client.subscribeTerminal(terminalId);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "printf '\\033[31mred text\\033[0m\\n'\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("red text"), 15000);
|
||||
|
||||
const capture = await ctx.client.captureTerminal(terminalId);
|
||||
const capturedText = capture.lines.join("\n");
|
||||
|
||||
expect(capturedText).toContain("red text");
|
||||
expect(capturedText).not.toContain("\u001b[31m");
|
||||
}, 15000);
|
||||
|
||||
test("returns empty for non-existent terminal", async () => {
|
||||
const capture = await ctx.client.captureTerminal("terminal-does-not-exist");
|
||||
|
||||
expect(capture.lines).toEqual([]);
|
||||
expect(capture.totalLines).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list terminals across directories", () => {
|
||||
test("lists terminals from all directories when cwd is omitted", async () => {
|
||||
const cwd1 = tmpCwd();
|
||||
const cwd2 = tmpCwd();
|
||||
tempDirs.push(cwd1, cwd2);
|
||||
|
||||
const firstCreated = await ctx.client.createTerminal(cwd1, "first-terminal");
|
||||
const secondCreated = await ctx.client.createTerminal(cwd2, "second-terminal");
|
||||
|
||||
const list = await ctx.client.listTerminals();
|
||||
|
||||
expect(list).not.toHaveProperty("cwd");
|
||||
expect(list.terminals).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
id: firstCreated.terminal!.id,
|
||||
name: "first-terminal",
|
||||
},
|
||||
{
|
||||
id: secondCreated.terminal!.id,
|
||||
name: "second-terminal",
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("lists terminals for specific directory when cwd is provided", async () => {
|
||||
const cwd1 = tmpCwd();
|
||||
const cwd2 = tmpCwd();
|
||||
tempDirs.push(cwd1, cwd2);
|
||||
|
||||
const firstCreated = await ctx.client.createTerminal(cwd1, "cwd-one-terminal");
|
||||
await ctx.client.createTerminal(cwd2, "cwd-two-terminal");
|
||||
|
||||
const list = await ctx.client.listTerminals(cwd1);
|
||||
|
||||
expect(list.cwd).toBe(cwd1);
|
||||
expect(list.terminals).toEqual([
|
||||
{
|
||||
id: firstCreated.terminal!.id,
|
||||
name: "cwd-one-terminal",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
import net from "node:net";
|
||||
import { scheduler } from "node:timers/promises";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { findFreePort, ServiceRouteStore } from "./service-proxy.js";
|
||||
import {
|
||||
ServiceHealthMonitor,
|
||||
type ServiceStatusEntry,
|
||||
} from "./service-health-monitor.js";
|
||||
|
||||
type TcpServerHandle = {
|
||||
port: number;
|
||||
server: net.Server;
|
||||
};
|
||||
|
||||
async function startTcpServer(): Promise<TcpServerHandle> {
|
||||
const server = net.createServer((socket) => {
|
||||
socket.end();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to resolve TCP server address");
|
||||
}
|
||||
|
||||
return { port: address.port, server };
|
||||
}
|
||||
|
||||
async function closeServer(server: net.Server): Promise<void> {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function advancePoll(ms: number): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await scheduler.yield();
|
||||
}
|
||||
}
|
||||
|
||||
describe("ServiceHealthMonitor", () => {
|
||||
const servers = new Set<net.Server>();
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
for (const server of servers) {
|
||||
await closeServer(server);
|
||||
}
|
||||
servers.clear();
|
||||
});
|
||||
|
||||
it("marks a healthy port as running after successful TCP connect", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const healthy = await startTcpServer();
|
||||
servers.add(healthy.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks an unreachable port as stopped after consecutive failures", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const deadPort = await findFreePort();
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: deadPort,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
failuresBeforeStopped: 2,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await advancePoll(1_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: deadPort,
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not emit when status has not changed", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const healthy = await startTcpServer();
|
||||
servers.add(healthy.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(3_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("respects startup grace period — does not probe newly registered routes for 5 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const healthy = await startTcpServer();
|
||||
servers.add(healthy.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 5_000,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(4_000);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await advancePoll(1_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires 2 consecutive failures before marking stopped (debounce)", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const healthy = await startTcpServer();
|
||||
servers.add(healthy.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
failuresBeforeStopped: 2,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
await closeServer(healthy.server);
|
||||
servers.delete(healthy.server);
|
||||
|
||||
await advancePoll(1_000);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
await advancePoll(1_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
expect(onChange).toHaveBeenLastCalledWith("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops probing routes that are removed from the store", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const healthy = await startTcpServer();
|
||||
servers.add(healthy.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
failuresBeforeStopped: 2,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
routeStore.removeRoute("api.localhost");
|
||||
await closeServer(healthy.server);
|
||||
servers.delete(healthy.server);
|
||||
|
||||
await advancePoll(3_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onChange with workspaceId and full service list when status transitions", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const api = await startTcpServer();
|
||||
const web = await startTcpServer();
|
||||
servers.add(api.server);
|
||||
servers.add(web.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: api.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.localhost",
|
||||
port: web.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: api.port,
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: web.port,
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("getStatusForHostname returns current status after probe", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const healthy = await startTcpServer();
|
||||
servers.add(healthy.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
});
|
||||
|
||||
expect(monitor.getStatusForHostname("api.localhost")).toBeNull();
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(monitor.getStatusForHostname("api.localhost")).toBe("running");
|
||||
expect(monitor.getStatusForHostname("unknown.localhost")).toBeNull();
|
||||
});
|
||||
|
||||
it("coalesces multiple service changes in same workspace into one onChange call per poll cycle", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const api = await startTcpServer();
|
||||
const web = await startTcpServer();
|
||||
servers.add(api.server);
|
||||
servers.add(web.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: api.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.localhost",
|
||||
port: web.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
failuresBeforeStopped: 2,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
onChange.mockClear();
|
||||
await closeServer(api.server);
|
||||
await closeServer(web.server);
|
||||
servers.delete(api.server);
|
||||
servers.delete(web.server);
|
||||
|
||||
await advancePoll(1_000);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await advancePoll(1_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: api.port,
|
||||
status: "stopped",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: web.port,
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import net from "node:net";
|
||||
import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js";
|
||||
|
||||
export interface ServiceStatusEntry {
|
||||
serviceName: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
status: "running" | "stopped";
|
||||
}
|
||||
|
||||
type RouteHealthState = {
|
||||
status: ServiceStatusEntry["status"] | null;
|
||||
consecutiveFailures: number;
|
||||
registeredAt: number;
|
||||
};
|
||||
|
||||
export class ServiceHealthMonitor {
|
||||
private readonly routeStore: ServiceRouteStore;
|
||||
private readonly onChange: (
|
||||
workspaceId: string,
|
||||
services: ServiceStatusEntry[],
|
||||
) => void;
|
||||
private readonly pollIntervalMs: number;
|
||||
private readonly probeTimeoutMs: number;
|
||||
private readonly graceMs: number;
|
||||
private readonly failuresBeforeStopped: number;
|
||||
private readonly routeStates = new Map<string, RouteHealthState>();
|
||||
private readonly lastEmittedSnapshots = new Map<string, string>();
|
||||
|
||||
private intervalHandle: NodeJS.Timeout | null = null;
|
||||
private pollInFlight = false;
|
||||
|
||||
constructor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs = 3_000,
|
||||
probeTimeoutMs = 500,
|
||||
graceMs = 5_000,
|
||||
failuresBeforeStopped = 2,
|
||||
}: {
|
||||
routeStore: ServiceRouteStore;
|
||||
onChange: (workspaceId: string, services: ServiceStatusEntry[]) => void;
|
||||
pollIntervalMs?: number;
|
||||
probeTimeoutMs?: number;
|
||||
graceMs?: number;
|
||||
failuresBeforeStopped?: number;
|
||||
}) {
|
||||
this.routeStore = routeStore;
|
||||
this.onChange = onChange;
|
||||
this.pollIntervalMs = pollIntervalMs;
|
||||
this.probeTimeoutMs = probeTimeoutMs;
|
||||
this.graceMs = graceMs;
|
||||
this.failuresBeforeStopped = failuresBeforeStopped;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.intervalHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const route of this.routeStore.listRoutes()) {
|
||||
this.getOrCreateState(route.hostname, now);
|
||||
}
|
||||
|
||||
this.intervalHandle = setInterval(() => {
|
||||
void this.poll();
|
||||
}, this.pollIntervalMs);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.intervalHandle) {
|
||||
clearInterval(this.intervalHandle);
|
||||
this.intervalHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (this.pollInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pollInFlight = true;
|
||||
try {
|
||||
const routes = this.routeStore.listRoutes();
|
||||
const activeHostnames = new Set(routes.map((route) => route.hostname));
|
||||
const changedWorkspaceIds = new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
for (const route of routes) {
|
||||
const state = this.getOrCreateState(route.hostname, now);
|
||||
if (now - state.registeredAt < this.graceMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isHealthy = await this.probeRoute(route.port);
|
||||
const previousStatus = state.status;
|
||||
|
||||
if (isHealthy) {
|
||||
state.consecutiveFailures = 0;
|
||||
state.status = "running";
|
||||
} else {
|
||||
state.consecutiveFailures += 1;
|
||||
if (state.consecutiveFailures >= this.failuresBeforeStopped) {
|
||||
state.status = "stopped";
|
||||
}
|
||||
}
|
||||
|
||||
if (state.status !== null && state.status !== previousStatus) {
|
||||
changedWorkspaceIds.add(route.workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
this.pruneRemovedRoutes(activeHostnames);
|
||||
|
||||
for (const workspaceId of changedWorkspaceIds) {
|
||||
const services = this.buildWorkspaceServiceList(workspaceId);
|
||||
const snapshot = JSON.stringify(services);
|
||||
if (snapshot === this.lastEmittedSnapshots.get(workspaceId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.lastEmittedSnapshots.set(workspaceId, snapshot);
|
||||
this.onChange(workspaceId, services);
|
||||
}
|
||||
} finally {
|
||||
this.pollInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateState(hostname: string, registeredAt: number): RouteHealthState {
|
||||
const existing = this.routeStates.get(hostname);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const state: RouteHealthState = {
|
||||
status: null,
|
||||
consecutiveFailures: 0,
|
||||
registeredAt,
|
||||
};
|
||||
this.routeStates.set(hostname, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
private pruneRemovedRoutes(activeHostnames: Set<string>): void {
|
||||
for (const hostname of this.routeStates.keys()) {
|
||||
if (activeHostnames.has(hostname)) {
|
||||
continue;
|
||||
}
|
||||
this.routeStates.delete(hostname);
|
||||
}
|
||||
}
|
||||
|
||||
private buildWorkspaceServiceList(workspaceId: string): ServiceStatusEntry[] {
|
||||
return this.routeStore
|
||||
.listRoutesForWorkspace(workspaceId)
|
||||
.flatMap((route) => {
|
||||
const state = this.routeStates.get(route.hostname);
|
||||
if (!state?.status) {
|
||||
return [];
|
||||
}
|
||||
return [this.toServiceStatusEntry(route, state.status)];
|
||||
});
|
||||
}
|
||||
|
||||
getStatusForHostname(hostname: string): ServiceStatusEntry["status"] | null {
|
||||
return this.routeStates.get(hostname)?.status ?? null;
|
||||
}
|
||||
|
||||
private toServiceStatusEntry(
|
||||
route: ServiceRouteEntry,
|
||||
status: ServiceStatusEntry["status"],
|
||||
): ServiceStatusEntry {
|
||||
return {
|
||||
serviceName: route.serviceName,
|
||||
hostname: route.hostname,
|
||||
port: route.port,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
private probeRoute(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect({ host: "127.0.0.1", port });
|
||||
let settled = false;
|
||||
|
||||
const finish = (healthy: boolean) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(healthy);
|
||||
};
|
||||
|
||||
socket.setTimeout(this.probeTimeoutMs);
|
||||
socket.once("connect", () => finish(true));
|
||||
socket.once("timeout", () => finish(false));
|
||||
socket.once("error", () => finish(false));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import { describe, 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 {
|
||||
ServiceRouteStore,
|
||||
createServiceProxyMiddleware,
|
||||
createServiceProxyUpgradeHandler,
|
||||
findFreePort,
|
||||
} from "./service-proxy.js";
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function closeServer(server: http.Server): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ServiceRouteStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ServiceRouteStore", () => {
|
||||
it("registerRoute and findRoute with exact match", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "editor.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "editor",
|
||||
});
|
||||
|
||||
const route = store.findRoute("editor.localhost");
|
||||
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 });
|
||||
});
|
||||
|
||||
it("findRoute strips port from host header", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "editor.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "editor",
|
||||
});
|
||||
|
||||
const route = store.findRoute("editor.localhost:6767");
|
||||
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 });
|
||||
});
|
||||
|
||||
it("findRoute subdomain match", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "editor.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "editor",
|
||||
});
|
||||
|
||||
const route = store.findRoute("fix-auth.editor.localhost");
|
||||
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 });
|
||||
});
|
||||
|
||||
it("listRoutes returns enriched entries", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
serviceName: "docs",
|
||||
});
|
||||
|
||||
const routes = store.listRoutes();
|
||||
expect(routes).toHaveLength(2);
|
||||
expect(routes).toContainEqual({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
expect(routes).toContainEqual({
|
||||
hostname: "b.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
serviceName: "docs",
|
||||
});
|
||||
});
|
||||
|
||||
it("listRoutesForWorkspace returns only routes for that workspace", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
serviceName: "docs",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "c.localhost",
|
||||
port: 5000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([
|
||||
{
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "web",
|
||||
},
|
||||
{
|
||||
hostname: "c.localhost",
|
||||
port: 5000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "api",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("removeRoute works", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "editor.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "editor",
|
||||
});
|
||||
store.removeRoute("editor.localhost");
|
||||
|
||||
expect(store.findRoute("editor.localhost")).toBeNull();
|
||||
});
|
||||
|
||||
it("removeRoute cleans up workspace index", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "editor.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "editor",
|
||||
});
|
||||
|
||||
store.removeRoute("editor.localhost");
|
||||
|
||||
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("removeRoutesForPort works", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "c.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
serviceName: "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 ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
store.removeRoutesForPort(3000);
|
||||
|
||||
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("findRoute returns null for unknown hosts", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "editor.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "editor",
|
||||
});
|
||||
|
||||
expect(store.findRoute("unknown.example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP proxy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("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: ServiceRouteStore,
|
||||
opts?: { fallback?: boolean },
|
||||
): Promise<{ port: number; server: http.Server }> {
|
||||
const port = await findFreePort();
|
||||
const app = express();
|
||||
app.use(createServiceProxyMiddleware({ 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 ServiceRouteStore();
|
||||
routeStore.addRoute("test-service.localhost", upstream.port);
|
||||
|
||||
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 ServiceRouteStore();
|
||||
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 ServiceRouteStore();
|
||||
routeStore.addRoute("dead-service.localhost", deadPort);
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("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 ServiceRouteStore();
|
||||
routeStore.addRoute("ws-service.localhost", upstreamPort);
|
||||
|
||||
const proxyPort = await findFreePort();
|
||||
const proxyServer = http.createServer((_req, res) => {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
const upgradeHandler = createServiceProxyUpgradeHandler({
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("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()));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
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",
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ServiceRouteStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ServiceRoute {
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface ServiceRouteEntry extends ServiceRoute {
|
||||
workspaceId: string;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
export class ServiceRouteStore {
|
||||
private routes = new Map<string, ServiceRouteEntry>();
|
||||
private workspaceHostnames = new Map<string, Set<string>>();
|
||||
|
||||
addRoute(hostname: string, port: number): void {
|
||||
this.registerRoute({
|
||||
hostname,
|
||||
port,
|
||||
workspaceId: "",
|
||||
serviceName: hostname,
|
||||
});
|
||||
}
|
||||
|
||||
registerRoute(entry: ServiceRouteEntry): 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);
|
||||
}
|
||||
|
||||
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): ServiceRoute | 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;
|
||||
}
|
||||
|
||||
listRoutes(): ServiceRouteEntry[] {
|
||||
return Array.from(this.routes.values()).map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
listRoutesForWorkspace(workspaceId: string): ServiceRouteEntry[] {
|
||||
const hostnames = this.workspaceHostnames.get(workspaceId);
|
||||
if (!hostnames) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const routes: ServiceRouteEntry[] = [];
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createServiceProxyMiddleware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createServiceProxyMiddleware({
|
||||
routeStore,
|
||||
logger,
|
||||
}: {
|
||||
routeStore: ServiceRouteStore;
|
||||
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 },
|
||||
"Service proxy: upstream unreachable",
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { "content-type": "text/plain" });
|
||||
res.end("502 Bad Gateway");
|
||||
}
|
||||
});
|
||||
|
||||
req.pipe(proxyReq, { end: true });
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createServiceProxyUpgradeHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createServiceProxyUpgradeHandler({
|
||||
routeStore,
|
||||
logger,
|
||||
}: {
|
||||
routeStore: ServiceRouteStore;
|
||||
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 },
|
||||
"Service 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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ServiceRouteStore } from "./service-proxy.js";
|
||||
import {
|
||||
buildWorkspaceServicePayloads,
|
||||
createServiceStatusEmitter,
|
||||
} from "./service-status-projection.js";
|
||||
|
||||
describe("service-status-projection", () => {
|
||||
it("buildWorkspaceServicePayloads returns service payloads from workspace routes", () => {
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "docs.localhost",
|
||||
port: 3002,
|
||||
workspaceId: "workspace-b",
|
||||
serviceName: "docs",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.localhost",
|
||||
port: 3003,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
|
||||
expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767)).toEqual([
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
status: "stopped",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3003,
|
||||
url: "http://web.localhost:6767",
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("computes URLs with and without a daemon port", () => {
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767)).toEqual([
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", null)).toEqual([
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: null,
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("createServiceStatusEmitter emits updates to all active sessions", () => {
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const sessionA = { emit: vi.fn() };
|
||||
const sessionB = { emit: vi.fn() };
|
||||
|
||||
const emitUpdate = createServiceStatusEmitter({
|
||||
sessions: () => [sessionA, sessionB],
|
||||
routeStore,
|
||||
daemonPort: 6767,
|
||||
});
|
||||
|
||||
emitUpdate("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(sessionA.emit).toHaveBeenCalledWith({
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: "workspace-a",
|
||||
services: [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(sessionB.emit).toHaveBeenCalledWith({
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: "workspace-a",
|
||||
services: [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses resolveStatus to set initial service status when provided", () => {
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.localhost",
|
||||
port: 3003,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
|
||||
const statuses = new Map<string, "running" | "stopped">([
|
||||
["api.localhost", "running"],
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767, (hostname) =>
|
||||
statuses.get(hostname) ?? null,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3003,
|
||||
url: "http://web.localhost:6767",
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits workspace-specific batches", () => {
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.localhost",
|
||||
port: 3002,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "docs.localhost",
|
||||
port: 3003,
|
||||
workspaceId: "workspace-b",
|
||||
serviceName: "docs",
|
||||
});
|
||||
|
||||
const session = { emit: vi.fn() };
|
||||
const emitUpdate = createServiceStatusEmitter({
|
||||
sessions: () => [session],
|
||||
routeStore,
|
||||
daemonPort: null,
|
||||
});
|
||||
|
||||
emitUpdate("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3002,
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
|
||||
emitUpdate("workspace-b", [
|
||||
{
|
||||
serviceName: "docs",
|
||||
hostname: "docs.localhost",
|
||||
port: 3003,
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(session.emit).toHaveBeenNthCalledWith(1, {
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: "workspace-a",
|
||||
services: [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: null,
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3002,
|
||||
url: null,
|
||||
status: "stopped",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(session.emit).toHaveBeenNthCalledWith(2, {
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: "workspace-b",
|
||||
services: [
|
||||
{
|
||||
serviceName: "docs",
|
||||
hostname: "docs.localhost",
|
||||
port: 3003,
|
||||
url: null,
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type {
|
||||
ServiceStatusUpdateMessage,
|
||||
SessionOutboundMessage,
|
||||
WorkspaceServicePayload,
|
||||
} from "../shared/messages.js";
|
||||
import type { ServiceStatusEntry } from "./service-health-monitor.js";
|
||||
import type { ServiceRouteStore } from "./service-proxy.js";
|
||||
|
||||
type SessionEmitter = {
|
||||
emit(message: SessionOutboundMessage): void;
|
||||
};
|
||||
|
||||
function resolveDaemonPort(daemonPort: number | null | (() => number | null)): number | null {
|
||||
if (typeof daemonPort === "function") {
|
||||
return daemonPort();
|
||||
}
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function toServiceUrl(hostname: string, daemonPort: number | null): string | null {
|
||||
if (daemonPort === null) {
|
||||
return null;
|
||||
}
|
||||
return `http://${hostname}:${daemonPort}`;
|
||||
}
|
||||
|
||||
export function buildWorkspaceServicePayloads(
|
||||
routeStore: ServiceRouteStore,
|
||||
workspaceId: string,
|
||||
daemonPort: number | null,
|
||||
resolveStatus?: (hostname: string) => "running" | "stopped" | null,
|
||||
): WorkspaceServicePayload[] {
|
||||
return routeStore.listRoutesForWorkspace(workspaceId).map((route) => ({
|
||||
serviceName: route.serviceName,
|
||||
hostname: route.hostname,
|
||||
port: route.port,
|
||||
url: toServiceUrl(route.hostname, daemonPort),
|
||||
status: resolveStatus?.(route.hostname) ?? "stopped",
|
||||
}));
|
||||
}
|
||||
|
||||
function buildServiceStatusUpdateMessage(params: {
|
||||
workspaceId: string;
|
||||
services: WorkspaceServicePayload[];
|
||||
}): ServiceStatusUpdateMessage {
|
||||
return {
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: params.workspaceId,
|
||||
services: params.services,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createServiceStatusEmitter({
|
||||
sessions,
|
||||
routeStore,
|
||||
daemonPort,
|
||||
}: {
|
||||
sessions: () => SessionEmitter[];
|
||||
routeStore: ServiceRouteStore;
|
||||
daemonPort: number | null | (() => number | null);
|
||||
}): (workspaceId: string, services: ServiceStatusEntry[]) => void {
|
||||
return (workspaceId, services) => {
|
||||
const resolvedDaemonPort = resolveDaemonPort(daemonPort);
|
||||
const serviceStatusByHostname = new Map(
|
||||
services.map((service) => [service.hostname, service.status] as const),
|
||||
);
|
||||
|
||||
const projected = buildWorkspaceServicePayloads(routeStore, workspaceId, resolvedDaemonPort).map(
|
||||
(service) => ({
|
||||
...service,
|
||||
status: serviceStatusByHostname.get(service.hostname) ?? service.status,
|
||||
}),
|
||||
);
|
||||
|
||||
const message = buildServiceStatusUpdateMessage({
|
||||
workspaceId,
|
||||
services: projected,
|
||||
});
|
||||
|
||||
for (const session of sessions()) {
|
||||
session.emit(message);
|
||||
}
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import type { PaseoSpeechConfig } from "../bootstrap.js";
|
||||
import type { InitializedLocalSpeech } from "./providers/local/runtime.js";
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech-provider.js";
|
||||
import type { TurnDetectionProvider } from "./turn-detection-provider.js";
|
||||
import { initializeSpeechRuntime } from "./speech-runtime.js";
|
||||
import { createSpeechService } from "./speech-runtime.js";
|
||||
|
||||
const { initializeLocalSpeechServicesMock } = vi.hoisted(() => ({
|
||||
initializeLocalSpeechServicesMock: vi.fn<(args: unknown) => Promise<InitializedLocalSpeech>>(),
|
||||
@@ -70,7 +70,7 @@ function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpe
|
||||
return { providers };
|
||||
}
|
||||
|
||||
describe("initializeSpeechRuntime readiness", () => {
|
||||
describe("createSpeechService readiness", () => {
|
||||
beforeEach(() => {
|
||||
initializeLocalSpeechServicesMock.mockReset();
|
||||
});
|
||||
@@ -92,7 +92,7 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
cleanup: () => {},
|
||||
});
|
||||
|
||||
const runtime = await initializeSpeechRuntime({
|
||||
const runtime = createSpeechService({
|
||||
logger: pino({ level: "silent" }),
|
||||
speechConfig: createSpeechConfig({
|
||||
dictationStt: { provider: "local", enabled: true, explicit: true },
|
||||
@@ -101,14 +101,16 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
voiceTts: { provider: "local", enabled: false, explicit: true },
|
||||
}),
|
||||
});
|
||||
runtime.start();
|
||||
await runtime.ready;
|
||||
|
||||
const readiness = runtime.getSpeechReadiness();
|
||||
const readiness = runtime.getReadiness();
|
||||
expect(readiness.dictation.available).toBe(true);
|
||||
expect(readiness.realtimeVoice.reasonCode).toBe("disabled");
|
||||
expect(readiness.voiceFeature.available).toBe(true);
|
||||
expect(readiness.voiceFeature.reasonCode).toBe("ready");
|
||||
|
||||
runtime.cleanup();
|
||||
runtime.stop();
|
||||
});
|
||||
|
||||
it("keeps voice feature available when only realtime voice is enabled and ready", async () => {
|
||||
@@ -130,7 +132,7 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
cleanup: () => {},
|
||||
});
|
||||
|
||||
const runtime = await initializeSpeechRuntime({
|
||||
const runtime = createSpeechService({
|
||||
logger: pino({ level: "silent" }),
|
||||
speechConfig: createSpeechConfig({
|
||||
dictationStt: { provider: "local", enabled: false, explicit: true },
|
||||
@@ -139,13 +141,15 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
voiceTts: { provider: "local", enabled: true, explicit: true },
|
||||
}),
|
||||
});
|
||||
runtime.start();
|
||||
await runtime.ready;
|
||||
|
||||
const readiness = runtime.getSpeechReadiness();
|
||||
const readiness = runtime.getReadiness();
|
||||
expect(readiness.realtimeVoice.available).toBe(true);
|
||||
expect(readiness.dictation.reasonCode).toBe("disabled");
|
||||
expect(readiness.voiceFeature.available).toBe(true);
|
||||
expect(readiness.voiceFeature.reasonCode).toBe("ready");
|
||||
|
||||
runtime.cleanup();
|
||||
runtime.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -336,25 +336,23 @@ function resolveEffectiveProviderIds(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export type InitializedSpeechRuntime = {
|
||||
resolveVoiceTurnDetection: () => TurnDetectionProvider | null;
|
||||
resolveVoiceStt: () => SpeechToTextProvider | null;
|
||||
resolveVoiceTts: () => TextToSpeechProvider | null;
|
||||
export type SpeechService = {
|
||||
resolveStt: () => SpeechToTextProvider | null;
|
||||
resolveTts: () => TextToSpeechProvider | null;
|
||||
resolveTurnDetection: () => TurnDetectionProvider | null;
|
||||
resolveDictationStt: () => SpeechToTextProvider | null;
|
||||
getSpeechReadiness: () => SpeechReadinessSnapshot;
|
||||
subscribeSpeechReadiness: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void;
|
||||
cleanup: () => void;
|
||||
localModelConfig: {
|
||||
modelsDir: string;
|
||||
defaultModelIds: LocalSpeechModelId[];
|
||||
} | null;
|
||||
getReadiness: () => SpeechReadinessSnapshot;
|
||||
onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
ready: Promise<void>;
|
||||
};
|
||||
|
||||
export async function initializeSpeechRuntime(params: {
|
||||
export function createSpeechService(params: {
|
||||
logger: Logger;
|
||||
openaiConfig?: PaseoOpenAIConfig;
|
||||
speechConfig?: PaseoSpeechConfig;
|
||||
}): Promise<InitializedSpeechRuntime> {
|
||||
}): SpeechService {
|
||||
const logger = params.logger.child({ module: "speech-runtime" });
|
||||
const speechConfig = params.speechConfig ?? null;
|
||||
const openaiConfig = params.openaiConfig;
|
||||
@@ -397,6 +395,14 @@ export async function initializeSpeechRuntime(params: {
|
||||
const readinessListeners = new Set<(snapshot: SpeechReadinessSnapshot) => void>();
|
||||
let lastReadinessFingerprint: string | null = null;
|
||||
let lastPublishedReadinessSnapshot: SpeechReadinessSnapshot | null = null;
|
||||
let started = false;
|
||||
let readySettled = false;
|
||||
let resolveReady!: () => void;
|
||||
let rejectReady!: (error: unknown) => void;
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
|
||||
const computeReadinessSnapshot = (): SpeechReadinessSnapshot => {
|
||||
const realtimeVoice = buildRealtimeVoiceReadiness({
|
||||
@@ -658,16 +664,36 @@ export async function initializeSpeechRuntime(params: {
|
||||
}
|
||||
};
|
||||
|
||||
await runReconcile();
|
||||
const snapshot = computeReadinessSnapshot();
|
||||
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) {
|
||||
if (missingLocalModelIds.length > 0) {
|
||||
startBackgroundDownload();
|
||||
const start = (): void => {
|
||||
if (started || stopped) {
|
||||
return;
|
||||
}
|
||||
scheduleMonitor();
|
||||
}
|
||||
started = true;
|
||||
void (async () => {
|
||||
try {
|
||||
await runReconcile();
|
||||
const snapshot = computeReadinessSnapshot();
|
||||
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) {
|
||||
if (missingLocalModelIds.length > 0) {
|
||||
startBackgroundDownload();
|
||||
}
|
||||
scheduleMonitor();
|
||||
}
|
||||
if (!readySettled) {
|
||||
readySettled = true;
|
||||
resolveReady();
|
||||
}
|
||||
} catch (error) {
|
||||
if (!readySettled) {
|
||||
readySettled = true;
|
||||
rejectReady(error);
|
||||
}
|
||||
logger.error({ err: error }, "Speech runtime failed during initial reconcile");
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const cleanup = (): void => {
|
||||
const stop = (): void => {
|
||||
stopped = true;
|
||||
if (monitorTimeout) {
|
||||
clearTimeout(monitorTimeout);
|
||||
@@ -677,13 +703,14 @@ export async function initializeSpeechRuntime(params: {
|
||||
};
|
||||
|
||||
return {
|
||||
resolveVoiceTurnDetection: () => turnDetectionService,
|
||||
resolveVoiceStt: () => sttService,
|
||||
resolveVoiceTts: () => ttsService,
|
||||
resolveTurnDetection: () => turnDetectionService,
|
||||
resolveStt: () => sttService,
|
||||
resolveTts: () => ttsService,
|
||||
resolveDictationStt: () => dictationSttService,
|
||||
getSpeechReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(),
|
||||
subscribeSpeechReadiness,
|
||||
cleanup,
|
||||
localModelConfig,
|
||||
getReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(),
|
||||
onReadinessChange: subscribeSpeechReadiness,
|
||||
start,
|
||||
stop,
|
||||
ready,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,11 +33,8 @@ import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { PushService } from "./push/push-service.js";
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
|
||||
import type { Resolvable } from "./speech/provider-resolver.js";
|
||||
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
||||
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
|
||||
import type { ServiceRouteStore } from "./service-proxy.js";
|
||||
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
|
||||
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
|
||||
import {
|
||||
computeShouldNotifyClient,
|
||||
@@ -243,18 +240,13 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
private readonly pushService: PushService;
|
||||
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
|
||||
private readonly stt: Resolvable<SpeechToTextProvider | null>;
|
||||
private readonly tts: Resolvable<TextToSpeechProvider | null>;
|
||||
private readonly turnDetection: Resolvable<TurnDetectionProvider | null>;
|
||||
private readonly speech: SpeechService | null;
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private readonly serviceRouteStore: ServiceRouteStore | null;
|
||||
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||||
private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null;
|
||||
private readonly dictation: {
|
||||
finalTimeoutMs?: number;
|
||||
stt?: Resolvable<SpeechToTextProvider | null>;
|
||||
localModels?: {
|
||||
modelsDir: string;
|
||||
defaultModelIds: LocalSpeechModelId[];
|
||||
};
|
||||
getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||||
} | null;
|
||||
private readonly voice: {
|
||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||
@@ -288,6 +280,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly inboundSessionRequestCounts = new Map<string, number>();
|
||||
private readonly requestLatencies = new Map<string, number[]>();
|
||||
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private unsubscribeSpeechReadiness: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
server: HTTPServer,
|
||||
@@ -299,11 +292,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
paseoHome: string,
|
||||
createAgentMcpTransport: AgentMcpTransportFactory,
|
||||
wsConfig: WebSocketServerConfig,
|
||||
speech?: {
|
||||
turnDetection: Resolvable<TurnDetectionProvider | null>;
|
||||
stt: Resolvable<SpeechToTextProvider | null>;
|
||||
tts: Resolvable<TextToSpeechProvider | null>;
|
||||
},
|
||||
speech?: SpeechService | null,
|
||||
terminalManager?: TerminalManager | null,
|
||||
voice?: {
|
||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||
@@ -312,12 +301,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
},
|
||||
dictation?: {
|
||||
finalTimeoutMs?: number;
|
||||
stt?: Resolvable<SpeechToTextProvider | null>;
|
||||
localModels?: {
|
||||
modelsDir: string;
|
||||
defaultModelIds: LocalSpeechModelId[];
|
||||
};
|
||||
getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||||
},
|
||||
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap,
|
||||
daemonVersion?: string,
|
||||
@@ -328,6 +311,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
loopService?: LoopService,
|
||||
scheduleService?: ScheduleService,
|
||||
checkoutDiffManager?: CheckoutDiffManager,
|
||||
serviceRouteStore?: ServiceRouteStore | null,
|
||||
getDaemonTcpPort?: () => number | null,
|
||||
resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null,
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.serverId = serverId;
|
||||
@@ -358,17 +344,21 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.paseoHome = paseoHome;
|
||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||
this.turnDetection = speech?.turnDetection ?? null;
|
||||
this.stt = speech?.stt ?? null;
|
||||
this.tts = speech?.tts ?? null;
|
||||
this.speech = speech ?? null;
|
||||
this.terminalManager = terminalManager ?? null;
|
||||
this.voice = voice ?? null;
|
||||
this.dictation = dictation ?? null;
|
||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
||||
this.serviceRouteStore = serviceRouteStore ?? null;
|
||||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||
this.resolveServiceStatus = resolveServiceStatus ?? null;
|
||||
this.serverCapabilities = buildServerCapabilities({
|
||||
readiness: this.dictation?.getSpeechReadiness?.() ?? null,
|
||||
readiness: this.speech?.getReadiness() ?? null,
|
||||
});
|
||||
this.unsubscribeSpeechReadiness = this.speech?.onReadinessChange((snapshot) => {
|
||||
this.publishSpeechReadiness(snapshot);
|
||||
}) ?? null;
|
||||
|
||||
const pushLogger = this.logger.child({ module: "push" });
|
||||
this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json"));
|
||||
@@ -433,6 +423,16 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
}
|
||||
|
||||
public listActiveSessions(): Session[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[...this.sessions.values(), ...this.externalSessionsByKey.values()].map(
|
||||
(connection) => connection.session,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void {
|
||||
this.updateServerCapabilities(buildServerCapabilities({ readiness }));
|
||||
}
|
||||
@@ -457,6 +457,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
this.unsubscribeSpeechReadiness?.();
|
||||
this.unsubscribeSpeechReadiness = null;
|
||||
if (this.runtimeMetricsInterval) {
|
||||
clearInterval(this.runtimeMetricsInterval);
|
||||
this.runtimeMetricsInterval = null;
|
||||
@@ -658,12 +660,15 @@ export class VoiceAssistantWebSocketServer {
|
||||
scheduleService: this.scheduleService,
|
||||
checkoutDiffManager: this.checkoutDiffManager,
|
||||
createAgentMcpTransport: this.createAgentMcpTransport,
|
||||
stt: this.stt,
|
||||
tts: this.tts,
|
||||
stt: () => this.speech?.resolveStt() ?? null,
|
||||
tts: () => this.speech?.resolveTts() ?? null,
|
||||
terminalManager: this.terminalManager,
|
||||
serviceRouteStore: this.serviceRouteStore ?? undefined,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
|
||||
resolveServiceStatus: this.resolveServiceStatus ?? undefined,
|
||||
voice: {
|
||||
...(this.voice ?? {}),
|
||||
turnDetection: this.turnDetection,
|
||||
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
|
||||
},
|
||||
voiceBridge: {
|
||||
registerVoiceSpeakHandler: (agentId, handler) => {
|
||||
@@ -681,7 +686,14 @@ export class VoiceAssistantWebSocketServer {
|
||||
ensureVoiceMcpSocketForAgent: this.voice?.ensureVoiceMcpSocketForAgent,
|
||||
removeVoiceMcpSocketForAgent: this.voice?.removeVoiceMcpSocketForAgent,
|
||||
},
|
||||
dictation: this.dictation ?? undefined,
|
||||
dictation:
|
||||
this.dictation || this.speech
|
||||
? {
|
||||
finalTimeoutMs: this.dictation?.finalTimeoutMs,
|
||||
stt: () => this.speech?.resolveDictationStt() ?? null,
|
||||
getSpeechReadiness: () => this.speech!.getReadiness(),
|
||||
}
|
||||
: undefined,
|
||||
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,12 @@ import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
|
||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
||||
import {
|
||||
createAgentWorktree,
|
||||
runAsyncWorktreeBootstrap,
|
||||
spawnWorktreeServices,
|
||||
} from "./worktree-bootstrap.js";
|
||||
import { ServiceRouteStore } from "./service-proxy.js";
|
||||
|
||||
describe("runAsyncWorktreeBootstrap", () => {
|
||||
let tempDir: string;
|
||||
@@ -56,7 +61,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
const worktreeBootstrap = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-streaming-setup",
|
||||
baseBranch: "main",
|
||||
@@ -69,7 +74,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-test",
|
||||
worktree,
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
@@ -113,12 +119,15 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({
|
||||
index: 1,
|
||||
command: 'echo "line-one"; echo "line-two" 1>&2',
|
||||
log: expect.stringContaining("line-one"),
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(persistedSetupItems[0].detail.commands[0]?.log).toContain("line-two");
|
||||
expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({
|
||||
index: 2,
|
||||
command: 'echo "line-three"',
|
||||
log: "line-three\n",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
});
|
||||
@@ -160,7 +169,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
const worktreeBootstrap = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-live-failure",
|
||||
baseBranch: "main",
|
||||
@@ -172,7 +181,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
await expect(
|
||||
runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-live-failure",
|
||||
worktree,
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
@@ -210,7 +220,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
const worktreeBootstrap = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-large-output",
|
||||
baseBranch: "main",
|
||||
@@ -221,7 +231,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-large-output",
|
||||
worktree,
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
@@ -244,6 +255,64 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(persistedSetupItem.detail.log).toContain("prefix-");
|
||||
expect(persistedSetupItem.detail.log).toContain("-suffix");
|
||||
expect(persistedSetupItem.detail.log).toContain("...<output truncated in the middle>...");
|
||||
expect(persistedSetupItem.detail.commands[0]?.log).toContain("prefix-");
|
||||
expect(persistedSetupItem.detail.commands[0]?.log).toContain("-suffix");
|
||||
expect(persistedSetupItem.detail.commands[0]?.log).toContain(
|
||||
"...<output truncated in the middle>...",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps only the final carriage-return-updated content in command logs", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [
|
||||
`node -e "process.stdout.write('fetch 1/3\\\\rfetch 2/3\\\\rfetch 3/3\\\\nready\\\\n')"`,
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add carriage return setup'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktreeBootstrap = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-carriage-return",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-carriage-return",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-carriage-return",
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
return true;
|
||||
},
|
||||
emitLiveTimelineItem: async () => true,
|
||||
});
|
||||
|
||||
const persistedSetupItem = persisted.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
item.type === "tool_call" && item.name === "paseo_worktree_setup",
|
||||
);
|
||||
expect(persistedSetupItem?.detail.type).toBe("worktree_setup");
|
||||
if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") {
|
||||
throw new Error("Expected worktree_setup tool detail");
|
||||
}
|
||||
|
||||
expect(persistedSetupItem.detail.log).toContain("\nfetch 3/3\nready\n");
|
||||
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 1/3\n");
|
||||
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 2/3\n");
|
||||
expect(persistedSetupItem.detail.commands[0]?.log).toBe("fetch 3/3\nready\n");
|
||||
});
|
||||
|
||||
it("waits for terminal output before sending bootstrap commands", async () => {
|
||||
@@ -266,7 +335,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
const worktreeBootstrap = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-terminal-readiness",
|
||||
baseBranch: "main",
|
||||
@@ -280,7 +349,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-terminal-readiness",
|
||||
worktree,
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
@@ -357,7 +427,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
const worktreeBootstrap = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-shared-runtime-port",
|
||||
baseBranch: "main",
|
||||
@@ -370,7 +440,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-shared-runtime-port",
|
||||
worktree,
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
@@ -416,13 +487,13 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
emitLiveTimelineItem: async () => true,
|
||||
});
|
||||
|
||||
const setupPortPath = join(worktree.worktreePath, "setup-port.txt");
|
||||
const setupPortPath = join(worktreeBootstrap.worktree.worktreePath, "setup-port.txt");
|
||||
await waitForPathExists(setupPortPath);
|
||||
|
||||
const setupPort = readFileSync(setupPortPath, "utf8").trim();
|
||||
expect(setupPort.length).toBeGreaterThan(0);
|
||||
expect(registeredEnvs).toHaveLength(1);
|
||||
expect(registeredEnvs[0]?.cwd).toBe(worktree.worktreePath);
|
||||
expect(registeredEnvs[0]?.cwd).toBe(worktreeBootstrap.worktree.worktreePath);
|
||||
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
|
||||
expect(createTerminalEnvs.length).toBeGreaterThan(0);
|
||||
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
|
||||
@@ -435,4 +506,85 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
);
|
||||
expect(terminalToolCall?.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("spawns services without PASEO_SERVICE_URL when the daemon has no TCP port", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
services: {
|
||||
web: {
|
||||
command: "npm run dev",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add service config'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> = [];
|
||||
|
||||
const results = await spawnWorktreeServices({
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
branchName: "feature-socket-service",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
terminalManager: {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
},
|
||||
async createTerminal(options) {
|
||||
createTerminalCalls.push(options);
|
||||
return {
|
||||
id: "term-service",
|
||||
name: options.name ?? "Terminal",
|
||||
cwd: options.cwd,
|
||||
send: () => {},
|
||||
subscribe: () => () => {},
|
||||
onExit: () => () => {},
|
||||
getState: () => ({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "$" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
}),
|
||||
kill: () => {},
|
||||
};
|
||||
},
|
||||
registerCwdEnv() {},
|
||||
getTerminal() {
|
||||
return undefined;
|
||||
},
|
||||
killTerminal() {},
|
||||
listDirectories() {
|
||||
return [];
|
||||
},
|
||||
killAll() {},
|
||||
subscribeTerminalsChanged() {
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "feature-socket-service.web.localhost",
|
||||
port: expect.any(Number),
|
||||
workspaceId: repoDir,
|
||||
serviceName: "web",
|
||||
},
|
||||
]);
|
||||
expect(createTerminalCalls).toHaveLength(1);
|
||||
expect(createTerminalCalls[0]?.cwd).toBe(repoDir);
|
||||
expect(createTerminalCalls[0]?.name).toBe("web");
|
||||
expect(createTerminalCalls[0]?.env?.PORT).toEqual(expect.any(String));
|
||||
expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1");
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_URL).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,16 +7,20 @@ import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
import {
|
||||
createWorktree,
|
||||
getServiceConfigs,
|
||||
getWorktreeTerminalSpecs,
|
||||
listPaseoWorktrees,
|
||||
processCarriageReturns,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
runWorktreeSetupCommands,
|
||||
slugify,
|
||||
WorktreeSetupError,
|
||||
type WorktreeConfig,
|
||||
type WorktreeSetupCommandResult,
|
||||
type WorktreeRuntimeEnv,
|
||||
} from "../utils/worktree.js";
|
||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
||||
import { findFreePort, type ServiceRouteStore } from "./service-proxy.js";
|
||||
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
|
||||
|
||||
export interface WorktreeBootstrapTerminalResult {
|
||||
name: string | null;
|
||||
@@ -29,7 +33,10 @@ export interface WorktreeBootstrapTerminalResult {
|
||||
export interface RunAsyncWorktreeBootstrapOptions {
|
||||
agentId: string;
|
||||
worktree: WorktreeConfig;
|
||||
shouldBootstrap?: boolean;
|
||||
terminalManager: TerminalManager | null;
|
||||
serviceRouteStore?: ServiceRouteStore;
|
||||
daemonPort?: number | null;
|
||||
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
|
||||
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
|
||||
logger?: Logger;
|
||||
@@ -43,6 +50,11 @@ export interface CreateAgentWorktreeOptions {
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentWorktreeResult {
|
||||
worktree: WorktreeConfig;
|
||||
shouldBootstrap: boolean;
|
||||
}
|
||||
|
||||
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
||||
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
|
||||
const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500;
|
||||
@@ -51,8 +63,6 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
};
|
||||
const execAsync = promisify(exec);
|
||||
const worktreeSetupEligibility = new WeakMap<WorktreeConfig, boolean>();
|
||||
|
||||
type MiddleTruncationAccumulator = {
|
||||
totalBytes: number;
|
||||
head: string;
|
||||
@@ -60,6 +70,12 @@ type MiddleTruncationAccumulator = {
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export type WorktreeSetupOutputAccumulator = MiddleTruncationAccumulator;
|
||||
export type WorktreeSetupProgressAccumulator = {
|
||||
resultsByIndex: Map<number, WorktreeSetupCommandResult>;
|
||||
outputAccumulatorsByIndex: Map<number, WorktreeSetupOutputAccumulator>;
|
||||
};
|
||||
|
||||
function byteLength(text: string): number {
|
||||
return Buffer.byteLength(text, "utf8");
|
||||
}
|
||||
@@ -86,7 +102,7 @@ function sliceLastBytes(text: string, maxBytes: number): string {
|
||||
return bytes.subarray(bytes.length - maxBytes).toString("utf8");
|
||||
}
|
||||
|
||||
function createMiddleTruncationAccumulator(): MiddleTruncationAccumulator {
|
||||
export function createWorktreeSetupOutputAccumulator(): WorktreeSetupOutputAccumulator {
|
||||
return {
|
||||
totalBytes: 0,
|
||||
head: "",
|
||||
@@ -103,8 +119,8 @@ function getHeadTailBudgets(maxBytes: number): { headBytes: number; tailBytes: n
|
||||
return { headBytes, tailBytes };
|
||||
}
|
||||
|
||||
function appendToMiddleTruncationAccumulator(
|
||||
accumulator: MiddleTruncationAccumulator,
|
||||
export function appendWorktreeSetupOutputAccumulator(
|
||||
accumulator: WorktreeSetupOutputAccumulator,
|
||||
chunk: string,
|
||||
): void {
|
||||
if (!chunk) {
|
||||
@@ -161,16 +177,17 @@ function renderMiddleTruncationAccumulator(accumulator: MiddleTruncationAccumula
|
||||
|
||||
export async function createAgentWorktree(
|
||||
options: CreateAgentWorktreeOptions,
|
||||
): Promise<WorktreeConfig> {
|
||||
): Promise<CreateAgentWorktreeResult> {
|
||||
const existingWorktree = await findExistingPaseoWorktreeBySlug(options);
|
||||
if (existingWorktree) {
|
||||
const branchName = await resolveBranchNameForWorktreePath(existingWorktree.path);
|
||||
const reusedWorktree = {
|
||||
branchName,
|
||||
worktreePath: existingWorktree.path,
|
||||
return {
|
||||
worktree: {
|
||||
branchName,
|
||||
worktreePath: existingWorktree.path,
|
||||
},
|
||||
shouldBootstrap: false,
|
||||
};
|
||||
worktreeSetupEligibility.set(reusedWorktree, false);
|
||||
return reusedWorktree;
|
||||
}
|
||||
|
||||
const createdWorktree = await createWorktree({
|
||||
@@ -181,8 +198,10 @@ export async function createAgentWorktree(
|
||||
runSetup: false,
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
worktreeSetupEligibility.set(createdWorktree, true);
|
||||
return createdWorktree;
|
||||
return {
|
||||
worktree: createdWorktree,
|
||||
shouldBootstrap: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function findExistingPaseoWorktreeBySlug(options: CreateAgentWorktreeOptions) {
|
||||
@@ -221,7 +240,7 @@ function commandStatusFromResult(
|
||||
|
||||
function buildWorktreeSetupLog(input: {
|
||||
results: WorktreeSetupCommandResult[];
|
||||
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
|
||||
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
|
||||
}): { log: string; truncated: boolean } {
|
||||
const { results, outputAccumulatorsByIndex } = input;
|
||||
if (results.length === 0) {
|
||||
@@ -236,15 +255,13 @@ function buildWorktreeSetupLog(input: {
|
||||
const total = results.length;
|
||||
for (const [index, result] of results.entries()) {
|
||||
lines.push(`==> [${index + 1}/${total}] Running: ${result.command}`);
|
||||
const accumulator = outputAccumulatorsByIndex?.get(index + 1);
|
||||
const output = accumulator
|
||||
? renderMiddleTruncationAccumulator(accumulator)
|
||||
: truncateTextInMiddle(
|
||||
`${result.stdout ?? ""}${result.stderr ?? ""}`,
|
||||
MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES,
|
||||
);
|
||||
if (output.text.length > 0) {
|
||||
lines.push(output.text.replace(/\n$/, ""));
|
||||
const output = buildWorktreeSetupCommandLog({
|
||||
index: index + 1,
|
||||
result,
|
||||
outputAccumulatorsByIndex,
|
||||
});
|
||||
if (output.log.length > 0) {
|
||||
lines.push(output.log.replace(/\n$/, ""));
|
||||
}
|
||||
if (output.truncated) {
|
||||
anyTruncated = true;
|
||||
@@ -261,34 +278,136 @@ function buildWorktreeSetupLog(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorktreeSetupCommandLog(input: {
|
||||
index: number;
|
||||
result: WorktreeSetupCommandResult;
|
||||
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
|
||||
}): { log: string; truncated: boolean } {
|
||||
const { index, result, outputAccumulatorsByIndex } = input;
|
||||
const accumulator = outputAccumulatorsByIndex?.get(index);
|
||||
const rendered = accumulator
|
||||
? renderMiddleTruncationAccumulator(accumulator)
|
||||
: truncateTextInMiddle(
|
||||
`${result.stdout ?? ""}${result.stderr ?? ""}`,
|
||||
MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES,
|
||||
);
|
||||
|
||||
return {
|
||||
log: processCarriageReturns(rendered.text),
|
||||
truncated: rendered.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorktreeSetupProgressAccumulator(): WorktreeSetupProgressAccumulator {
|
||||
return {
|
||||
resultsByIndex: new Map(),
|
||||
outputAccumulatorsByIndex: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyWorktreeSetupProgressEvent(
|
||||
accumulator: WorktreeSetupProgressAccumulator,
|
||||
event: Parameters<NonNullable<Parameters<typeof runWorktreeSetupCommands>[0]["onEvent"]>>[0],
|
||||
): void {
|
||||
const existing = accumulator.resultsByIndex.get(event.index);
|
||||
const baseResult: WorktreeSetupCommandResult = existing ?? {
|
||||
command: event.command,
|
||||
cwd: event.cwd,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: null,
|
||||
durationMs: 0,
|
||||
};
|
||||
|
||||
if (event.type === "output") {
|
||||
const outputAccumulator =
|
||||
accumulator.outputAccumulatorsByIndex.get(event.index) ??
|
||||
createWorktreeSetupOutputAccumulator();
|
||||
appendWorktreeSetupOutputAccumulator(outputAccumulator, event.chunk);
|
||||
accumulator.outputAccumulatorsByIndex.set(event.index, outputAccumulator);
|
||||
accumulator.resultsByIndex.set(event.index, {
|
||||
...baseResult,
|
||||
stdout: baseResult.stdout,
|
||||
stderr: baseResult.stderr,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "command_completed") {
|
||||
accumulator.resultsByIndex.set(event.index, {
|
||||
...baseResult,
|
||||
stdout: event.stdout,
|
||||
stderr: event.stderr,
|
||||
exitCode: event.exitCode,
|
||||
durationMs: event.durationMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
accumulator.resultsByIndex.set(event.index, baseResult);
|
||||
}
|
||||
|
||||
export function getWorktreeSetupProgressResults(
|
||||
accumulator: WorktreeSetupProgressAccumulator,
|
||||
): WorktreeSetupCommandResult[] {
|
||||
return Array.from(accumulator.resultsByIndex.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, result]) => result);
|
||||
}
|
||||
|
||||
export function buildWorktreeSetupDetail(input: {
|
||||
worktree: WorktreeConfig;
|
||||
results: WorktreeSetupCommandResult[];
|
||||
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
|
||||
}): Extract<ToolCallDetail, { type: "worktree_setup" }> {
|
||||
let anyCommandTruncated = false;
|
||||
const commands = input.results.map((result, index) => {
|
||||
const renderedLog = buildWorktreeSetupCommandLog({
|
||||
index: index + 1,
|
||||
result,
|
||||
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
|
||||
});
|
||||
if (renderedLog.truncated) {
|
||||
anyCommandTruncated = true;
|
||||
}
|
||||
return {
|
||||
index: index + 1,
|
||||
command: result.command,
|
||||
cwd: result.cwd,
|
||||
log: renderedLog.log,
|
||||
status: commandStatusFromResult(result),
|
||||
exitCode: result.exitCode,
|
||||
...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}),
|
||||
};
|
||||
});
|
||||
const renderedLog = buildWorktreeSetupLog({
|
||||
results: input.results,
|
||||
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
type: "worktree_setup",
|
||||
worktreePath: input.worktree.worktreePath,
|
||||
branchName: input.worktree.branchName,
|
||||
log: renderedLog.log,
|
||||
commands,
|
||||
...(renderedLog.truncated || anyCommandTruncated ? { truncated: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSetupTimelineItem(input: {
|
||||
callId: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
worktree: WorktreeConfig;
|
||||
results: WorktreeSetupCommandResult[];
|
||||
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
|
||||
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
|
||||
errorMessage: string | null;
|
||||
}): AgentTimelineItem {
|
||||
const commands = input.results.map((result, index) => ({
|
||||
index: index + 1,
|
||||
command: result.command,
|
||||
cwd: result.cwd,
|
||||
status: commandStatusFromResult(result),
|
||||
exitCode: result.exitCode,
|
||||
...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}),
|
||||
}));
|
||||
const renderedLog = buildWorktreeSetupLog({
|
||||
const detail = buildWorktreeSetupDetail({
|
||||
worktree: input.worktree,
|
||||
results: input.results,
|
||||
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
|
||||
});
|
||||
const detail = {
|
||||
type: "worktree_setup" as const,
|
||||
worktreePath: input.worktree.worktreePath,
|
||||
branchName: input.worktree.branchName,
|
||||
log: renderedLog.log,
|
||||
commands,
|
||||
...(renderedLog.truncated ? { truncated: true } : {}),
|
||||
};
|
||||
|
||||
if (input.status === "running") {
|
||||
return {
|
||||
@@ -523,7 +642,7 @@ async function runWorktreeTerminalBootstrap(
|
||||
export async function runAsyncWorktreeBootstrap(
|
||||
options: RunAsyncWorktreeBootstrapOptions,
|
||||
): Promise<void> {
|
||||
if (worktreeSetupEligibility.get(options.worktree) === false) {
|
||||
if (options.shouldBootstrap === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -531,17 +650,14 @@ export async function runAsyncWorktreeBootstrap(
|
||||
let setupResults: WorktreeSetupCommandResult[] = [];
|
||||
let runtimeEnv: WorktreeRuntimeEnv | null = null;
|
||||
const emitLiveTimelineItem = options.emitLiveTimelineItem;
|
||||
const runningResultsByIndex = new Map<number, WorktreeSetupCommandResult>();
|
||||
const outputAccumulatorsByIndex = new Map<number, MiddleTruncationAccumulator>();
|
||||
const progressAccumulator = createWorktreeSetupProgressAccumulator();
|
||||
let liveEmitQueue = Promise.resolve();
|
||||
|
||||
const queueLiveRunningEmit = () => {
|
||||
if (!emitLiveTimelineItem) {
|
||||
return;
|
||||
}
|
||||
const runningResults = Array.from(runningResultsByIndex.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, result]) => result);
|
||||
const runningResults = getWorktreeSetupProgressResults(progressAccumulator);
|
||||
liveEmitQueue = liveEmitQueue.then(async () => {
|
||||
try {
|
||||
await emitLiveTimelineItem(
|
||||
@@ -550,7 +666,7 @@ export async function runAsyncWorktreeBootstrap(
|
||||
status: "running",
|
||||
worktree: options.worktree,
|
||||
results: runningResults,
|
||||
outputAccumulatorsByIndex,
|
||||
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||
errorMessage: null,
|
||||
}),
|
||||
);
|
||||
@@ -579,42 +695,7 @@ export async function runAsyncWorktreeBootstrap(
|
||||
cleanupOnFailure: false,
|
||||
runtimeEnv,
|
||||
onEvent: (event) => {
|
||||
const existing = runningResultsByIndex.get(event.index);
|
||||
const baseResult: WorktreeSetupCommandResult = existing ?? {
|
||||
command: event.command,
|
||||
cwd: event.cwd,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: null,
|
||||
durationMs: 0,
|
||||
};
|
||||
if (event.type === "output") {
|
||||
const outputAccumulator =
|
||||
outputAccumulatorsByIndex.get(event.index) ?? createMiddleTruncationAccumulator();
|
||||
appendToMiddleTruncationAccumulator(outputAccumulator, event.chunk);
|
||||
outputAccumulatorsByIndex.set(event.index, outputAccumulator);
|
||||
runningResultsByIndex.set(event.index, {
|
||||
...baseResult,
|
||||
// Keep the timeline command model lightweight; output is carried in
|
||||
// outputAccumulatorsByIndex.
|
||||
stdout: baseResult.stdout,
|
||||
stderr: baseResult.stderr,
|
||||
});
|
||||
queueLiveRunningEmit();
|
||||
return;
|
||||
}
|
||||
if (event.type === "command_completed") {
|
||||
runningResultsByIndex.set(event.index, {
|
||||
...baseResult,
|
||||
stdout: event.stdout,
|
||||
stderr: event.stderr,
|
||||
exitCode: event.exitCode,
|
||||
durationMs: event.durationMs,
|
||||
});
|
||||
queueLiveRunningEmit();
|
||||
return;
|
||||
}
|
||||
runningResultsByIndex.set(event.index, baseResult);
|
||||
applyWorktreeSetupProgressEvent(progressAccumulator, event);
|
||||
queueLiveRunningEmit();
|
||||
},
|
||||
});
|
||||
@@ -626,7 +707,7 @@ export async function runAsyncWorktreeBootstrap(
|
||||
status: "completed",
|
||||
worktree: options.worktree,
|
||||
results: setupResults,
|
||||
outputAccumulatorsByIndex,
|
||||
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||
errorMessage: null,
|
||||
}),
|
||||
);
|
||||
@@ -645,7 +726,7 @@ export async function runAsyncWorktreeBootstrap(
|
||||
status: "failed",
|
||||
worktree: options.worktree,
|
||||
results: setupResults,
|
||||
outputAccumulatorsByIndex,
|
||||
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||
errorMessage: message,
|
||||
}),
|
||||
);
|
||||
@@ -653,4 +734,138 @@ export async function runAsyncWorktreeBootstrap(
|
||||
}
|
||||
|
||||
await runWorktreeTerminalBootstrap(options, runtimeEnv);
|
||||
|
||||
if (!options.terminalManager || !options.serviceRouteStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await spawnWorktreeServices({
|
||||
repoRoot: options.worktree.worktreePath,
|
||||
workspaceId: options.worktree.worktreePath,
|
||||
branchName: options.worktree.branchName,
|
||||
daemonPort: options.daemonPort,
|
||||
routeStore: options.serviceRouteStore,
|
||||
terminalManager: options.terminalManager,
|
||||
logger: options.logger,
|
||||
});
|
||||
} catch (error) {
|
||||
options.logger?.warn(
|
||||
{ err: error, agentId: options.agentId, worktreePath: options.worktree.worktreePath },
|
||||
"Failed to spawn worktree services",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service lifecycle helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorktreeServiceResult {
|
||||
serviceName: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
terminalId: string;
|
||||
}
|
||||
|
||||
export async function spawnWorktreeServices(options: {
|
||||
repoRoot: string;
|
||||
workspaceId: string;
|
||||
branchName: string | null;
|
||||
daemonPort?: number | null;
|
||||
routeStore: ServiceRouteStore;
|
||||
terminalManager: TerminalManager;
|
||||
logger?: Logger;
|
||||
}): Promise<WorktreeServiceResult[]> {
|
||||
const { repoRoot, workspaceId, branchName, daemonPort, routeStore, terminalManager, logger } =
|
||||
options;
|
||||
const serviceConfigs = getServiceConfigs(repoRoot);
|
||||
if (serviceConfigs.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: WorktreeServiceResult[] = [];
|
||||
|
||||
for (const [serviceName, config] of serviceConfigs) {
|
||||
let hostname: string | null = null;
|
||||
let port: number | null = null;
|
||||
|
||||
try {
|
||||
port = config.port ?? (await findFreePort());
|
||||
const branchHostnameLabel = branchName ? slugify(branchName) : null;
|
||||
|
||||
const isDefaultBranch =
|
||||
branchName === null || branchName === "main" || branchName === "master";
|
||||
hostname = isDefaultBranch
|
||||
? `${serviceName}.localhost`
|
||||
: `${branchHostnameLabel}.${serviceName}.localhost`;
|
||||
|
||||
routeStore.registerRoute({
|
||||
hostname,
|
||||
port,
|
||||
workspaceId,
|
||||
serviceName,
|
||||
});
|
||||
|
||||
const env: Record<string, string> = {
|
||||
PORT: String(port),
|
||||
HOST: "127.0.0.1",
|
||||
};
|
||||
if (daemonPort !== null && daemonPort !== undefined) {
|
||||
env.PASEO_SERVICE_URL = `http://${hostname}:${daemonPort}`;
|
||||
}
|
||||
|
||||
const terminal = await terminalManager.createTerminal({
|
||||
cwd: repoRoot,
|
||||
name: serviceName,
|
||||
env,
|
||||
});
|
||||
|
||||
await waitForTerminalBootstrapReadiness(terminal);
|
||||
terminal.send({ type: "input", data: `${config.command}\r` });
|
||||
|
||||
logger?.info(
|
||||
{ serviceName, hostname, port, terminalId: terminal.id },
|
||||
`Registered service proxy: ${hostname} -> 127.0.0.1:${port}`,
|
||||
);
|
||||
|
||||
results.push({
|
||||
serviceName,
|
||||
hostname,
|
||||
port,
|
||||
terminalId: terminal.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (hostname && port !== null) {
|
||||
routeStore.removeRoute(hostname);
|
||||
}
|
||||
logger?.error(
|
||||
{
|
||||
err: error,
|
||||
serviceName,
|
||||
repoRoot,
|
||||
branchName,
|
||||
hostname,
|
||||
port,
|
||||
command: config.command,
|
||||
},
|
||||
"Failed to spawn worktree service",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function teardownWorktreeServices(options: {
|
||||
hostnames: string[];
|
||||
routeStore: ServiceRouteStore;
|
||||
logger: Logger;
|
||||
}): void {
|
||||
const { hostnames, routeStore, logger } = options;
|
||||
for (const hostname of hostnames) {
|
||||
routeStore.removeRoute(hostname);
|
||||
logger.info({ hostname }, "Removed service proxy route");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,795 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { SessionOutboundMessage } from "./messages.js";
|
||||
import { ServiceRouteStore } from "./service-proxy.js";
|
||||
import {
|
||||
createPaseoWorktreeInBackground,
|
||||
handleCreatePaseoWorktreeRequest,
|
||||
handleWorkspaceSetupStatusRequest,
|
||||
} from "./worktree-session.js";
|
||||
import { createWorktree } from "../utils/worktree.js";
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createTerminalManagerStub(options?: {
|
||||
createTerminal?: (input: {
|
||||
cwd: string;
|
||||
name?: string;
|
||||
env?: Record<string, string>;
|
||||
}) => Promise<any>;
|
||||
}) {
|
||||
const terminals: Array<{
|
||||
id: string;
|
||||
cwd: string;
|
||||
name: string | undefined;
|
||||
env: Record<string, string> | undefined;
|
||||
sent: string[];
|
||||
}> = [];
|
||||
|
||||
return {
|
||||
terminals,
|
||||
manager: {
|
||||
registerCwdEnv: vi.fn(),
|
||||
createTerminal: vi.fn(async (input: {
|
||||
cwd: string;
|
||||
name?: string;
|
||||
env?: Record<string, string>;
|
||||
}) => {
|
||||
if (options?.createTerminal) {
|
||||
return options.createTerminal(input);
|
||||
}
|
||||
const sent: string[] = [];
|
||||
const terminal = {
|
||||
id: `terminal-${terminals.length + 1}`,
|
||||
getState: () => ({
|
||||
scrollback: [[{ char: "$" }]],
|
||||
grid: [],
|
||||
}),
|
||||
subscribe: () => () => {},
|
||||
send: (message: { type: string; data: string }) => {
|
||||
if (message.type === "input") {
|
||||
sent.push(message.data);
|
||||
}
|
||||
},
|
||||
};
|
||||
terminals.push({
|
||||
id: terminal.id,
|
||||
cwd: input.cwd,
|
||||
name: input.name,
|
||||
env: input.env,
|
||||
sent,
|
||||
});
|
||||
return terminal;
|
||||
}),
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
function createGitRepo(options?: { paseoConfig?: Record<string, unknown> }) {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-session-test-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
||||
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
||||
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
||||
if (options?.paseoConfig) {
|
||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||
}
|
||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
describe("createPaseoWorktreeInBackground", () => {
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const target of cleanupPaths.splice(0)) {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("emits running then completed snapshots for no-setup workspaces and then launches services", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
paseoConfig: {
|
||||
services: {
|
||||
web: {
|
||||
command: "npm run dev",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createWorktree({
|
||||
branchName: "feature-no-setup",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-no-setup",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const routeStore = new ServiceRouteStore();
|
||||
const logger = createLogger();
|
||||
const terminalManager = createTerminalManagerStub();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await createPaseoWorktreeInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: terminalManager.manager,
|
||||
archiveWorkspaceRecord,
|
||||
serviceRouteStore: routeStore,
|
||||
daemonPort: 6767,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "feature-no-setup",
|
||||
worktreePath,
|
||||
},
|
||||
shouldBootstrap: true,
|
||||
},
|
||||
);
|
||||
|
||||
const progressMessages = emitted.filter(
|
||||
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||
message.type === "workspace_setup_progress",
|
||||
);
|
||||
expect(progressMessages).toHaveLength(2);
|
||||
expect(progressMessages[0]?.payload).toMatchObject({
|
||||
workspaceId: worktreePath,
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath,
|
||||
branchName: "feature-no-setup",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
});
|
||||
expect(progressMessages[1]?.payload).toMatchObject({
|
||||
workspaceId: worktreePath,
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath,
|
||||
branchName: "feature-no-setup",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
});
|
||||
expect(snapshots.get(worktreePath)).toMatchObject({
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath,
|
||||
branchName: "feature-no-setup",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "feature-no-setup.web.localhost",
|
||||
port: expect.any(Number),
|
||||
workspaceId: worktreePath,
|
||||
serviceName: "web",
|
||||
},
|
||||
]);
|
||||
expect(terminalManager.terminals).toHaveLength(1);
|
||||
expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath);
|
||||
expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String));
|
||||
expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeDefined();
|
||||
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||
});
|
||||
|
||||
test("archives the pending workspace and emits a failed snapshot when setup cannot start", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
writeFileSync(path.join(repoDir, "paseo.json"), "{ invalid json\n");
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'broken config'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createWorktree({
|
||||
branchName: "broken-feature",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "broken-feature",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const logger = createLogger();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await createPaseoWorktreeInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord,
|
||||
serviceRouteStore: null,
|
||||
daemonPort: null,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "broken-feature",
|
||||
worktreePath,
|
||||
},
|
||||
shouldBootstrap: true,
|
||||
},
|
||||
);
|
||||
|
||||
const progressMessages = emitted.filter(
|
||||
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||
message.type === "workspace_setup_progress",
|
||||
);
|
||||
expect(progressMessages).toHaveLength(2);
|
||||
expect(progressMessages[0]?.payload.status).toBe("running");
|
||||
expect(progressMessages[0]?.payload.error).toBeNull();
|
||||
expect(progressMessages[1]?.payload.status).toBe("failed");
|
||||
expect(progressMessages[1]?.payload.error).toContain("Failed to parse paseo.json");
|
||||
expect(progressMessages[1]?.payload.detail.commands).toEqual([]);
|
||||
expect(snapshots.get(worktreePath)).toMatchObject({
|
||||
status: "failed",
|
||||
error: expect.stringContaining("Failed to parse paseo.json"),
|
||||
});
|
||||
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(worktreePath);
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||
});
|
||||
|
||||
test("emits running setup snapshots before completed for real setup commands", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ['sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"'],
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createWorktree({
|
||||
branchName: "feature-running-setup",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-running-setup",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const logger = createLogger();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await createPaseoWorktreeInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord,
|
||||
serviceRouteStore: null,
|
||||
daemonPort: null,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "feature-running-setup",
|
||||
worktreePath,
|
||||
},
|
||||
shouldBootstrap: true,
|
||||
},
|
||||
);
|
||||
|
||||
const progressMessages = emitted.filter(
|
||||
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||
message.type === "workspace_setup_progress",
|
||||
);
|
||||
expect(progressMessages.length).toBeGreaterThan(1);
|
||||
expect(progressMessages[0]?.payload).toMatchObject({
|
||||
workspaceId: worktreePath,
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath,
|
||||
branchName: "feature-running-setup",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
});
|
||||
expect(progressMessages.at(-1)?.payload.status).toBe("completed");
|
||||
|
||||
const runningMessages = progressMessages.filter((message) => message.payload.status === "running");
|
||||
expect(runningMessages.length).toBeGreaterThan(0);
|
||||
expect(progressMessages.findIndex((message) => message.payload.status === "running")).toBeLessThan(
|
||||
progressMessages.findIndex((message) => message.payload.status === "completed"),
|
||||
);
|
||||
|
||||
const setupOutputMessage = runningMessages.find((message) =>
|
||||
message.payload.detail.commands[0]?.log.includes("phase-one"),
|
||||
);
|
||||
expect(setupOutputMessage?.payload.detail.log).toContain("phase-one");
|
||||
expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({
|
||||
index: 1,
|
||||
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
|
||||
log: expect.stringContaining("phase-one"),
|
||||
status: "running",
|
||||
});
|
||||
|
||||
expect(progressMessages.at(-1)?.payload).toMatchObject({
|
||||
workspaceId: worktreePath,
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath,
|
||||
branchName: "feature-running-setup",
|
||||
},
|
||||
});
|
||||
expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two");
|
||||
expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({
|
||||
index: 1,
|
||||
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
|
||||
log: expect.stringContaining("phase-two"),
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(snapshots.get(worktreePath)).toMatchObject({
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("emits completed when reusing an existing worktree without bootstrapping", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["printf 'ran' > setup-ran.txt"],
|
||||
},
|
||||
services: {
|
||||
web: {
|
||||
command: "npm run dev",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const existingWorktree = await createWorktree({
|
||||
branchName: "reused-worktree",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "reused-worktree",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const routeStore = new ServiceRouteStore();
|
||||
const logger = createLogger();
|
||||
const terminalManager = createTerminalManagerStub();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await createPaseoWorktreeInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: terminalManager.manager,
|
||||
archiveWorkspaceRecord,
|
||||
serviceRouteStore: routeStore,
|
||||
daemonPort: 6767,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "reused-worktree",
|
||||
worktreePath: existingWorktree.worktreePath,
|
||||
},
|
||||
shouldBootstrap: false,
|
||||
},
|
||||
);
|
||||
|
||||
const progressMessages = emitted.filter(
|
||||
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||
message.type === "workspace_setup_progress",
|
||||
);
|
||||
expect(progressMessages).toHaveLength(2);
|
||||
expect(progressMessages[0]?.payload).toMatchObject({
|
||||
workspaceId: existingWorktree.worktreePath,
|
||||
status: "running",
|
||||
error: null,
|
||||
});
|
||||
expect(progressMessages[1]?.payload).toMatchObject({
|
||||
workspaceId: existingWorktree.worktreePath,
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: existingWorktree.worktreePath,
|
||||
branchName: "reused-worktree",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
});
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "reused-worktree.web.localhost",
|
||||
port: expect.any(Number),
|
||||
workspaceId: existingWorktree.worktreePath,
|
||||
serviceName: "web",
|
||||
},
|
||||
]);
|
||||
expect(terminalManager.terminals).toHaveLength(1);
|
||||
expect(terminalManager.terminals[0]?.cwd).toBe(existingWorktree.worktreePath);
|
||||
expect(terminalManager.terminals[0]?.name).toBe("web");
|
||||
expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String));
|
||||
expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeDefined();
|
||||
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
|
||||
expect(
|
||||
readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"),
|
||||
).toContain("hello");
|
||||
expect(() => readFileSync(path.join(existingWorktree.worktreePath, "setup-ran.txt"), "utf8")).toThrow();
|
||||
expect(snapshots.get(existingWorktree.worktreePath)).toMatchObject({
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath);
|
||||
});
|
||||
|
||||
test("keeps setup completed when service launch fails afterward", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
paseoConfig: {
|
||||
services: {
|
||||
web: {
|
||||
command: "npm run dev",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createWorktree({
|
||||
branchName: "feature-service-failure",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-service-failure",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const routeStore = new ServiceRouteStore();
|
||||
const logger = createLogger();
|
||||
const terminalManager = createTerminalManagerStub({
|
||||
createTerminal: async () => {
|
||||
throw new Error("terminal spawn failed");
|
||||
},
|
||||
});
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await createPaseoWorktreeInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: terminalManager.manager,
|
||||
archiveWorkspaceRecord,
|
||||
serviceRouteStore: routeStore,
|
||||
daemonPort: 6767,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "feature-service-failure",
|
||||
worktreePath,
|
||||
},
|
||||
shouldBootstrap: true,
|
||||
},
|
||||
);
|
||||
|
||||
const progressMessages = emitted.filter(
|
||||
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||
message.type === "workspace_setup_progress",
|
||||
);
|
||||
expect(progressMessages).toHaveLength(2);
|
||||
expect(progressMessages[0]?.payload.status).toBe("running");
|
||||
expect(progressMessages[0]?.payload.error).toBeNull();
|
||||
expect(progressMessages[1]?.payload.status).toBe("completed");
|
||||
expect(progressMessages[1]?.payload.error).toBeNull();
|
||||
expect(emitted.some((message) => message.type === "workspace_setup_progress" && message.payload.status === "failed")).toBe(false);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
cwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktreeSlug: "feature-service-failure",
|
||||
worktreePath,
|
||||
}),
|
||||
"Failed to spawn worktree services after workspace setup completed",
|
||||
);
|
||||
expect(snapshots.get(worktreePath)).toMatchObject({
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||
});
|
||||
|
||||
test("launches services in socket mode without requiring a daemon TCP port", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
paseoConfig: {
|
||||
services: {
|
||||
web: {
|
||||
command: "npm run dev",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createWorktree({
|
||||
branchName: "feature-socket-mode",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-socket-mode",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const routeStore = new ServiceRouteStore();
|
||||
const logger = createLogger();
|
||||
const terminalManager = createTerminalManagerStub();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await createPaseoWorktreeInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: terminalManager.manager,
|
||||
archiveWorkspaceRecord,
|
||||
serviceRouteStore: routeStore,
|
||||
daemonPort: null,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "feature-socket-mode",
|
||||
worktreePath,
|
||||
},
|
||||
shouldBootstrap: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "feature-socket-mode.web.localhost",
|
||||
port: expect.any(Number),
|
||||
workspaceId: worktreePath,
|
||||
serviceName: "web",
|
||||
},
|
||||
]);
|
||||
expect(terminalManager.terminals).toHaveLength(1);
|
||||
expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath);
|
||||
expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String));
|
||||
expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeUndefined();
|
||||
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
|
||||
expect(snapshots.get(worktreePath)).toMatchObject({
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||
});
|
||||
|
||||
test("returns the cached workspace setup snapshot for status requests", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map([
|
||||
[
|
||||
"/repo/.paseo/worktrees/feature-a",
|
||||
{
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/repo/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
log: "done",
|
||||
commands: [],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await handleWorkspaceSetupStatusRequest(
|
||||
{
|
||||
emit: (message) => emitted.push(message),
|
||||
workspaceSetupSnapshots: snapshots,
|
||||
},
|
||||
{
|
||||
type: "workspace_setup_status_request",
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
requestId: "req-status",
|
||||
},
|
||||
);
|
||||
|
||||
expect(emitted).toContainEqual({
|
||||
type: "workspace_setup_status_response",
|
||||
payload: {
|
||||
requestId: "req-status",
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
snapshot: {
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/repo/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
log: "done",
|
||||
commands: [],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when no cached workspace setup snapshot exists", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
|
||||
await handleWorkspaceSetupStatusRequest(
|
||||
{
|
||||
emit: (message) => emitted.push(message),
|
||||
workspaceSetupSnapshots: new Map(),
|
||||
},
|
||||
{
|
||||
type: "workspace_setup_status_request",
|
||||
workspaceId: "/repo/.paseo/worktrees/missing",
|
||||
requestId: "req-missing",
|
||||
},
|
||||
);
|
||||
|
||||
expect(emitted).toContainEqual({
|
||||
type: "workspace_setup_status_response",
|
||||
payload: {
|
||||
requestId: "req-missing",
|
||||
workspaceId: "/repo/.paseo/worktrees/missing",
|
||||
snapshot: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
test("creates the worktree before emitting the response", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const backgroundWork = vi.fn(async () => {});
|
||||
|
||||
try {
|
||||
await handleCreatePaseoWorktreeRequest(
|
||||
{
|
||||
paseoHome,
|
||||
sessionLogger: createLogger(),
|
||||
emit: (message) => emitted.push(message),
|
||||
registerPendingWorktreeWorkspace: vi.fn(async (options) => {
|
||||
expect(existsSync(options.worktreePath)).toBe(true);
|
||||
return {
|
||||
workspaceId: options.worktreePath,
|
||||
projectId: options.repoRoot,
|
||||
} as any;
|
||||
}),
|
||||
describeWorkspaceRecord: vi.fn(async (workspace) => ({
|
||||
id: workspace.workspaceId,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: path.basename(repoDir),
|
||||
projectRootPath: repoDir,
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: path.basename(workspace.workspaceId),
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
})),
|
||||
createPaseoWorktreeInBackground: backgroundWork,
|
||||
},
|
||||
{
|
||||
type: "create_paseo_worktree_request",
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "response-after-create",
|
||||
requestId: "req-1",
|
||||
},
|
||||
);
|
||||
|
||||
const response = emitted.find(
|
||||
(message): message is Extract<SessionOutboundMessage, { type: "create_paseo_worktree_response" }> =>
|
||||
message.type === "create_paseo_worktree_response",
|
||||
);
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.id).toBeTruthy();
|
||||
expect(existsSync(response!.payload.workspace!.id)).toBe(true);
|
||||
expect(backgroundWork).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "response-after-create",
|
||||
worktreePath: response!.payload.workspace!.id,
|
||||
},
|
||||
shouldBootstrap: true,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,831 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { Logger } from "pino";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import {
|
||||
type GitSetupOptions,
|
||||
type ProjectPlacementPayload,
|
||||
type SessionInboundMessage,
|
||||
type SessionOutboundMessage,
|
||||
type WorkspaceSetupSnapshot,
|
||||
type WorkspaceDescriptorPayload,
|
||||
} from "./messages.js";
|
||||
import type {
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
||||
import {
|
||||
applyWorktreeSetupProgressEvent,
|
||||
buildWorktreeSetupDetail,
|
||||
createAgentWorktree,
|
||||
createWorktreeSetupProgressAccumulator,
|
||||
getWorktreeSetupProgressResults,
|
||||
spawnWorktreeServices,
|
||||
} from "./worktree-bootstrap.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { ServiceRouteStore } from "./service-proxy.js";
|
||||
import {
|
||||
getCheckoutStatusLite,
|
||||
resolveRepositoryDefaultBranch,
|
||||
} from "../utils/checkout-git.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import {
|
||||
computeWorktreePath,
|
||||
deletePaseoWorktree,
|
||||
getWorktreeSetupCommands,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
listPaseoWorktrees,
|
||||
resolvePaseoWorktreeRootForCwd,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
runWorktreeSetupCommands,
|
||||
slugify,
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
type WorktreeSetupCommandResult,
|
||||
WorktreeSetupError,
|
||||
} from "../utils/worktree.js";
|
||||
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
||||
|
||||
export type NormalizedGitOptions = {
|
||||
baseBranch?: string;
|
||||
createNewBranch: boolean;
|
||||
newBranchName?: string;
|
||||
createWorktree: boolean;
|
||||
worktreeSlug?: string;
|
||||
};
|
||||
|
||||
type EmitSessionMessage = (message: SessionOutboundMessage) => void;
|
||||
|
||||
type BuildAgentSessionConfigDependencies = {
|
||||
paseoHome?: string;
|
||||
sessionLogger: Logger;
|
||||
checkoutExistingBranch: (cwd: string, branch: string) => Promise<void>;
|
||||
createBranchFromBase: (params: {
|
||||
cwd: string;
|
||||
baseBranch: string;
|
||||
newBranchName: string;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
type ArchivePaseoWorktreeDependencies = {
|
||||
paseoHome?: string;
|
||||
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
|
||||
agentStorage: Pick<AgentStorage, "list" | "remove">;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
emit: EmitSessionMessage;
|
||||
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type RegisterPendingWorktreeWorkspaceDependencies = {
|
||||
buildPersistedProjectRecord: (input: {
|
||||
workspaceId: string;
|
||||
placement: ProjectPlacementPayload;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}) => PersistedProjectRecord;
|
||||
buildPersistedWorkspaceRecord: (input: {
|
||||
workspaceId: string;
|
||||
placement: ProjectPlacementPayload;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}) => PersistedWorkspaceRecord;
|
||||
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
|
||||
syncWorkspaceGitWatchTarget: (
|
||||
cwd: string,
|
||||
options: { isGit: boolean },
|
||||
) => Promise<void>;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert">;
|
||||
archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type CreatePaseoWorktreeInBackgroundDependencies = {
|
||||
paseoHome?: 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>;
|
||||
serviceRouteStore: ServiceRouteStore | null;
|
||||
daemonPort?: number | null;
|
||||
};
|
||||
|
||||
type HandleWorkspaceSetupStatusRequestDependencies = {
|
||||
emit: EmitSessionMessage;
|
||||
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
|
||||
};
|
||||
|
||||
type HandleCreatePaseoWorktreeRequestDependencies = {
|
||||
paseoHome?: string;
|
||||
describeWorkspaceRecord: (
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
) => Promise<WorkspaceDescriptorPayload>;
|
||||
emit: EmitSessionMessage;
|
||||
registerPendingWorktreeWorkspace: (options: {
|
||||
repoRoot: string;
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
}) => Promise<PersistedWorkspaceRecord>;
|
||||
sessionLogger: Logger;
|
||||
createPaseoWorktreeInBackground: (options: {
|
||||
requestCwd: string;
|
||||
repoRoot: string;
|
||||
worktree: WorktreeConfig;
|
||||
shouldBootstrap: boolean;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
type KillTerminalsUnderPathDependencies = {
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
killTrackedTerminal: (terminalId: string, options?: { emitExit: boolean }) => void;
|
||||
sessionLogger: Logger;
|
||||
terminalManager: TerminalManager | null;
|
||||
};
|
||||
|
||||
export async function buildAgentSessionConfig(
|
||||
dependencies: BuildAgentSessionConfigDependencies,
|
||||
config: AgentSessionConfig,
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
_labels?: Record<string, string>,
|
||||
): Promise<{
|
||||
sessionConfig: AgentSessionConfig;
|
||||
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
|
||||
}> {
|
||||
let cwd = expandTilde(config.cwd);
|
||||
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
|
||||
let worktreeBootstrap: { worktree: WorktreeConfig; shouldBootstrap: boolean } | undefined;
|
||||
|
||||
if (!normalized) {
|
||||
return {
|
||||
sessionConfig: {
|
||||
...config,
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized.createWorktree) {
|
||||
let targetBranch: string;
|
||||
|
||||
if (normalized.createNewBranch) {
|
||||
targetBranch = normalized.newBranchName!;
|
||||
} else {
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
targetBranch = stdout.trim();
|
||||
}
|
||||
|
||||
if (!targetBranch) {
|
||||
throw new Error("A branch name is required when creating a worktree.");
|
||||
}
|
||||
|
||||
dependencies.sessionLogger.info(
|
||||
{ worktreeSlug: normalized.worktreeSlug ?? targetBranch, branch: targetBranch },
|
||||
`Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}`,
|
||||
);
|
||||
|
||||
const baseBranch =
|
||||
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
||||
const createdWorktree = await createAgentWorktree({
|
||||
branchName: targetBranch,
|
||||
cwd,
|
||||
baseBranch,
|
||||
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
cwd = createdWorktree.worktree.worktreePath;
|
||||
worktreeBootstrap = createdWorktree;
|
||||
} else if (normalized.createNewBranch) {
|
||||
const baseBranch =
|
||||
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
||||
await dependencies.createBranchFromBase({
|
||||
cwd,
|
||||
baseBranch,
|
||||
newBranchName: normalized.newBranchName!,
|
||||
});
|
||||
} else if (normalized.baseBranch) {
|
||||
await dependencies.checkoutExistingBranch(cwd, normalized.baseBranch);
|
||||
}
|
||||
|
||||
return {
|
||||
sessionConfig: {
|
||||
...config,
|
||||
cwd,
|
||||
},
|
||||
worktreeBootstrap,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeGitOptions(
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
): NormalizedGitOptions | null {
|
||||
const fallbackOptions: GitSetupOptions | undefined = legacyWorktreeName
|
||||
? {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
newBranchName: legacyWorktreeName,
|
||||
worktreeSlug: legacyWorktreeName,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const merged = gitOptions ?? fallbackOptions;
|
||||
if (!merged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseBranch = merged.baseBranch?.trim() || undefined;
|
||||
const createWorktree = Boolean(merged.createWorktree);
|
||||
const createNewBranch = Boolean(merged.createNewBranch);
|
||||
const normalizedBranchName = merged.newBranchName ? slugify(merged.newBranchName) : undefined;
|
||||
const normalizedWorktreeSlug = merged.worktreeSlug
|
||||
? slugify(merged.worktreeSlug)
|
||||
: normalizedBranchName;
|
||||
|
||||
if (!createWorktree && !createNewBranch && !baseBranch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (baseBranch) {
|
||||
assertSafeGitRef(baseBranch, "base branch");
|
||||
}
|
||||
|
||||
if (createNewBranch) {
|
||||
if (!normalizedBranchName) {
|
||||
throw new Error("New branch name is required");
|
||||
}
|
||||
const validation = validateBranchSlug(normalizedBranchName);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid branch name: ${validation.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedWorktreeSlug) {
|
||||
const validation = validateBranchSlug(normalizedWorktreeSlug);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid worktree name: ${validation.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseBranch,
|
||||
createNewBranch,
|
||||
newBranchName: normalizedBranchName,
|
||||
createWorktree,
|
||||
worktreeSlug: normalizedWorktreeSlug,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertSafeGitRef(ref: string, label: string): void {
|
||||
if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) {
|
||||
throw new Error(`Invalid ${label}: ${ref}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveGitCreateBaseBranch(
|
||||
cwd: string,
|
||||
paseoHome?: string,
|
||||
): Promise<string> {
|
||||
const checkout = await getCheckoutStatusLite(cwd, { paseoHome });
|
||||
if (!checkout.isGit) {
|
||||
throw new Error("Cannot create a worktree outside a git repository");
|
||||
}
|
||||
|
||||
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd;
|
||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
||||
if (!baseBranch) {
|
||||
throw new Error("Unable to resolve repository default branch");
|
||||
}
|
||||
return baseBranch;
|
||||
}
|
||||
|
||||
export async function handlePaseoWorktreeListRequest(
|
||||
dependencies: { emit: EmitSessionMessage; paseoHome?: string },
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
|
||||
): Promise<void> {
|
||||
const { requestId } = msg;
|
||||
const cwd = msg.repoRoot ?? msg.cwd;
|
||||
if (!cwd) {
|
||||
dependencies.emit({
|
||||
type: "paseo_worktree_list_response",
|
||||
payload: {
|
||||
worktrees: [],
|
||||
error: { code: "UNKNOWN", message: "cwd or repoRoot is required" },
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const worktrees = await listPaseoWorktrees({ cwd, paseoHome: dependencies.paseoHome });
|
||||
dependencies.emit({
|
||||
type: "paseo_worktree_list_response",
|
||||
payload: {
|
||||
worktrees: worktrees.map((entry) => ({
|
||||
worktreePath: entry.path,
|
||||
createdAt: entry.createdAt,
|
||||
branchName: entry.branchName ?? null,
|
||||
head: entry.head ?? null,
|
||||
})),
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
dependencies.emit({
|
||||
type: "paseo_worktree_list_response",
|
||||
payload: {
|
||||
worktrees: [],
|
||||
error: toCheckoutError(error),
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function archivePaseoWorktree(
|
||||
dependencies: ArchivePaseoWorktreeDependencies,
|
||||
options: {
|
||||
targetPath: string;
|
||||
repoRoot: string;
|
||||
requestId: string;
|
||||
},
|
||||
): Promise<string[]> {
|
||||
let targetPath = options.targetPath;
|
||||
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
if (resolvedWorktree) {
|
||||
targetPath = resolvedWorktree.worktreePath;
|
||||
}
|
||||
|
||||
const removedAgents = new Set<string>();
|
||||
const affectedWorkspaceCwds = new Set<string>([targetPath]);
|
||||
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]);
|
||||
const agents = dependencies.agentManager.listAgents();
|
||||
for (const agent of agents) {
|
||||
if (!dependencies.isPathWithinRoot(targetPath, agent.cwd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
removedAgents.add(agent.id);
|
||||
affectedWorkspaceCwds.add(agent.cwd);
|
||||
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
|
||||
try {
|
||||
await dependencies.agentManager.closeAgent(agent.id);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
try {
|
||||
await dependencies.agentStorage.remove(agent.id);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
const registryRecords = await dependencies.agentStorage.list();
|
||||
for (const record of registryRecords) {
|
||||
if (!dependencies.isPathWithinRoot(targetPath, record.cwd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
removedAgents.add(record.id);
|
||||
affectedWorkspaceCwds.add(record.cwd);
|
||||
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd));
|
||||
try {
|
||||
await dependencies.agentStorage.remove(record.id);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
await dependencies.killTerminalsUnderPath(targetPath);
|
||||
|
||||
await deletePaseoWorktree({
|
||||
cwd: options.repoRoot,
|
||||
worktreePath: targetPath,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
|
||||
for (const workspaceId of affectedWorkspaceIds) {
|
||||
await dependencies.archiveWorkspaceRecord(workspaceId);
|
||||
}
|
||||
|
||||
for (const agentId of removedAgents) {
|
||||
dependencies.emit({
|
||||
type: "agent_deleted",
|
||||
payload: {
|
||||
agentId,
|
||||
requestId: options.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await dependencies.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds);
|
||||
|
||||
return Array.from(removedAgents);
|
||||
}
|
||||
|
||||
export async function handlePaseoWorktreeArchiveRequest(
|
||||
dependencies: Omit<ArchivePaseoWorktreeDependencies, "emitWorkspaceUpdatesForCwds"> & {
|
||||
emit: EmitSessionMessage;
|
||||
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
|
||||
},
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
|
||||
): Promise<void> {
|
||||
const { requestId } = msg;
|
||||
let targetPath = msg.worktreePath;
|
||||
let repoRoot = msg.repoRoot ?? null;
|
||||
|
||||
try {
|
||||
if (!targetPath) {
|
||||
if (!repoRoot || !msg.branchName) {
|
||||
throw new Error("worktreePath or repoRoot+branchName is required");
|
||||
}
|
||||
const worktrees = await listPaseoWorktrees({
|
||||
cwd: repoRoot,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
const match = worktrees.find((entry) => entry.branchName === msg.branchName);
|
||||
if (!match) {
|
||||
throw new Error(`Paseo worktree not found for branch ${msg.branchName}`);
|
||||
}
|
||||
targetPath = match.path;
|
||||
}
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
if (!ownership.allowed) {
|
||||
dependencies.emit({
|
||||
type: "paseo_worktree_archive_response",
|
||||
payload: {
|
||||
success: false,
|
||||
removedAgents: [],
|
||||
error: {
|
||||
code: "NOT_ALLOWED",
|
||||
message: "Worktree is not a Paseo-owned worktree",
|
||||
},
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
repoRoot = ownership.repoRoot ?? repoRoot ?? null;
|
||||
if (!repoRoot) {
|
||||
throw new Error("Unable to resolve repo root for worktree");
|
||||
}
|
||||
|
||||
const removedAgents = await archivePaseoWorktree(dependencies, {
|
||||
targetPath,
|
||||
repoRoot,
|
||||
requestId,
|
||||
});
|
||||
|
||||
dependencies.emit({
|
||||
type: "paseo_worktree_archive_response",
|
||||
payload: {
|
||||
success: true,
|
||||
removedAgents,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
dependencies.emit({
|
||||
type: "paseo_worktree_archive_response",
|
||||
payload: {
|
||||
success: false,
|
||||
removedAgents: [],
|
||||
error: toCheckoutError(error),
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerPendingWorktreeWorkspace(
|
||||
dependencies: RegisterPendingWorktreeWorkspaceDependencies,
|
||||
options: {
|
||||
repoRoot: string;
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
},
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
|
||||
const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot);
|
||||
const placement: ProjectPlacementPayload = {
|
||||
...basePlacement,
|
||||
checkout: {
|
||||
cwd: workspaceId,
|
||||
isGit: true,
|
||||
currentBranch: options.branchName,
|
||||
remoteUrl: basePlacement.checkout.remoteUrl,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: options.repoRoot,
|
||||
},
|
||||
};
|
||||
const now = new Date().toISOString();
|
||||
const existingWorkspace = await dependencies.workspaceRegistry.get(workspaceId);
|
||||
const existingProject = await dependencies.projectRegistry.get(placement.projectKey);
|
||||
const nextProjectRecord = dependencies.buildPersistedProjectRecord({
|
||||
workspaceId,
|
||||
placement,
|
||||
createdAt: existingProject?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
placement,
|
||||
createdAt: existingWorkspace?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await dependencies.projectRegistry.upsert(nextProjectRecord);
|
||||
await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord);
|
||||
await dependencies.syncWorkspaceGitWatchTarget(workspaceId, {
|
||||
isGit: placement.checkout.isGit,
|
||||
});
|
||||
|
||||
if (
|
||||
existingWorkspace &&
|
||||
!existingWorkspace.archivedAt &&
|
||||
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
|
||||
) {
|
||||
await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
|
||||
}
|
||||
|
||||
return nextWorkspaceRecord;
|
||||
}
|
||||
|
||||
export async function handleCreatePaseoWorktreeRequest(
|
||||
dependencies: HandleCreatePaseoWorktreeRequestDependencies,
|
||||
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const checkout = await getCheckoutStatusLite(request.cwd, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
if (!checkout.isGit) {
|
||||
throw new Error("Create worktree requires a git repository");
|
||||
}
|
||||
|
||||
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd;
|
||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
||||
if (!baseBranch) {
|
||||
throw new Error("Unable to resolve repository default branch");
|
||||
}
|
||||
|
||||
const normalizedSlug = request.worktreeSlug ? slugify(request.worktreeSlug) : uuidv4();
|
||||
const validation = validateBranchSlug(normalizedSlug);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid worktree name: ${validation.error}`);
|
||||
}
|
||||
|
||||
await computeWorktreePath(repoRoot, normalizedSlug, dependencies.paseoHome);
|
||||
const createdWorktree = await createAgentWorktree({
|
||||
cwd: repoRoot,
|
||||
branchName: normalizedSlug,
|
||||
baseBranch,
|
||||
worktreeSlug: normalizedSlug,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
const workspace = await dependencies.registerPendingWorktreeWorkspace({
|
||||
repoRoot,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
branchName: createdWorktree.worktree.branchName,
|
||||
});
|
||||
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
|
||||
dependencies.emit({
|
||||
type: "create_paseo_worktree_response",
|
||||
payload: {
|
||||
workspace: descriptor,
|
||||
error: null,
|
||||
setupTerminalId: null,
|
||||
requestId: request.requestId,
|
||||
},
|
||||
});
|
||||
|
||||
void dependencies.createPaseoWorktreeInBackground({
|
||||
requestCwd: request.cwd,
|
||||
repoRoot,
|
||||
worktree: createdWorktree.worktree,
|
||||
shouldBootstrap: createdWorktree.shouldBootstrap,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create worktree";
|
||||
dependencies.sessionLogger.error(
|
||||
{ err: error, cwd: request.cwd, worktreeSlug: request.worktreeSlug },
|
||||
"Failed to create worktree",
|
||||
);
|
||||
dependencies.emit({
|
||||
type: "create_paseo_worktree_response",
|
||||
payload: {
|
||||
workspace: null,
|
||||
error: message,
|
||||
setupTerminalId: null,
|
||||
requestId: request.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleWorkspaceSetupStatusRequest(
|
||||
dependencies: HandleWorkspaceSetupStatusRequestDependencies,
|
||||
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
|
||||
): Promise<void> {
|
||||
const workspaceId = normalizePersistedWorkspaceId(request.workspaceId);
|
||||
dependencies.emit({
|
||||
type: "workspace_setup_status_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspaceId,
|
||||
snapshot: dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPaseoWorktreeInBackground(
|
||||
dependencies: CreatePaseoWorktreeInBackgroundDependencies,
|
||||
options: {
|
||||
requestCwd: string;
|
||||
repoRoot: string;
|
||||
worktree: WorktreeConfig;
|
||||
shouldBootstrap: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
let worktree: WorktreeConfig = options.worktree;
|
||||
let setupResults: WorktreeSetupCommandResult[] = [];
|
||||
let setupStarted = false;
|
||||
const progressAccumulator = createWorktreeSetupProgressAccumulator();
|
||||
const workspaceId = normalizePersistedWorkspaceId(worktree.worktreePath);
|
||||
|
||||
const emitSetupProgress = (status: "running" | "completed" | "failed", error: string | null) => {
|
||||
const snapshot: WorkspaceSetupSnapshot = {
|
||||
status,
|
||||
detail: buildWorktreeSetupDetail({
|
||||
worktree,
|
||||
results:
|
||||
status === "running" ? getWorktreeSetupProgressResults(progressAccumulator) : setupResults,
|
||||
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||
}),
|
||||
error,
|
||||
};
|
||||
dependencies.cacheWorkspaceSetupSnapshot(workspaceId, snapshot);
|
||||
dependencies.emit({
|
||||
type: "workspace_setup_progress",
|
||||
payload: {
|
||||
workspaceId,
|
||||
...snapshot,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
try {
|
||||
emitSetupProgress("running", null);
|
||||
|
||||
if (!options.shouldBootstrap) {
|
||||
emitSetupProgress("completed", null);
|
||||
} else {
|
||||
const setupCommands = getWorktreeSetupCommands(worktree.worktreePath);
|
||||
if (setupCommands.length === 0) {
|
||||
setupStarted = true;
|
||||
emitSetupProgress("completed", null);
|
||||
} else {
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
repoRootPath: options.repoRoot,
|
||||
});
|
||||
dependencies.terminalManager?.registerCwdEnv({
|
||||
cwd: worktree.worktreePath,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
setupStarted = true;
|
||||
setupResults = await runWorktreeSetupCommands({
|
||||
worktreePath: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
cleanupOnFailure: false,
|
||||
repoRootPath: options.repoRoot,
|
||||
runtimeEnv,
|
||||
onEvent: (event) => {
|
||||
applyWorktreeSetupProgressEvent(progressAccumulator, event);
|
||||
emitSetupProgress("running", null);
|
||||
},
|
||||
});
|
||||
emitSetupProgress("completed", null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WorktreeSetupError) {
|
||||
setupResults = error.results;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
emitSetupProgress("failed", message);
|
||||
|
||||
if (!setupStarted) {
|
||||
await dependencies.archiveWorkspaceRecord(normalizePersistedWorkspaceId(worktree.worktreePath));
|
||||
}
|
||||
|
||||
dependencies.sessionLogger.error(
|
||||
{
|
||||
err: error,
|
||||
cwd: options.requestCwd,
|
||||
repoRoot: options.repoRoot,
|
||||
worktreeSlug: worktree.branchName,
|
||||
worktreePath: worktree.worktreePath,
|
||||
setupStarted,
|
||||
},
|
||||
"Background worktree creation failed",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dependencies.terminalManager || !dependencies.serviceRouteStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await spawnWorktreeServices({
|
||||
repoRoot: worktree.worktreePath,
|
||||
workspaceId: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
daemonPort: dependencies.daemonPort,
|
||||
routeStore: dependencies.serviceRouteStore,
|
||||
terminalManager: dependencies.terminalManager,
|
||||
logger: dependencies.sessionLogger,
|
||||
});
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger.error(
|
||||
{
|
||||
err: error,
|
||||
cwd: options.requestCwd,
|
||||
repoRoot: options.repoRoot,
|
||||
worktreeSlug: worktree.branchName,
|
||||
worktreePath: worktree.worktreePath,
|
||||
},
|
||||
"Failed to spawn worktree services after workspace setup completed",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function killTerminalsUnderPath(
|
||||
dependencies: KillTerminalsUnderPathDependencies,
|
||||
rootPath: string,
|
||||
): Promise<void> {
|
||||
if (!dependencies.terminalManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanupErrors: Array<{ cwd: string; message: string }> = [];
|
||||
const terminalDirectories = [...dependencies.terminalManager.listDirectories()];
|
||||
for (const terminalCwd of terminalDirectories) {
|
||||
if (!dependencies.isPathWithinRoot(rootPath, terminalCwd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const terminals = await dependencies.terminalManager.getTerminals(terminalCwd);
|
||||
for (const terminal of [...terminals]) {
|
||||
dependencies.killTrackedTerminal(terminal.id, { emitExit: true });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
cleanupErrors.push({ cwd: terminalCwd, message });
|
||||
dependencies.sessionLogger.warn(
|
||||
{ err: error, cwd: terminalCwd },
|
||||
"Failed to clean up worktree terminals during archive",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanupErrors.length > 0) {
|
||||
const details = cleanupErrors.map((entry) => `${entry.cwd}: ${entry.message}`).join("; ");
|
||||
throw new Error(`Failed to clean up worktree terminals during archive (${details})`);
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,27 @@ const NonNullUnknownSchema = z.union([
|
||||
z.object({}).passthrough(),
|
||||
]);
|
||||
|
||||
const WorktreeSetupCommandSnapshotSchema = z.object({
|
||||
index: z.number().int().positive(),
|
||||
command: z.string(),
|
||||
cwd: z.string(),
|
||||
log: z.string(),
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
exitCode: z.number().nullable(),
|
||||
durationMs: z.number().nonnegative().optional(),
|
||||
});
|
||||
|
||||
const WorktreeSetupDetailPayloadSchema = z.object({
|
||||
type: z.literal("worktree_setup"),
|
||||
worktreePath: z.string(),
|
||||
branchName: z.string(),
|
||||
log: z.string(),
|
||||
commands: z.array(WorktreeSetupCommandSnapshotSchema),
|
||||
truncated: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUnion("type", [
|
||||
WorktreeSetupDetailPayloadSchema,
|
||||
z.object({
|
||||
type: z.literal("shell"),
|
||||
command: z.string(),
|
||||
@@ -254,23 +274,6 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
|
||||
bytes: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("worktree_setup"),
|
||||
worktreePath: z.string(),
|
||||
branchName: z.string(),
|
||||
log: z.string(),
|
||||
commands: z.array(
|
||||
z.object({
|
||||
index: z.number().int().positive(),
|
||||
command: z.string(),
|
||||
cwd: z.string(),
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
exitCode: z.number().nullable(),
|
||||
durationMs: z.number().nonnegative().optional(),
|
||||
}),
|
||||
),
|
||||
truncated: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("sub_agent"),
|
||||
subAgentType: z.string().optional(),
|
||||
@@ -726,17 +729,6 @@ export const ListAvailableProvidersRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SpeechModelsListRequestSchema = z.object({
|
||||
type: z.literal("speech_models_list_request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SpeechModelsDownloadRequestSchema = z.object({
|
||||
type: z.literal("speech_models_download_request"),
|
||||
modelIds: z.array(z.string()).optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ResumeAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("resume_agent_request"),
|
||||
handle: AgentPersistenceHandleSchema,
|
||||
@@ -994,6 +986,12 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupStatusRequestSchema = z.object({
|
||||
type: z.literal("workspace_setup_status_request"),
|
||||
workspaceId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const OpenProjectRequestSchema = z.object({
|
||||
type: z.literal("open_project_request"),
|
||||
cwd: z.string(),
|
||||
@@ -1127,7 +1125,7 @@ export const RegisterPushTokenMessageSchema = z.object({
|
||||
|
||||
export const ListTerminalsRequestSchema = z.object({
|
||||
type: z.literal("list_terminals_request"),
|
||||
cwd: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
@@ -1183,6 +1181,15 @@ export const KillTerminalRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const CaptureTerminalRequestSchema = z.object({
|
||||
type: z.literal("capture_terminal_request"),
|
||||
terminalId: z.string(),
|
||||
start: z.number().int().optional(),
|
||||
end: z.number().int().optional(),
|
||||
stripAnsi: z.boolean().default(true),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
VoiceAudioChunkMessageSchema,
|
||||
AbortRequestMessageSchema,
|
||||
@@ -1203,8 +1210,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CreateAgentRequestMessageSchema,
|
||||
ListProviderModelsRequestMessageSchema,
|
||||
ListAvailableProvidersRequestMessageSchema,
|
||||
SpeechModelsListRequestSchema,
|
||||
SpeechModelsDownloadRequestSchema,
|
||||
ResumeAgentRequestMessageSchema,
|
||||
RefreshAgentRequestMessageSchema,
|
||||
CancelAgentRequestMessageSchema,
|
||||
@@ -1230,6 +1235,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PaseoWorktreeListRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
CreatePaseoWorktreeRequestSchema,
|
||||
WorkspaceSetupStatusRequestSchema,
|
||||
OpenProjectRequestSchema,
|
||||
ArchiveWorkspaceRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
@@ -1248,6 +1254,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
UnsubscribeTerminalRequestSchema,
|
||||
TerminalInputSchema,
|
||||
KillTerminalRequestSchema,
|
||||
CaptureTerminalRequestSchema,
|
||||
ChatCreateRequestSchema,
|
||||
ChatListRequestSchema,
|
||||
ChatInspectRequestSchema,
|
||||
@@ -1574,6 +1581,14 @@ export const ProjectPlacementPayloadSchema = z.object({
|
||||
checkout: ProjectCheckoutLitePayloadSchema,
|
||||
});
|
||||
|
||||
export const WorkspaceServicePayloadSchema = z.object({
|
||||
serviceName: z.string(),
|
||||
hostname: z.string(),
|
||||
port: z.number().int().positive(),
|
||||
url: z.string().nullable(),
|
||||
status: z.enum(["running", "stopped"]),
|
||||
});
|
||||
|
||||
export const WorkspaceDescriptorPayloadSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
@@ -1591,6 +1606,7 @@ export const WorkspaceDescriptorPayloadSchema = z.object({
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
services: z.array(WorkspaceServicePayloadSchema).default([]),
|
||||
});
|
||||
|
||||
export const AgentUpdateMessageSchema = z.object({
|
||||
@@ -1683,6 +1699,39 @@ export const WorkspaceUpdateMessageSchema = z.object({
|
||||
]),
|
||||
});
|
||||
|
||||
export const ServiceStatusUpdateMessageSchema = z.object({
|
||||
type: z.literal("service_status_update"),
|
||||
payload: z.object({
|
||||
workspaceId: z.string(),
|
||||
services: z.array(WorkspaceServicePayloadSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupProgressMessageSchema = z.object({
|
||||
type: z.literal("workspace_setup_progress"),
|
||||
payload: z.object({
|
||||
workspaceId: z.string(),
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
detail: WorktreeSetupDetailPayloadSchema,
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupSnapshotSchema = z.object({
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
detail: WorktreeSetupDetailPayloadSchema,
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupStatusResponseMessageSchema = z.object({
|
||||
type: z.literal("workspace_setup_status_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
snapshot: WorkspaceSetupSnapshotSchema.nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const OpenProjectResponseMessageSchema = z.object({
|
||||
type: z.literal("open_project_response"),
|
||||
payload: z.object({
|
||||
@@ -2104,34 +2153,6 @@ export const ListAvailableProvidersResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const SpeechModelsListResponseSchema = z.object({
|
||||
type: z.literal("speech_models_list_response"),
|
||||
payload: z.object({
|
||||
modelsDir: z.string(),
|
||||
models: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
kind: z.string(),
|
||||
description: z.string(),
|
||||
modelDir: z.string(),
|
||||
isDownloaded: z.boolean(),
|
||||
missingFiles: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SpeechModelsDownloadResponseSchema = z.object({
|
||||
type: z.literal("speech_models_download_response"),
|
||||
payload: z.object({
|
||||
modelsDir: z.string(),
|
||||
downloadedModelIds: z.array(z.string()),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const AgentSlashCommandSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
@@ -2199,7 +2220,7 @@ export const TerminalStateSchema = z
|
||||
export const ListTerminalsResponseSchema = z.object({
|
||||
type: z.literal("list_terminals_response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
terminals: z.array(TerminalInfoSchema.omit({ cwd: true })),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
@@ -2248,6 +2269,16 @@ export const KillTerminalResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const CaptureTerminalResponseSchema = z.object({
|
||||
type: z.literal("capture_terminal_response"),
|
||||
payload: z.object({
|
||||
terminalId: z.string(),
|
||||
lines: z.array(z.string()),
|
||||
totalLines: z.number().int().nonnegative(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const TerminalStreamExitSchema = z.object({
|
||||
type: z.literal("terminal_stream_exit"),
|
||||
payload: z.object({
|
||||
@@ -2272,6 +2303,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ArtifactMessageSchema,
|
||||
AgentUpdateMessageSchema,
|
||||
WorkspaceUpdateMessageSchema,
|
||||
ServiceStatusUpdateMessageSchema,
|
||||
WorkspaceSetupProgressMessageSchema,
|
||||
WorkspaceSetupStatusResponseMessageSchema,
|
||||
AgentStreamMessageSchema,
|
||||
AgentStatusMessageSchema,
|
||||
FetchAgentsResponseMessageSchema,
|
||||
@@ -2311,14 +2345,13 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FileDownloadTokenResponseSchema,
|
||||
ListProviderModelsResponseMessageSchema,
|
||||
ListAvailableProvidersResponseSchema,
|
||||
SpeechModelsListResponseSchema,
|
||||
SpeechModelsDownloadResponseSchema,
|
||||
ListCommandsResponseSchema,
|
||||
ListTerminalsResponseSchema,
|
||||
TerminalsChangedSchema,
|
||||
CreateTerminalResponseSchema,
|
||||
SubscribeTerminalResponseSchema,
|
||||
KillTerminalResponseSchema,
|
||||
CaptureTerminalResponseSchema,
|
||||
TerminalStreamExitSchema,
|
||||
ChatCreateResponseSchema,
|
||||
ChatListResponseSchema,
|
||||
@@ -2356,14 +2389,21 @@ export type ServerInfoStatusPayload = z.infer<typeof ServerInfoStatusPayloadSche
|
||||
export type RpcErrorMessage = z.infer<typeof RpcErrorMessageSchema>;
|
||||
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
|
||||
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
|
||||
export type WorkspaceSetupProgressMessage = z.infer<typeof WorkspaceSetupProgressMessageSchema>;
|
||||
export type WorkspaceSetupSnapshot = z.infer<typeof WorkspaceSetupSnapshotSchema>;
|
||||
export type WorkspaceSetupStatusResponseMessage = z.infer<
|
||||
typeof WorkspaceSetupStatusResponseMessageSchema
|
||||
>;
|
||||
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
|
||||
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
|
||||
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>;
|
||||
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
|
||||
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
|
||||
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>;
|
||||
export type WorkspaceServicePayload = z.infer<typeof WorkspaceServicePayloadSchema>;
|
||||
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
|
||||
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
||||
export type ServiceStatusUpdateMessage = z.infer<typeof ServiceStatusUpdateMessageSchema>;
|
||||
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
|
||||
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
|
||||
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
|
||||
@@ -2381,8 +2421,6 @@ export type ListProviderModelsResponseMessage = z.infer<
|
||||
typeof ListProviderModelsResponseMessageSchema
|
||||
>;
|
||||
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
|
||||
export type SpeechModelsListResponse = z.infer<typeof SpeechModelsListResponseSchema>;
|
||||
export type SpeechModelsDownloadResponse = z.infer<typeof SpeechModelsDownloadResponseSchema>;
|
||||
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
|
||||
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
|
||||
export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>;
|
||||
@@ -2443,8 +2481,6 @@ export type LoopListRequest = z.infer<typeof LoopListRequestSchema>;
|
||||
export type LoopInspectRequest = z.infer<typeof LoopInspectRequestSchema>;
|
||||
export type LoopLogsRequest = z.infer<typeof LoopLogsRequestSchema>;
|
||||
export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>;
|
||||
export type SpeechModelsListRequestMessage = z.infer<typeof SpeechModelsListRequestSchema>;
|
||||
export type SpeechModelsDownloadRequestMessage = z.infer<typeof SpeechModelsDownloadRequestSchema>;
|
||||
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
|
||||
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
|
||||
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
|
||||
@@ -2480,6 +2516,7 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
|
||||
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
|
||||
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
|
||||
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
|
||||
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
|
||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||
@@ -2515,6 +2552,8 @@ export type TerminalCursor = z.infer<typeof TerminalCursorSchema>;
|
||||
export type TerminalState = z.infer<typeof TerminalStateSchema>;
|
||||
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
|
||||
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>;
|
||||
export type CaptureTerminalRequest = z.infer<typeof CaptureTerminalRequestSchema>;
|
||||
export type CaptureTerminalResponse = z.infer<typeof CaptureTerminalResponseSchema>;
|
||||
export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -44,10 +44,138 @@ describe("workspace message schemas", () => {
|
||||
name: "",
|
||||
status: "not-a-bucket",
|
||||
activityAt: null,
|
||||
services: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("parses workspace descriptors with services", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: "/repo",
|
||||
projectId: "/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
name: "repo",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
services: [
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3000,
|
||||
url: "http://web.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_update");
|
||||
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
|
||||
throw new Error("Expected workspace_update upsert payload");
|
||||
}
|
||||
expect(parsed.payload.workspace.services).toEqual([
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3000,
|
||||
url: "http://web.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses service_status_update payload", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: "/repo",
|
||||
services: [
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3000,
|
||||
url: null,
|
||||
status: "stopped",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("service_status_update");
|
||||
expect(parsed.payload.workspaceId).toBe("/repo");
|
||||
});
|
||||
|
||||
test("parses workspace_setup_progress payload", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_setup_progress",
|
||||
payload: {
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/repo/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
log: "done",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/repo/.paseo/worktrees/feature-a",
|
||||
log: "done",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
durationMs: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_setup_progress");
|
||||
});
|
||||
|
||||
test("parses workspace_setup_status_request", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "workspace_setup_status_request",
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
requestId: "req-status",
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_setup_status_request");
|
||||
});
|
||||
|
||||
test("parses workspace_setup_status_response payload", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_setup_status_response",
|
||||
payload: {
|
||||
requestId: "req-status",
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
snapshot: {
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/repo/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
log: "done",
|
||||
commands: [],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_setup_status_response");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,7 @@ describe("shared tool-call display mapping", () => {
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||
log: "==> [1/1] Running: npm install\n",
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { randomUUID } from "crypto";
|
||||
import { chmodSync, existsSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import type { TerminalCell, TerminalState } from "../shared/messages.js";
|
||||
|
||||
const { Terminal } = xterm;
|
||||
@@ -40,6 +41,17 @@ export interface CreateTerminalOptions {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface CaptureTerminalLinesOptions {
|
||||
start?: number;
|
||||
end?: number;
|
||||
stripAnsi?: boolean;
|
||||
}
|
||||
|
||||
export interface CaptureTerminalLinesResult {
|
||||
lines: string[];
|
||||
totalLines: number;
|
||||
}
|
||||
|
||||
type EnsureNodePtySpawnHelperExecutableOptions = {
|
||||
packageRoot?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
@@ -230,6 +242,60 @@ function extractCursorState(terminal: TerminalType): TerminalState["cursor"] {
|
||||
};
|
||||
}
|
||||
|
||||
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
|
||||
const text = cells.map((cell) => cell.char).join("").trimEnd();
|
||||
return options.stripAnsi ? stripAnsi(text) : text;
|
||||
}
|
||||
|
||||
function resolveCaptureLineIndex(
|
||||
lineNumber: number | undefined,
|
||||
totalLines: number,
|
||||
fallback: "start" | "end",
|
||||
): number {
|
||||
if (totalLines === 0) {
|
||||
return fallback === "start" ? 0 : -1;
|
||||
}
|
||||
|
||||
const defaultIndex = fallback === "start" ? 0 : totalLines - 1;
|
||||
if (typeof lineNumber !== "number") {
|
||||
return defaultIndex;
|
||||
}
|
||||
|
||||
const resolvedIndex = lineNumber < 0 ? totalLines + lineNumber : lineNumber;
|
||||
if (resolvedIndex < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (resolvedIndex >= totalLines) {
|
||||
return totalLines - 1;
|
||||
}
|
||||
return resolvedIndex;
|
||||
}
|
||||
|
||||
export function captureTerminalLines(
|
||||
terminal: TerminalSession,
|
||||
options: CaptureTerminalLinesOptions = {},
|
||||
): CaptureTerminalLinesResult {
|
||||
const state = terminal.getState();
|
||||
const allLines = [...state.scrollback, ...state.grid].map((cells) =>
|
||||
cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }),
|
||||
);
|
||||
const totalLines = allLines.length;
|
||||
const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start");
|
||||
const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end");
|
||||
|
||||
if (totalLines === 0 || startIndex > endIndex) {
|
||||
return {
|
||||
lines: [],
|
||||
totalLines,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
lines: allLines.slice(startIndex, endIndex + 1),
|
||||
totalLines,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
|
||||
const {
|
||||
cwd,
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
runWorktreeSetupCommands,
|
||||
slugify,
|
||||
} from "./worktree";
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
import {
|
||||
getPaseoWorktreeMetadataPath,
|
||||
} from "./worktree-metadata.js";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
@@ -527,69 +529,106 @@ describe("paseo worktree manager", () => {
|
||||
expect(remaining.some((worktree) => worktree.path === created.worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("runs destroy commands from paseo.json before deleting a worktree", async () => {
|
||||
it("runs teardown commands from paseo.json before deleting a worktree", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
destroy: [
|
||||
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
teardown: [
|
||||
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
'echo "port=$PASEO_WORKTREE_PORT" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
],
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add destroy commands'", {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execSync(
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown commands'",
|
||||
{
|
||||
cwd: repoDir,
|
||||
},
|
||||
);
|
||||
|
||||
const created = await createWorktree({
|
||||
branchName: "destroy-branch",
|
||||
branchName: "teardown-branch",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "destroy-test",
|
||||
worktreeSlug: "teardown-test",
|
||||
paseoHome,
|
||||
});
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: created.worktreePath,
|
||||
branchName: created.branchName,
|
||||
});
|
||||
|
||||
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
|
||||
const destroyLog = readFileSync(join(repoDir, "destroy.log"), "utf8");
|
||||
expect(destroyLog).toContain(`source=${repoDir}`);
|
||||
expect(destroyLog).toContain(`root_alias=${repoDir}`);
|
||||
expect(destroyLog).toContain(`worktree=${created.worktreePath}`);
|
||||
expect(destroyLog).toContain("branch=destroy-branch");
|
||||
const teardownLog = readFileSync(join(repoDir, "teardown.log"), "utf8");
|
||||
expect(teardownLog).toContain(`source=${repoDir}`);
|
||||
expect(teardownLog).toContain(`root_alias=${repoDir}`);
|
||||
expect(teardownLog).toContain(`worktree=${created.worktreePath}`);
|
||||
expect(teardownLog).toContain("branch=teardown-branch");
|
||||
expect(teardownLog).toContain(`port=${runtimeEnv.PASEO_WORKTREE_PORT}`);
|
||||
});
|
||||
|
||||
it("does not remove worktree when a destroy command fails", async () => {
|
||||
it("omits PASEO_WORKTREE_PORT from teardown env when runtime metadata is missing", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
destroy: [
|
||||
'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy-start.log"',
|
||||
teardown: [
|
||||
'echo "port=${PASEO_WORKTREE_PORT-unset}" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-port.log"',
|
||||
],
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync(
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown port logging'",
|
||||
{ cwd: repoDir },
|
||||
);
|
||||
|
||||
const created = await createWorktree({
|
||||
branchName: "teardown-port-missing-branch",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "teardown-port-missing-test",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
|
||||
|
||||
expect(readFileSync(join(repoDir, "teardown-port.log"), "utf8").trim()).toBe("port=unset");
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not remove worktree when a teardown command fails", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
teardown: [
|
||||
'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-start.log"',
|
||||
"echo boom 1>&2; exit 9",
|
||||
],
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync(
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add failing destroy commands'",
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add failing teardown commands'",
|
||||
{ cwd: repoDir },
|
||||
);
|
||||
|
||||
const created = await createWorktree({
|
||||
branchName: "destroy-failure-branch",
|
||||
branchName: "teardown-failure-branch",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "destroy-failure-test",
|
||||
worktreeSlug: "teardown-failure-test",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
await expect(
|
||||
deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }),
|
||||
).rejects.toThrow("Worktree destroy command failed");
|
||||
).rejects.toThrow("Worktree teardown command failed");
|
||||
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
expect(existsSync(join(repoDir, "destroy-start.log"))).toBe(true);
|
||||
expect(existsSync(join(repoDir, "teardown-start.log"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } f
|
||||
import { join, basename, dirname, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
import { createHash } from "node:crypto";
|
||||
import * as pty from "node-pty";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import {
|
||||
normalizeBaseRefName,
|
||||
readPaseoWorktreeMetadata,
|
||||
@@ -13,13 +15,15 @@ import {
|
||||
writePaseoWorktreeRuntimeMetadata,
|
||||
} from "./worktree-metadata.js";
|
||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
|
||||
|
||||
interface PaseoConfig {
|
||||
worktree?: {
|
||||
setup?: string[];
|
||||
destroy?: string[];
|
||||
teardown?: string[];
|
||||
terminals?: WorktreeTerminalConfig[];
|
||||
};
|
||||
services?: Record<string, ServiceConfig>;
|
||||
}
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -84,6 +88,11 @@ export interface WorktreeTerminalConfig {
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface ServiceConfig {
|
||||
command: string;
|
||||
port?: number; // explicit port override, otherwise auto-assigned
|
||||
}
|
||||
|
||||
export class WorktreeSetupError extends Error {
|
||||
readonly results: WorktreeSetupCommandResult[];
|
||||
|
||||
@@ -94,14 +103,14 @@ export class WorktreeSetupError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type WorktreeDestroyCommandResult = WorktreeSetupCommandResult;
|
||||
export type WorktreeTeardownCommandResult = WorktreeSetupCommandResult;
|
||||
|
||||
export class WorktreeDestroyError extends Error {
|
||||
readonly results: WorktreeDestroyCommandResult[];
|
||||
export class WorktreeTeardownError extends Error {
|
||||
readonly results: WorktreeTeardownCommandResult[];
|
||||
|
||||
constructor(message: string, results: WorktreeDestroyCommandResult[]) {
|
||||
constructor(message: string, results: WorktreeTeardownCommandResult[]) {
|
||||
super(message);
|
||||
this.name = "WorktreeDestroyError";
|
||||
this.name = "WorktreeTeardownError";
|
||||
this.results = results;
|
||||
}
|
||||
}
|
||||
@@ -150,13 +159,13 @@ export function getWorktreeSetupCommands(repoRoot: string): string[] {
|
||||
return setupCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
||||
}
|
||||
|
||||
export function getWorktreeDestroyCommands(repoRoot: string): string[] {
|
||||
export function getWorktreeTeardownCommands(repoRoot: string): string[] {
|
||||
const config = readPaseoConfig(repoRoot);
|
||||
const destroyCommands = config?.worktree?.destroy;
|
||||
if (!destroyCommands || destroyCommands.length === 0) {
|
||||
const teardownCommands = config?.worktree?.teardown;
|
||||
if (!teardownCommands || teardownCommands.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return destroyCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
||||
return teardownCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
||||
}
|
||||
|
||||
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
|
||||
@@ -194,6 +203,90 @@ export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConf
|
||||
return specs;
|
||||
}
|
||||
|
||||
export function getServiceConfigs(repoRoot: string): Map<string, ServiceConfig> {
|
||||
const config = readPaseoConfig(repoRoot);
|
||||
const services = config?.services;
|
||||
if (!services || typeof services !== "object") {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const result = new Map<string, ServiceConfig>();
|
||||
for (const [name, entry] of Object.entries(services)) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawCommand = entry.command;
|
||||
if (typeof rawCommand !== "string") {
|
||||
continue;
|
||||
}
|
||||
const command = rawCommand.trim();
|
||||
if (!command) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const serviceConfig: ServiceConfig = { command };
|
||||
|
||||
if (typeof entry.port === "number" && Number.isFinite(entry.port)) {
|
||||
serviceConfig.port = entry.port;
|
||||
}
|
||||
|
||||
result.set(name, serviceConfig);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function processCarriageReturns(text: string): string {
|
||||
if (!text.includes("\r")) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const output: string[] = [];
|
||||
let line: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
const flushLine = () => {
|
||||
output.push(line.join(""));
|
||||
line = [];
|
||||
cursor = 0;
|
||||
};
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
|
||||
if (char === "\r") {
|
||||
if (text[index + 1] === "\n") {
|
||||
flushLine();
|
||||
output.push("\n");
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
cursor = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\n") {
|
||||
flushLine();
|
||||
output.push("\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cursor < line.length) {
|
||||
line[cursor] = char;
|
||||
} else {
|
||||
line.push(char);
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
if (line.length > 0) {
|
||||
output.push(line.join(""));
|
||||
}
|
||||
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
async function execSetupCommand(
|
||||
command: string,
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv },
|
||||
@@ -239,6 +332,27 @@ async function execSetupCommandStreamed(options: {
|
||||
const stderrChunks: string[] = [];
|
||||
let settled = false;
|
||||
|
||||
const emitOutput = (stream: "stdout" | "stderr", chunk: string) => {
|
||||
const text = stripAnsi(chunk);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
if (stream === "stdout") {
|
||||
stdoutChunks.push(text);
|
||||
} else {
|
||||
stderrChunks.push(text);
|
||||
}
|
||||
options.onEvent?.({
|
||||
type: "output",
|
||||
index: options.index,
|
||||
total: options.total,
|
||||
command: options.command,
|
||||
cwd: options.cwd,
|
||||
stream,
|
||||
chunk: text,
|
||||
});
|
||||
};
|
||||
|
||||
const finish = (exitCode: number | null) => {
|
||||
if (settled) {
|
||||
return;
|
||||
@@ -274,48 +388,52 @@ async function execSetupCommandStreamed(options: {
|
||||
cwd: options.cwd,
|
||||
});
|
||||
|
||||
const child = spawn("/bin/bash", ["-lc", options.command], {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
const text = chunk.toString();
|
||||
stdoutChunks.push(text);
|
||||
options.onEvent?.({
|
||||
type: "output",
|
||||
index: options.index,
|
||||
total: options.total,
|
||||
command: options.command,
|
||||
const spawnWithPipes = () => {
|
||||
const child = spawn("/bin/bash", ["-lc", options.command], {
|
||||
cwd: options.cwd,
|
||||
stream: "stdout",
|
||||
chunk: text,
|
||||
env: options.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
const text = chunk.toString();
|
||||
stderrChunks.push(text);
|
||||
options.onEvent?.({
|
||||
type: "output",
|
||||
index: options.index,
|
||||
total: options.total,
|
||||
command: options.command,
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
emitOutput("stdout", chunk.toString());
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
emitOutput("stderr", chunk.toString());
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
emitOutput("stderr", error instanceof Error ? error.message : String(error));
|
||||
finish(null);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
finish(typeof code === "number" ? code : null);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
|
||||
const terminal = pty.spawn("/bin/bash", ["-lc", options.command], {
|
||||
cwd: options.cwd,
|
||||
stream: "stderr",
|
||||
chunk: text,
|
||||
env: options.env,
|
||||
name: "xterm-color",
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
});
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
stderrChunks.push(error instanceof Error ? error.message : String(error));
|
||||
finish(null);
|
||||
});
|
||||
terminal.onData((data) => {
|
||||
emitOutput("stdout", data);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
finish(typeof code === "number" ? code : null);
|
||||
});
|
||||
terminal.onExit(({ exitCode }) => {
|
||||
finish(typeof exitCode === "number" ? exitCode : null);
|
||||
});
|
||||
} catch (error) {
|
||||
emitOutput("stderr", error instanceof Error ? error.message : String(error));
|
||||
spawnWithPipes();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -506,14 +624,14 @@ export async function resolveWorktreeRuntimeEnv(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function runWorktreeDestroyCommands(options: {
|
||||
export async function runWorktreeTeardownCommands(options: {
|
||||
worktreePath: string;
|
||||
branchName?: string;
|
||||
repoRootPath?: string;
|
||||
}): Promise<WorktreeDestroyCommandResult[]> {
|
||||
}): Promise<WorktreeTeardownCommandResult[]> {
|
||||
// Read paseo.json from the worktree (it will have the same content as the source repo)
|
||||
const destroyCommands = getWorktreeDestroyCommands(options.worktreePath);
|
||||
if (destroyCommands.length === 0) {
|
||||
const teardownCommands = getWorktreeTeardownCommands(options.worktreePath);
|
||||
if (teardownCommands.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -521,30 +639,32 @@ export async function runWorktreeDestroyCommands(options: {
|
||||
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
|
||||
const branchName =
|
||||
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
|
||||
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
|
||||
const destroyEnv = {
|
||||
const teardownEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
// Source checkout path is the original git repo root (shared across worktrees), not the
|
||||
// worktree itself. This allows destroy scripts to clean resources using paths from the
|
||||
// source checkout.
|
||||
// worktree itself. This allows lifecycle scripts to copy or clean resources using paths
|
||||
// from the source checkout.
|
||||
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
|
||||
// Backward-compatible alias.
|
||||
PASEO_ROOT_PATH: repoRootPath,
|
||||
PASEO_WORKTREE_PATH: options.worktreePath,
|
||||
PASEO_BRANCH_NAME: branchName,
|
||||
...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}),
|
||||
};
|
||||
|
||||
const results: WorktreeDestroyCommandResult[] = [];
|
||||
for (const cmd of destroyCommands) {
|
||||
const results: WorktreeTeardownCommandResult[] = [];
|
||||
for (const cmd of teardownCommands) {
|
||||
const result = await execSetupCommand(cmd, {
|
||||
cwd: options.worktreePath,
|
||||
env: destroyEnv,
|
||||
env: teardownEnv,
|
||||
});
|
||||
results.push(result);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new WorktreeDestroyError(
|
||||
`Worktree destroy command failed: ${cmd}\n${result.stderr}`.trim(),
|
||||
throw new WorktreeTeardownError(
|
||||
`Worktree teardown command failed: ${cmd}\n${result.stderr}`.trim(),
|
||||
results,
|
||||
);
|
||||
}
|
||||
@@ -884,7 +1004,7 @@ export async function deletePaseoWorktree({
|
||||
throw new Error("Refusing to delete non-Paseo worktree");
|
||||
}
|
||||
|
||||
await runWorktreeDestroyCommands({
|
||||
await runWorktreeTeardownCommands({
|
||||
worktreePath: resolvedWorktree,
|
||||
});
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
<div className="space-y-24">
|
||||
<MultiProviderSection />
|
||||
<SelfHostedSection />
|
||||
<ServiceProxySection />
|
||||
<ShortcutsSection />
|
||||
<LocalVoiceSection />
|
||||
<CLISection />
|
||||
@@ -475,6 +476,52 @@ function SelfHostedSection() {
|
||||
}
|
||||
|
||||
|
||||
function ServiceProxySection() {
|
||||
const workspaces = [
|
||||
{ name: "fix-auth", url: "fix-auth.my-app.localhost" },
|
||||
{ name: "add-search", url: "add-search.my-app.localhost" },
|
||||
{ name: "upgrade-deps", url: "upgrade-deps.my-app.localhost" },
|
||||
];
|
||||
|
||||
return (
|
||||
<FeatureSection
|
||||
title="Forget about ports"
|
||||
description="When agents work in parallel, they all run dev servers. Paseo gives each one a URL based on the branch name, no port conflicts, no guessing."
|
||||
>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.02] overflow-hidden">
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
{/* Project */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40">
|
||||
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-white/60">my-app</span>
|
||||
</div>
|
||||
|
||||
{/* Workspaces indented */}
|
||||
<div className="pl-6 space-y-2">
|
||||
{workspaces.map((ws) => (
|
||||
<div
|
||||
key={ws.name}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||
<span className="text-sm text-white/80">{ws.name}</span>
|
||||
<span className="text-xs text-white/25 font-mono">npm run dev</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-white/30">
|
||||
{ws.url}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutsSection() {
|
||||
const shortcuts = [
|
||||
{ keys: ["⌘", "1-9"], action: "Switch panels" },
|
||||
|
||||
@@ -96,7 +96,7 @@ function Worktrees() {
|
||||
|
||||
{/* paseo.json */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-medium">Setup with paseo.json</h2>
|
||||
<h2 className="text-xl font-medium">Lifecycle hooks with paseo.json</h2>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
When Paseo creates a worktree, it's a fresh checkout. Dependencies aren't installed,
|
||||
config files aren't copied. You can automate setup by creating a{" "}
|
||||
@@ -117,6 +117,20 @@ function Worktrees() {
|
||||
the worktree is created. Use it to install dependencies, copy local config files, or run
|
||||
any other initialization.
|
||||
</p>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
You can also add a <code className="font-mono">teardown</code> array for cleanup commands
|
||||
that run before Paseo removes the worktree directory during archive:
|
||||
</p>
|
||||
<Code>
|
||||
<pre className="text-white/80">{`{
|
||||
"worktree": {
|
||||
"teardown": [
|
||||
"pkill -f \\"vite --port $PASEO_WORKTREE_PORT\\" || true",
|
||||
"rm -rf \\"$PASEO_WORKTREE_PATH/.cache\\""
|
||||
]
|
||||
}
|
||||
}`}</pre>
|
||||
</Code>
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4 text-white/80">
|
||||
<strong>Important:</strong> Setup commands come from{" "}
|
||||
<code className="font-mono">paseo.json</code> in the selected base branch. If you pick{" "}
|
||||
@@ -130,7 +144,7 @@ function Worktrees() {
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-medium">Environment variables</h2>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
Setup commands have access to these environment variables:
|
||||
Setup and teardown commands have access to these environment variables:
|
||||
</p>
|
||||
<ul className="text-white/60 space-y-2 list-disc list-inside">
|
||||
<li>
|
||||
@@ -148,8 +162,8 @@ function Worktrees() {
|
||||
<code className="font-mono">$PASEO_BRANCH_NAME</code> — the branch name created
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">$PASEO_WORKTREE_PORT</code> — an available local port for
|
||||
setup scripts
|
||||
<code className="font-mono">$PASEO_WORKTREE_PORT</code> — the worktree port, when
|
||||
runtime metadata exists
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
@@ -157,6 +171,39 @@ function Worktrees() {
|
||||
shouldn't be in git (like <code className="font-mono">.env</code>) from your source
|
||||
checkout to the worktree.
|
||||
</p>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
<code className="font-mono">$PASEO_WORKTREE_PORT</code> is available when the worktree
|
||||
was bootstrapped with a port. That makes it useful for both starting services in setup
|
||||
and stopping them again in teardown.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Teardown */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-medium">Teardown</h2>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
Teardown runs during archive, before Paseo removes the worktree directory. Use it for
|
||||
cleanup that needs access to the worktree path or its assigned port.
|
||||
</p>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
Common uses include stopping dev servers on{" "}
|
||||
<code className="font-mono">$PASEO_WORKTREE_PORT</code>, deleting generated files, or
|
||||
deregistering services tied to that worktree.
|
||||
</p>
|
||||
<Code>
|
||||
<pre className="text-white/80">{`{
|
||||
"worktree": {
|
||||
"setup": [
|
||||
"npm ci",
|
||||
"nohup npm run dev -- --port $PASEO_WORKTREE_PORT > \\"$PASEO_WORKTREE_PATH/dev.log\\" 2>&1 &"
|
||||
],
|
||||
"teardown": [
|
||||
"pkill -f \\"npm run dev -- --port $PASEO_WORKTREE_PORT\\" || true",
|
||||
"rm -f \\"$PASEO_WORKTREE_PATH/dev.log\\""
|
||||
]
|
||||
}
|
||||
}`}</pre>
|
||||
</Code>
|
||||
</section>
|
||||
|
||||
{/* Common patterns */}
|
||||
|
||||
@@ -154,6 +154,59 @@ paseo chat wait <room> --timeout <duration>
|
||||
paseo chat delete <name-or-id>
|
||||
```
|
||||
|
||||
## Terminal Commands
|
||||
|
||||
Manage workspace terminals: create, inspect, send keystrokes, capture output.
|
||||
|
||||
```bash
|
||||
# List terminals (scoped to current directory by default)
|
||||
paseo terminal ls # Terminals in current directory
|
||||
paseo terminal ls --all # All terminals across all workspaces
|
||||
paseo terminal ls --cwd ~/dev/myapp # Terminals in a specific directory
|
||||
|
||||
# Create a terminal
|
||||
paseo terminal create # In current directory
|
||||
paseo terminal create --cwd ~/dev/myapp # In a specific directory
|
||||
paseo terminal create --name "build-runner" # With a custom name
|
||||
|
||||
# Kill a terminal (supports short ID prefixes and name matching)
|
||||
paseo terminal kill <terminal-id>
|
||||
paseo terminal kill abc123 # Short prefix
|
||||
paseo terminal kill build-runner # By name
|
||||
|
||||
# Capture terminal output as plain text (like tmux capture-pane -p)
|
||||
paseo terminal capture <terminal-id> # Visible pane only, ANSI stripped
|
||||
paseo terminal capture <terminal-id> --scrollback # Full scrollback + visible
|
||||
paseo terminal capture <terminal-id> -S # Short form of --scrollback
|
||||
paseo terminal capture <terminal-id> --start 0 --end 10 # Line range (tmux-style)
|
||||
paseo terminal capture <terminal-id> --start -5 # Last 5 lines
|
||||
paseo terminal capture <terminal-id> --ansi # Preserve ANSI escape codes
|
||||
paseo terminal capture <terminal-id> --json # JSON output with metadata
|
||||
|
||||
# Send keystrokes (like tmux send-keys)
|
||||
paseo terminal send-keys <terminal-id> "ls -la" Enter
|
||||
paseo terminal send-keys <terminal-id> "echo hello" Enter
|
||||
paseo terminal send-keys <terminal-id> C-c # Ctrl+C
|
||||
paseo terminal send-keys <terminal-id> C-d # Ctrl+D
|
||||
paseo terminal send-keys <terminal-id> --literal "raw text" # No special token interpretation
|
||||
```
|
||||
|
||||
**Special key tokens** (interpreted by default, use `--literal` to send raw):
|
||||
`Enter`, `Tab`, `Escape`, `Space`, `BSpace`, `C-c`, `C-d`, `C-z`, `C-l`, `C-a`, `C-e`
|
||||
|
||||
**Common pattern — launch a process and interact with it:**
|
||||
```bash
|
||||
id=$(paseo terminal create --name "my-shell" -q)
|
||||
paseo terminal send-keys "$id" "claude" Enter
|
||||
sleep 5
|
||||
paseo terminal capture "$id" --scrollback # See what happened
|
||||
paseo terminal send-keys "$id" "Hello!" Enter
|
||||
sleep 10
|
||||
paseo terminal capture "$id" --scrollback # See the response
|
||||
paseo terminal send-keys "$id" "/exit" Enter
|
||||
paseo terminal kill "$id"
|
||||
```
|
||||
|
||||
## Available Models
|
||||
|
||||
**Claude (default provider)** — use aliases, CLI resolves to latest version:
|
||||
|
||||
Reference in New Issue
Block a user