mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
Require explicit setup for fork pull requests (#4215)
* feat(workspaces): gate fork repository automation Cross-repository change requests can execute contributor-controlled package hooks even when the setup command comes from the base repository. Persist the checkout provenance and require one explicit workspace-level setup action before repository automation runs. * fix(workspaces): clarify blocked script errors * fix(app): keep long toasts readable * test(server): expect fork repository provenance * test(server): make setup marker portable * test(app): retry transient sidebar layout
This commit is contained in:
@@ -50,6 +50,8 @@ The daemon also supports an optional shared-secret password (set via `auth.passw
|
||||
|
||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||
|
||||
When Paseo checks out a change request from a different repository, it does not run that workspace's `paseo.json` setup, automatic terminals, named scripts, or teardown until you explicitly run setup for that workspace. The decision lasts for the workspace and does not re-prompt after new commits. Same-repository changes, ordinary branches, local workspaces, agent launches, terminals, explicit shell commands, and metadata-generation instructions are outside this gate.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
In Docker, the official image runs the daemon and agents as the non-root
|
||||
|
||||
+20
-19
@@ -439,25 +439,26 @@ workspace together with its owning project.
|
||||
|
||||
Array of workspace records. A workspace is a specific working directory within a project.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
||||
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
|
||||
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
||||
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
||||
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
|
||||
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
|
||||
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
| Field | Type | Description |
|
||||
| ------------------------------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
||||
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
|
||||
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
||||
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
||||
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
|
||||
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
|
||||
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
|
||||
|
||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
openHomeWithProject,
|
||||
openWorkspaceScriptsMenu,
|
||||
seedProjectForWorkspaceSetup,
|
||||
startWorkspaceScriptFromMenu,
|
||||
} from "../support/helpers/workspace-setup";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
const evidenceDir = "/tmp/fork-setup-gate";
|
||||
|
||||
test("fork workspace setup stays blocked until the user runs it", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
mkdirSync(evidenceDir, { recursive: true });
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("fork-setup-gate-", {
|
||||
withRemote: true,
|
||||
originUrl: "https://github.com/paseo-e2e/local-fixture.git",
|
||||
branches: ["pr-branch-2"],
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["node -e \"setTimeout(() => console.log('setup complete'), 1500)\""],
|
||||
},
|
||||
scripts: { dev: { command: 'node -e "setTimeout(() => {}, 30000)"' } },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const localRemote = path.join(repo.path, "remote.git");
|
||||
execFileSync("git", ["update-ref", "refs/pull/2/head", "refs/heads/pr-branch-2"], {
|
||||
cwd: localRemote,
|
||||
});
|
||||
execFileSync(
|
||||
"git",
|
||||
["config", `url.${localRemote}.insteadOf`, "git@github.com:fork-owner/local-fixture.git"],
|
||||
{ cwd: repo.path },
|
||||
);
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"config",
|
||||
"--add",
|
||||
`url.${localRemote}.insteadOf`,
|
||||
"https://github.com/paseo-e2e/local-fixture.git",
|
||||
],
|
||||
{ cwd: repo.path },
|
||||
);
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const result = await client.createWorkspace({
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd: repo.path,
|
||||
action: "checkout",
|
||||
checkoutSource: { kind: "change_request", forge: "github", number: 2 },
|
||||
},
|
||||
});
|
||||
if (!result.workspace || result.error)
|
||||
throw new Error(result.error ?? "Workspace creation failed");
|
||||
expect(result.setupSkippedReason).toContain("Scripts are blocked for PR #2");
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), result.workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await test.step("the blocked setup tab opens with the fork warning", async () => {
|
||||
await expect(page.getByTestId("workspace-setup-panel")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("alert")).toContainText("fork-owner/local-fixture");
|
||||
await expect(page.getByRole("button", { name: "Run setup" })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: path.join(evidenceDir, "blocked-fork-setup.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("starting a script explains why it is blocked", async () => {
|
||||
await openWorkspaceScriptsMenu(page);
|
||||
await startWorkspaceScriptFromMenu(page, "dev");
|
||||
await expect(page.getByText(/Scripts are blocked for PR #2/)).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: path.join(evidenceDir, "blocked-script-error.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.reload();
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page.getByRole("button", { name: "Run setup" })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("running setup replaces the warning with normal progress", async () => {
|
||||
await page.getByRole("button", { name: "Run setup" }).click();
|
||||
await expect(page.getByTestId("workspace-setup-status")).toContainText(/Running|Completed/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.screenshot({
|
||||
path: path.join(evidenceDir, "fork-setup-running.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-setup-status")).toContainText("Completed", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -84,31 +84,33 @@ if (origin === fixtureRemote) {
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "pr list" || command === "pr view") {
|
||||
const isFork = args.includes("2");
|
||||
const pr = {
|
||||
number: 1,
|
||||
number: isFork ? 2 : 1,
|
||||
title: "Use pasted PR as start ref",
|
||||
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||
url: "https://github.com/paseo-e2e/local-fixture/pull/" + (isFork ? 2 : 1),
|
||||
state: "OPEN",
|
||||
body: null,
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "pr-branch-1",
|
||||
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
|
||||
updatedAt: "2026-01-01T00:00:00Z"
|
||||
};
|
||||
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
|
||||
const isFork = args.some((arg) => arg === "number=2");
|
||||
process.stdout.write(JSON.stringify({
|
||||
data: { repository: { pullRequest: {
|
||||
number: 1,
|
||||
number: isFork ? 2 : 1,
|
||||
baseRefName: "main",
|
||||
headRefName: "pr-branch-1",
|
||||
isCrossRepository: false,
|
||||
headRepositoryOwner: { login: "paseo-e2e" },
|
||||
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
|
||||
isCrossRepository: isFork,
|
||||
headRepositoryOwner: { login: isFork ? "fork-owner" : "paseo-e2e" },
|
||||
headRepository: {
|
||||
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
|
||||
url: fixtureRemote
|
||||
sshUrl: isFork ? "git@github.com:fork-owner/local-fixture.git" : "git@github.com:paseo-e2e/local-fixture.git",
|
||||
url: isFork ? "https://github.com/fork-owner/local-fixture" : fixtureRemote
|
||||
}
|
||||
} } }
|
||||
}));
|
||||
|
||||
@@ -38,13 +38,9 @@ function itemLabel(key: SidebarNavKey): string {
|
||||
}[key];
|
||||
}
|
||||
|
||||
async function rowTop(locator: Locator): Promise<number> {
|
||||
await locator.waitFor({ state: "visible" });
|
||||
async function rowTop(locator: Locator): Promise<number | null> {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Expected a laid-out sidebar row to measure");
|
||||
}
|
||||
return box.y;
|
||||
return box?.y ?? null;
|
||||
}
|
||||
|
||||
export async function seedSidebarNavPreferences(
|
||||
@@ -140,6 +136,12 @@ async function expectVerticalOrder(
|
||||
const measured = await Promise.all(
|
||||
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
|
||||
);
|
||||
if (
|
||||
!measured.every(
|
||||
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
|
||||
},
|
||||
{ message: `Expected ${subject} in order`, timeout: 15_000 },
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface ToastApi {
|
||||
type ToastViewportPlacement = "app-shell" | "panel";
|
||||
|
||||
const DEFAULT_DURATION_MS = 2200;
|
||||
const TOAST_MAX_WIDTH = 480;
|
||||
|
||||
export function useToastHost(): {
|
||||
api: ToastApi;
|
||||
@@ -265,24 +266,26 @@ export function ToastViewport({
|
||||
|
||||
const content = (
|
||||
<View style={styles.container} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
onPointerEnter={isWeb ? pauseDismiss : undefined}
|
||||
onPointerLeave={isWeb ? resumeDismiss : undefined}
|
||||
style={toastAnimatedStyle}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
||||
{typeof toast.content === "string" ? (
|
||||
<Text testID="app-toast-message" style={toastMessageStyle}>
|
||||
{toast.content}
|
||||
</Text>
|
||||
) : (
|
||||
<View testID="app-toast-message" style={styles.contentSlot}>
|
||||
{toast.content}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
<View style={styles.widthBoundary} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
onPointerEnter={isWeb ? pauseDismiss : undefined}
|
||||
onPointerLeave={isWeb ? resumeDismiss : undefined}
|
||||
style={toastAnimatedStyle}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
||||
{typeof toast.content === "string" ? (
|
||||
<Text testID="app-toast-message" style={toastMessageStyle}>
|
||||
{toast.content}
|
||||
</Text>
|
||||
) : (
|
||||
<View testID="app-toast-message" style={styles.contentSlot}>
|
||||
{toast.content}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -302,9 +305,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
},
|
||||
widthBoundary: {
|
||||
width: "92%",
|
||||
maxWidth: TOAST_MAX_WIDTH,
|
||||
alignItems: "center",
|
||||
},
|
||||
toast: {
|
||||
alignSelf: "center",
|
||||
maxWidth: "92%",
|
||||
maxWidth: "100%",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
|
||||
@@ -503,14 +503,23 @@ export const ar: TranslationResources = {
|
||||
completed: "اكتمل الإعداد",
|
||||
failed: "فشل الإعداد",
|
||||
workspace: "إعداد Workspace",
|
||||
blocked: "تم حظر الإعداد",
|
||||
},
|
||||
status: {
|
||||
running: "جري",
|
||||
completed: "مكتمل",
|
||||
failed: "فشل",
|
||||
waiting: "في انتظار إخراج الإعداد",
|
||||
blocked: "محظور",
|
||||
},
|
||||
waiting: "جارٍ إعداد مساحة العمل...",
|
||||
blocked: {
|
||||
title: "لم يتم تشغيل الإعداد",
|
||||
description:
|
||||
"يأتي طلب السحب هذا من {{repository}}، وهو مستودع مختلف. قد يشغّل الإعداد والبرامج النصية تعليمات برمجية لم تراجعها.",
|
||||
run: "تشغيل الإعداد",
|
||||
runFailed: "فشل تشغيل إعداد مساحة العمل",
|
||||
},
|
||||
empty: {
|
||||
noCommands: "لم يتم تشغيل أي أوامر إعداد لمساحة العمل هذه.",
|
||||
},
|
||||
|
||||
@@ -501,14 +501,23 @@ export const en = {
|
||||
completed: "Setup completed",
|
||||
failed: "Setup failed",
|
||||
workspace: "Workspace setup",
|
||||
blocked: "Setup blocked",
|
||||
},
|
||||
status: {
|
||||
running: "Running",
|
||||
completed: "Completed",
|
||||
failed: "Failed",
|
||||
waiting: "Waiting for setup output",
|
||||
blocked: "Blocked",
|
||||
},
|
||||
waiting: "Setting up workspace...",
|
||||
blocked: {
|
||||
title: "Setup was not run",
|
||||
description:
|
||||
"This PR comes from {{repository}}, a different repository. Its setup and scripts could run code you have not reviewed.",
|
||||
run: "Run setup",
|
||||
runFailed: "Failed to run workspace setup",
|
||||
},
|
||||
empty: {
|
||||
noCommands: "No setup commands ran for this workspace.",
|
||||
},
|
||||
|
||||
@@ -507,14 +507,23 @@ export const es: TranslationResources = {
|
||||
completed: "Configuración completada",
|
||||
failed: "Error de configuración",
|
||||
workspace: "Configuración deWorkspace",
|
||||
blocked: "Configuración bloqueada",
|
||||
},
|
||||
status: {
|
||||
running: "Correr",
|
||||
completed: "Terminado",
|
||||
failed: "Fallido",
|
||||
waiting: "Esperando el resultado de la configuración",
|
||||
blocked: "Bloqueado",
|
||||
},
|
||||
waiting: "Configurando el espacio de trabajo...",
|
||||
blocked: {
|
||||
title: "No se ejecutó la configuración",
|
||||
description:
|
||||
"Este PR proviene de {{repository}}, un repositorio diferente. Su configuración y scripts podrían ejecutar código que no has revisado.",
|
||||
run: "Ejecutar configuración",
|
||||
runFailed: "No se pudo ejecutar la configuración",
|
||||
},
|
||||
empty: {
|
||||
noCommands: "No se ejecutó ningún comando de configuración para este espacio de trabajo.",
|
||||
},
|
||||
|
||||
@@ -507,14 +507,23 @@ export const fr: TranslationResources = {
|
||||
completed: "Configuration terminée",
|
||||
failed: "Échec de l'installation",
|
||||
workspace: "ConfigurationWorkspace",
|
||||
blocked: "Configuration bloquée",
|
||||
},
|
||||
status: {
|
||||
running: "En cours d'exécution",
|
||||
completed: "Complété",
|
||||
failed: "Échoué",
|
||||
waiting: "En attente de la sortie de configuration",
|
||||
blocked: "Bloqué",
|
||||
},
|
||||
waiting: "Configuration de l'espace de travail...",
|
||||
blocked: {
|
||||
title: "La configuration n’a pas été exécutée",
|
||||
description:
|
||||
"Cette PR provient de {{repository}}, un dépôt différent. Sa configuration et ses scripts pourraient exécuter du code que vous n’avez pas examiné.",
|
||||
run: "Exécuter la configuration",
|
||||
runFailed: "Échec de la configuration de l’espace de travail",
|
||||
},
|
||||
empty: {
|
||||
noCommands: "Aucune commande de configuration n'a été exécutée pour cet espace de travail.",
|
||||
},
|
||||
|
||||
@@ -507,14 +507,23 @@ export const ja: TranslationResources = {
|
||||
completed: "セットアップ完了",
|
||||
failed: "セットアップ失敗",
|
||||
workspace: "ワークスペースセットアップ",
|
||||
blocked: "セットアップはブロックされました",
|
||||
},
|
||||
status: {
|
||||
running: "実行中",
|
||||
completed: "完了",
|
||||
failed: "失敗",
|
||||
waiting: "セットアップ出力を待機中",
|
||||
blocked: "ブロック済み",
|
||||
},
|
||||
waiting: "ワークスペースをセットアップ中...",
|
||||
blocked: {
|
||||
title: "セットアップは実行されませんでした",
|
||||
description:
|
||||
"このPRは別のリポジトリ {{repository}} からのものです。セットアップとスクリプトは未確認のコードを実行する可能性があります。",
|
||||
run: "セットアップを実行",
|
||||
runFailed: "ワークスペースのセットアップに失敗しました",
|
||||
},
|
||||
empty: {
|
||||
noCommands: "このワークスペースでセットアップコマンドは実行されませんでした。",
|
||||
},
|
||||
|
||||
@@ -504,14 +504,23 @@ export const ko: TranslationResources = {
|
||||
completed: "설정 완료",
|
||||
failed: "설정 실패",
|
||||
workspace: "워크스페이스 설정",
|
||||
blocked: "설정 차단됨",
|
||||
},
|
||||
status: {
|
||||
running: "실행 중",
|
||||
completed: "완료됨",
|
||||
failed: "실패함",
|
||||
waiting: "설정 출력을 기다리는 중",
|
||||
blocked: "차단됨",
|
||||
},
|
||||
waiting: "워크스페이스 설정 중...",
|
||||
blocked: {
|
||||
title: "설정이 실행되지 않았습니다",
|
||||
description:
|
||||
"이 PR은 다른 저장소인 {{repository}}에서 왔습니다. 설정과 스크립트가 검토하지 않은 코드를 실행할 수 있습니다.",
|
||||
run: "설정 실행",
|
||||
runFailed: "워크스페이스 설정 실행 실패",
|
||||
},
|
||||
empty: {
|
||||
noCommands: "이 워크스페이스에서 실행된 설정 명령이 없습니다.",
|
||||
},
|
||||
|
||||
@@ -507,14 +507,23 @@ export const ptBR: TranslationResources = {
|
||||
completed: "Configuração concluída",
|
||||
failed: "Falha na configuração",
|
||||
workspace: "Configuração do workspace",
|
||||
blocked: "Configuração bloqueada",
|
||||
},
|
||||
status: {
|
||||
running: "Em execução",
|
||||
completed: "Concluído",
|
||||
failed: "Falhou",
|
||||
waiting: "Aguardando saída da configuração",
|
||||
blocked: "Bloqueado",
|
||||
},
|
||||
waiting: "Configurando workspace...",
|
||||
blocked: {
|
||||
title: "A configuração não foi executada",
|
||||
description:
|
||||
"Este PR vem de {{repository}}, um repositório diferente. A configuração e os scripts podem executar código que você não revisou.",
|
||||
run: "Executar configuração",
|
||||
runFailed: "Falha ao executar a configuração do workspace",
|
||||
},
|
||||
empty: {
|
||||
noCommands: "Nenhum comando de configuração foi executado para este workspace.",
|
||||
},
|
||||
|
||||
@@ -506,14 +506,23 @@ export const ru: TranslationResources = {
|
||||
completed: "Настройка завершена",
|
||||
failed: "Настройка не удалась",
|
||||
workspace: "Настройка рабочего пространства",
|
||||
blocked: "Настройка заблокирована",
|
||||
},
|
||||
status: {
|
||||
running: "Выполняется",
|
||||
completed: "Завершено",
|
||||
failed: "Завершено с ошибкой",
|
||||
waiting: "Ожидание вывода команд настройки",
|
||||
blocked: "Заблокировано",
|
||||
},
|
||||
waiting: "Настройка рабочего пространства...",
|
||||
blocked: {
|
||||
title: "Настройка не запускалась",
|
||||
description:
|
||||
"Этот PR поступил из другого репозитория — {{repository}}. Его настройка и скрипты могут выполнить код, который вы не проверяли.",
|
||||
run: "Запустить настройку",
|
||||
runFailed: "Не удалось запустить настройку рабочего пространства",
|
||||
},
|
||||
empty: {
|
||||
noCommands: "Для этого рабочего пространства не выполнялись команды настройки.",
|
||||
},
|
||||
|
||||
@@ -503,14 +503,23 @@ export const zhCN: TranslationResources = {
|
||||
completed: "Setup 已完成",
|
||||
failed: "Setup 失败",
|
||||
workspace: "Workspace setup",
|
||||
blocked: "设置已阻止",
|
||||
},
|
||||
status: {
|
||||
running: "正在运行",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
waiting: "正在等待 setup 输出",
|
||||
blocked: "已阻止",
|
||||
},
|
||||
waiting: "正在 setup workspace...",
|
||||
blocked: {
|
||||
title: "未运行设置",
|
||||
description:
|
||||
"此 PR 来自另一个仓库 {{repository}}。其设置和脚本可能会运行你尚未审查的代码。",
|
||||
run: "运行设置",
|
||||
runFailed: "运行工作区设置失败",
|
||||
},
|
||||
empty: {
|
||||
noCommands: "此 workspace 没有运行 setup 命令。",
|
||||
},
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
type WorkspaceSetupSnapshot,
|
||||
} from "@/stores/workspace-setup-store";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
|
||||
function useSetupPanelDescriptor(
|
||||
target: { kind: "setup"; workspaceId: string },
|
||||
@@ -49,6 +52,17 @@ function useSetupPanelDescriptor(
|
||||
};
|
||||
}
|
||||
|
||||
if (snapshot?.status === "blocked") {
|
||||
return {
|
||||
label: t("workspace.setup.descriptor.label"),
|
||||
subtitle: t("workspace.setup.descriptor.blocked"),
|
||||
tooltip: t("workspace.setup.descriptor.blocked"),
|
||||
titleState: "ready",
|
||||
icon: CircleAlert,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: t("workspace.setup.descriptor.label"),
|
||||
subtitle: t("workspace.setup.descriptor.workspace"),
|
||||
@@ -100,6 +114,55 @@ type SetupCommand = WorkspaceSetupSnapshot["detail"]["commands"][number];
|
||||
|
||||
const EMPTY_COMMANDS: SetupCommand[] = [];
|
||||
|
||||
function BlockedSetupNotice({
|
||||
serverId,
|
||||
workspaceId,
|
||||
source,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
source: NonNullable<WorkspaceSetupSnapshot["blockedSource"]>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const supportsSetupRun = useHostFeature(serverId, "workspaceSetupRun");
|
||||
const [runError, setRunError] = useState<string | null>(null);
|
||||
const [isRunningSetup, setIsRunningSetup] = useState(false);
|
||||
const runSetup = useCallback(async () => {
|
||||
if (!client) return;
|
||||
setRunError(null);
|
||||
setIsRunningSetup(true);
|
||||
try {
|
||||
const response = await client.runWorkspaceSetup(workspaceId);
|
||||
if (response.error) throw new Error(response.error);
|
||||
} catch (error) {
|
||||
setRunError(error instanceof Error ? error.message : t("workspace.setup.blocked.runFailed"));
|
||||
} finally {
|
||||
setIsRunningSetup(false);
|
||||
}
|
||||
}, [client, t, workspaceId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
variant="warning"
|
||||
title={t("workspace.setup.blocked.title")}
|
||||
description={t("workspace.setup.blocked.description", {
|
||||
repository: source.headRepository,
|
||||
})}
|
||||
testID="workspace-setup-blocked"
|
||||
>
|
||||
{supportsSetupRun ? (
|
||||
<Button accessibilityRole="button" onPress={runSetup} loading={isRunningSetup} size="sm">
|
||||
{t("workspace.setup.blocked.run")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Alert>
|
||||
{runError ? <Alert variant="error" description={runError} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAutoExpandIndex(commands: { index: number; status: string }[]): number | null {
|
||||
const running = commands.find((c) => c.status === "running");
|
||||
if (running) return running.index;
|
||||
@@ -109,11 +172,12 @@ function resolveAutoExpandIndex(commands: { index: number; status: string }[]):
|
||||
|
||||
function resolveSetupStatusLabel(
|
||||
status: string | undefined,
|
||||
labels: Record<CommandStatus | "waiting", string>,
|
||||
labels: Record<CommandStatus | "blocked" | "waiting", string>,
|
||||
): string {
|
||||
if (status === "running") return labels.running;
|
||||
if (status === "completed") return labels.completed;
|
||||
if (status === "failed") return labels.failed;
|
||||
if (status === "blocked") return labels.blocked;
|
||||
return labels.waiting;
|
||||
}
|
||||
|
||||
@@ -151,26 +215,18 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) {
|
||||
return { hasError, hasLog, isExpandable, isAutoExpanded, showDetail, processedLog };
|
||||
}
|
||||
|
||||
function SetupPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, target } = usePaneContext();
|
||||
invariant(target.kind === "setup", "SetupPanel requires setup target");
|
||||
|
||||
function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId,
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, 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)
|
||||
.fetchWorkspaceSetupStatus(workspaceId)
|
||||
.then((response) => {
|
||||
if (response.snapshot) {
|
||||
upsertProgress({
|
||||
@@ -181,33 +237,26 @@ function SetupPanel() {
|
||||
return;
|
||||
})
|
||||
.catch(() => {
|
||||
// Server may not support this yet — ignore
|
||||
// Server may not support this yet — ignore.
|
||||
});
|
||||
}, [client, snapshot, serverId, target.workspaceId, upsertProgress]);
|
||||
}, [client, snapshot, serverId, upsertProgress, workspaceId]);
|
||||
|
||||
const commands = snapshot?.detail.commands ?? EMPTY_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);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function useExpandedSetupCommands() {
|
||||
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));
|
||||
}
|
||||
if (isAutoExpanded) setManuallyCollapsed((current) => new Set(current).add(index));
|
||||
} else {
|
||||
next.add(index);
|
||||
// If the user re-expands, remove from manually collapsed
|
||||
setManuallyCollapsed((mc) => {
|
||||
const updated = new Set(mc);
|
||||
setManuallyCollapsed((current) => {
|
||||
const updated = new Set(current);
|
||||
updated.delete(index);
|
||||
return updated;
|
||||
});
|
||||
@@ -215,6 +264,23 @@ function SetupPanel() {
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
return { expandedIndices, manuallyCollapsed, toggleExpanded };
|
||||
}
|
||||
|
||||
function SetupPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, target } = usePaneContext();
|
||||
invariant(target.kind === "setup", "SetupPanel requires setup target");
|
||||
|
||||
const snapshot = useWorkspaceSetupSnapshot(serverId, target.workspaceId);
|
||||
|
||||
const commands = snapshot?.detail.commands ?? EMPTY_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, manuallyCollapsed, toggleExpanded } = useExpandedSetupCommands();
|
||||
|
||||
const autoExpandIndex = resolveAutoExpandIndex(commands);
|
||||
const statusLabels = useMemo(
|
||||
@@ -222,6 +288,7 @@ function SetupPanel() {
|
||||
running: t("workspace.setup.status.running"),
|
||||
completed: t("workspace.setup.status.completed"),
|
||||
failed: t("workspace.setup.status.failed"),
|
||||
blocked: t("workspace.setup.status.blocked"),
|
||||
waiting: t("workspace.setup.status.waiting"),
|
||||
}),
|
||||
[t],
|
||||
@@ -238,60 +305,97 @@ function SetupPanel() {
|
||||
<Text style={styles.hiddenStatus} testID="workspace-setup-status">
|
||||
{statusLabel}
|
||||
</Text>
|
||||
|
||||
{isWaiting ? (
|
||||
<View style={styles.waitingContainer}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.waitingText}>{t("workspace.setup.waiting")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{!isWaiting && hasNoSetupCommands ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text
|
||||
style={styles.emptyText}
|
||||
accessible
|
||||
accessibilityLabel={t("workspace.setup.accessibility.noCommands")}
|
||||
>
|
||||
{t("workspace.setup.empty.noCommands")}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{!isWaiting && !hasNoSetupCommands ? (
|
||||
<View style={styles.commandList}>
|
||||
{commands.map((command) => {
|
||||
const rowState = buildCommandRowState({
|
||||
command,
|
||||
autoExpandIndex,
|
||||
log,
|
||||
expandedIndices,
|
||||
manuallyCollapsed,
|
||||
snapshotError: snapshot?.error,
|
||||
});
|
||||
|
||||
return (
|
||||
<SetupCommandRow
|
||||
key={`${command.index}:${command.command}`}
|
||||
command={command}
|
||||
showDetail={rowState.showDetail}
|
||||
isAutoExpanded={rowState.isAutoExpanded}
|
||||
isExpandable={rowState.isExpandable}
|
||||
hasLog={rowState.hasLog}
|
||||
hasError={rowState.hasError}
|
||||
processedLog={rowState.processedLog}
|
||||
errorMessage={snapshot?.error ?? null}
|
||||
onToggle={toggleExpanded}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<StandaloneLogView commands={commands} log={log} />
|
||||
<TopLevelSetupError snapshotError={snapshot?.error ?? null} commands={commands} />
|
||||
</View>
|
||||
) : null}
|
||||
<SetupPanelBody
|
||||
serverId={serverId}
|
||||
workspaceId={target.workspaceId}
|
||||
snapshot={snapshot}
|
||||
commands={commands}
|
||||
log={log}
|
||||
isWaiting={isWaiting}
|
||||
hasNoSetupCommands={hasNoSetupCommands}
|
||||
autoExpandIndex={autoExpandIndex}
|
||||
expandedIndices={expandedIndices}
|
||||
manuallyCollapsed={manuallyCollapsed}
|
||||
onToggle={toggleExpanded}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
interface SetupPanelBodyProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
snapshot: WorkspaceSetupSnapshot | null;
|
||||
commands: SetupCommand[];
|
||||
log: string;
|
||||
isWaiting: boolean;
|
||||
hasNoSetupCommands: boolean;
|
||||
autoExpandIndex: number | null;
|
||||
expandedIndices: Set<number>;
|
||||
manuallyCollapsed: Set<number>;
|
||||
onToggle: (index: number, isAutoExpanded: boolean) => void;
|
||||
}
|
||||
|
||||
function SetupPanelBody(props: SetupPanelBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const { snapshot, commands, log } = props;
|
||||
if (snapshot?.status === "blocked" && snapshot.blockedSource) {
|
||||
return (
|
||||
<BlockedSetupNotice
|
||||
serverId={props.serverId}
|
||||
workspaceId={props.workspaceId}
|
||||
source={snapshot.blockedSource}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (props.isWaiting) {
|
||||
return (
|
||||
<View style={styles.waitingContainer}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.waitingText}>{t("workspace.setup.waiting")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (props.hasNoSetupCommands) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text
|
||||
style={styles.emptyText}
|
||||
accessible
|
||||
accessibilityLabel={t("workspace.setup.accessibility.noCommands")}
|
||||
>
|
||||
{t("workspace.setup.empty.noCommands")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.commandList}>
|
||||
{commands.map((command) => {
|
||||
const rowState = buildCommandRowState({
|
||||
command,
|
||||
autoExpandIndex: props.autoExpandIndex,
|
||||
log,
|
||||
expandedIndices: props.expandedIndices,
|
||||
manuallyCollapsed: props.manuallyCollapsed,
|
||||
snapshotError: snapshot?.error,
|
||||
});
|
||||
return (
|
||||
<SetupCommandRow
|
||||
key={`${command.index}:${command.command}`}
|
||||
command={command}
|
||||
{...rowState}
|
||||
errorMessage={snapshot?.error ?? null}
|
||||
onToggle={props.onToggle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<StandaloneLogView commands={commands} log={log} />
|
||||
<TopLevelSetupError snapshotError={snapshot?.error ?? null} commands={commands} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface SetupCommandRowProps {
|
||||
command: SetupCommand;
|
||||
showDetail: boolean;
|
||||
|
||||
@@ -221,11 +221,12 @@ describe("workspace-setup-store", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("seeds a setup tab only for failed setup", () => {
|
||||
it("seeds a setup tab for failed or blocked setup", () => {
|
||||
expect(shouldSeedWorkspaceSetupTab(null)).toBe(false);
|
||||
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("running"))).toBe(false);
|
||||
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("completed"))).toBe(false);
|
||||
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("failed", "Setup failed"))).toBe(true);
|
||||
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("blocked"))).toBe(true);
|
||||
});
|
||||
|
||||
it("claims one failed setup surface until a later setup lifecycle begins", () => {
|
||||
|
||||
@@ -34,11 +34,13 @@ export function shouldShowWorkspaceSetup(snapshot: WorkspaceSetupSnapshot | null
|
||||
if (!snapshot) {
|
||||
return false;
|
||||
}
|
||||
return snapshot.error !== null || snapshot.detail.commands.length > 0;
|
||||
return (
|
||||
snapshot.status === "blocked" || snapshot.error !== null || snapshot.detail.commands.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldSeedWorkspaceSetupTab(snapshot: WorkspaceSetupSnapshot | null): boolean {
|
||||
return snapshot?.status === "failed";
|
||||
return snapshot?.status === "failed" || snapshot?.status === "blocked";
|
||||
}
|
||||
|
||||
interface WorkspaceSetupStoreState {
|
||||
@@ -82,7 +84,7 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, g
|
||||
|
||||
set((state) => {
|
||||
const surfacedFailedSetupKeys = new Set(state.surfacedFailedSetupKeys);
|
||||
if (payload.status !== "failed") {
|
||||
if (payload.status !== "failed" && payload.status !== "blocked") {
|
||||
surfacedFailedSetupKeys.delete(key);
|
||||
}
|
||||
return {
|
||||
@@ -105,7 +107,10 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, g
|
||||
|
||||
let claimed = false;
|
||||
set((state) => {
|
||||
if (state.snapshots[key]?.status !== "failed" || state.surfacedFailedSetupKeys.has(key)) {
|
||||
if (
|
||||
!["failed", "blocked"].includes(state.snapshots[key]?.status ?? "") ||
|
||||
state.surfacedFailedSetupKeys.has(key)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
claimed = true;
|
||||
|
||||
@@ -575,6 +575,7 @@ async function resolveRunWorkspace(
|
||||
const branch = result.workspace.gitRuntime?.currentBranch;
|
||||
const label = branch ? `${result.workspace.name} (${branch})` : result.workspace.name;
|
||||
console.error(`Created workspace ${result.workspace.id} - ${label}`);
|
||||
if (result.setupSkippedReason) console.error(result.setupSkippedReason);
|
||||
console.error(
|
||||
"Tip: pass --workspace <id> (or set PASEO_WORKSPACE_ID) to run in an existing workspace.",
|
||||
);
|
||||
|
||||
@@ -5,10 +5,18 @@ import { runArchiveCommand } from "./archive.js";
|
||||
import { runCreateCommand } from "./create.js";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runRenameCommand } from "./rename.js";
|
||||
import { runSetupCommand } from "./setup.js";
|
||||
|
||||
export function createWorkspaceCommand(): Command {
|
||||
const workspace = new Command("workspace").description("Manage workspaces");
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
workspace
|
||||
.command("setup")
|
||||
.description("Allow and run setup for a workspace")
|
||||
.argument("<workspace-id>", "Workspace id"),
|
||||
).action(withOutput(runSetupCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
workspace
|
||||
.command("create")
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
|
||||
interface WorkspaceSetupResult {
|
||||
workspaceId: string;
|
||||
status: "started" | "already_allowed";
|
||||
}
|
||||
|
||||
const workspaceSetupSchema: OutputSchema<WorkspaceSetupResult> = {
|
||||
idField: "workspaceId",
|
||||
columns: [
|
||||
{ header: "WORKSPACE ID", field: "workspaceId", width: 20 },
|
||||
{ header: "STATUS", field: "status", width: 18 },
|
||||
],
|
||||
};
|
||||
|
||||
export async function runSetupCommand(
|
||||
workspaceId: string,
|
||||
options: { host?: string },
|
||||
_command: Command,
|
||||
): Promise<SingleResult<WorkspaceSetupResult>> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
} satisfies CommandError;
|
||||
});
|
||||
try {
|
||||
// COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02.
|
||||
if (!client.getLastServerInfoMessage()?.features?.workspaceSetupRun) {
|
||||
throw { code: "DAEMON_UPDATE_REQUIRED", message: "Update the host to run workspace setup." };
|
||||
}
|
||||
const payload = await client.runWorkspaceSetup(workspaceId);
|
||||
if (payload.error) throw new Error(payload.error);
|
||||
return {
|
||||
type: "single",
|
||||
data: { workspaceId, status: payload.started ? "started" : "already_allowed" },
|
||||
schema: workspaceSetupSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw { code: "WORKSPACE_SETUP_FAILED", message } satisfies CommandError;
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -2392,6 +2392,16 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async runWorkspaceSetup(
|
||||
workspaceId: string,
|
||||
requestId?: string,
|
||||
): Promise<Extract<SessionOutboundMessage, { type: "workspace.setup.run.response" }>["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest<"workspace.setup.run.response">({
|
||||
requestId,
|
||||
message: { type: "workspace.setup.run.request", workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async fetchAgent(options: FetchAgentOptions): Promise<FetchAgentResult | null>;
|
||||
async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null>;
|
||||
async fetchAgent(
|
||||
|
||||
@@ -2459,6 +2459,12 @@ export const WorkspaceSetupStatusRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupRunRequestSchema = z.object({
|
||||
type: z.literal("workspace.setup.run.request"),
|
||||
workspaceId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
|
||||
export const LegacyListAvailableEditorsRequestSchema = z.object({
|
||||
type: z.literal("list_available_editors_request"),
|
||||
@@ -3153,6 +3159,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
CreatePaseoWorktreeRequestSchema,
|
||||
WorkspaceSetupStatusRequestSchema,
|
||||
WorkspaceSetupRunRequestSchema,
|
||||
LegacyListAvailableEditorsRequestSchema,
|
||||
LegacyOpenInEditorRequestSchema,
|
||||
OpenProjectRequestSchema,
|
||||
@@ -3391,6 +3398,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
directorySync: z.boolean().optional(),
|
||||
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
|
||||
workspaceLabels: z.boolean().optional(),
|
||||
// COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02.
|
||||
workspaceSetupRun: z.boolean().optional(),
|
||||
// COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the
|
||||
// feature gate and checkoutGithubSetAutoMerge fallback after 2027-01-17
|
||||
// once the supported daemon floor is >= v0.2.0.
|
||||
@@ -4156,16 +4165,42 @@ export const WorkspaceSetupProgressMessageSchema = z.object({
|
||||
type: z.literal("workspace_setup_progress"),
|
||||
payload: z.object({
|
||||
workspaceId: z.string(),
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
status: z.enum(["running", "completed", "failed", "blocked"]),
|
||||
detail: WorktreeSetupDetailPayloadSchema,
|
||||
error: z.string().nullable(),
|
||||
blockedSource: z
|
||||
.object({
|
||||
kind: z.literal("change_request"),
|
||||
forge: z.string(),
|
||||
number: z.number().int().positive(),
|
||||
headRepository: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupSnapshotSchema = z.object({
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
status: z.enum(["running", "completed", "failed", "blocked"]),
|
||||
detail: WorktreeSetupDetailPayloadSchema,
|
||||
error: z.string().nullable(),
|
||||
blockedSource: z
|
||||
.object({
|
||||
kind: z.literal("change_request"),
|
||||
forge: z.string(),
|
||||
number: z.number().int().positive(),
|
||||
headRepository: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupRunResponseMessageSchema = z.object({
|
||||
type: z.literal("workspace.setup.run.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
started: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupStatusResponseMessageSchema = z.object({
|
||||
@@ -4564,6 +4599,7 @@ export const WorkspaceCreateResponseSchema = z.object({
|
||||
payload: z.object({
|
||||
workspace: WorkspaceDescriptorPayloadSchema.nullable(),
|
||||
setupTerminalId: z.string().nullable(),
|
||||
setupSkippedReason: z.string().optional(),
|
||||
error: z.string().nullable(),
|
||||
errorCode: z.string().optional(),
|
||||
requestId: z.string(),
|
||||
@@ -5596,6 +5632,7 @@ export const CreatePaseoWorktreeResponseSchema = z.object({
|
||||
error: z.string().nullable(),
|
||||
errorCode: z.string().optional(),
|
||||
setupTerminalId: z.string().nullable(),
|
||||
setupSkippedReason: z.string().optional(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
@@ -6400,6 +6437,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ScriptStatusUpdateMessageSchema,
|
||||
WorkspaceSetupProgressMessageSchema,
|
||||
WorkspaceSetupStatusResponseMessageSchema,
|
||||
WorkspaceSetupRunResponseMessageSchema,
|
||||
AgentStreamMessageSchema,
|
||||
AgentStatusMessageSchema,
|
||||
FetchAgentsResponseMessageSchema,
|
||||
@@ -6578,6 +6616,9 @@ export type WorkspaceSetupSnapshot = z.infer<typeof WorkspaceSetupSnapshotSchema
|
||||
export type WorkspaceSetupStatusResponseMessage = z.infer<
|
||||
typeof WorkspaceSetupStatusResponseMessageSchema
|
||||
>;
|
||||
export type WorkspaceSetupRunResponseMessage = z.infer<
|
||||
typeof WorkspaceSetupRunResponseMessageSchema
|
||||
>;
|
||||
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
|
||||
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
|
||||
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>;
|
||||
@@ -6913,6 +6954,7 @@ export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponse
|
||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
|
||||
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
|
||||
export type WorkspaceSetupRunRequest = z.infer<typeof WorkspaceSetupRunRequestSchema>;
|
||||
export type LegacyListAvailableEditorsRequest = z.infer<
|
||||
typeof LegacyListAvailableEditorsRequestSchema
|
||||
>;
|
||||
|
||||
@@ -10,6 +10,52 @@ import {
|
||||
} from "./messages.js";
|
||||
|
||||
describe("workspace message schemas", () => {
|
||||
test("parses blocked setup status and the explicit setup run RPC", () => {
|
||||
expect(
|
||||
SessionInboundMessageSchema.parse({
|
||||
type: "workspace.setup.run.request",
|
||||
workspaceId: "workspace-fork",
|
||||
requestId: "run-setup",
|
||||
}),
|
||||
).toMatchObject({ type: "workspace.setup.run.request", workspaceId: "workspace-fork" });
|
||||
|
||||
expect(
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_setup_progress",
|
||||
payload: {
|
||||
workspaceId: "workspace-fork",
|
||||
status: "blocked",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/repo/fork",
|
||||
branchName: "fork",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
error: null,
|
||||
blockedSource: {
|
||||
kind: "change_request",
|
||||
forge: "github",
|
||||
number: 42,
|
||||
headRepository: "contributor/paseo",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ payload: { status: "blocked", blockedSource: { number: 42 } } });
|
||||
|
||||
expect(
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "workspace.setup.run.response",
|
||||
payload: {
|
||||
requestId: "run-setup",
|
||||
workspaceId: "workspace-fork",
|
||||
started: true,
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ type: "workspace.setup.run.response", payload: { started: true } });
|
||||
});
|
||||
|
||||
test("parses fetch_workspaces_request", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "fetch_workspaces_request",
|
||||
|
||||
@@ -198,6 +198,7 @@ const INBOUND_PERMISSION = {
|
||||
"workspace.script.stop.request": "workspace.write",
|
||||
"workspace.title.set.request": "workspace.manage",
|
||||
workspace_setup_status_request: "workspace.read",
|
||||
"workspace.setup.run.request": "workspace.write",
|
||||
write_project_config_request: "workspace.write",
|
||||
} as const satisfies Record<InboundOperation, DaemonPermission | null>;
|
||||
|
||||
@@ -409,6 +410,7 @@ const OUTBOUND_PERMISSION = {
|
||||
"workspace.title.set.response": "workspace.manage",
|
||||
workspace_setup_progress: "workspace.read",
|
||||
workspace_setup_status_response: "workspace.read",
|
||||
"workspace.setup.run.response": "workspace.write",
|
||||
workspace_update: "workspace.read",
|
||||
write_project_config_response: "workspace.write",
|
||||
} as const satisfies Record<OutboundOperation, DaemonPermission | null>;
|
||||
|
||||
@@ -189,6 +189,7 @@ import { ScriptHealthMonitor } from "./script-health-monitor.js";
|
||||
import { createScriptStatusEmitter } from "./script-status-projection.js";
|
||||
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import { createWorkspaceScriptsService } from "./session/workspace-scripts/workspace-scripts-service.js";
|
||||
import { assertWorkspaceAutomationAllowedForWorkspace } from "./workspace-automation-gate.js";
|
||||
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
|
||||
import {
|
||||
createManagedProcessRegistry,
|
||||
@@ -1120,6 +1121,8 @@ export async function createPaseoDaemon(
|
||||
cacheWorkspaceSetupSnapshot: () => {},
|
||||
startWorkspaceSetup: (workspaceId, operation) =>
|
||||
workspaceSetupRuntime.start(workspaceId, operation),
|
||||
assertWorkspaceAutomationAllowed: (guardedWorkspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, guardedWorkspaceId),
|
||||
emit: emitExternalSessionMessage,
|
||||
sessionLogger: logger,
|
||||
terminalManager,
|
||||
@@ -1168,6 +1171,8 @@ export async function createPaseoDaemon(
|
||||
killTerminalsForWorkspace: (workspaceIdToKill) =>
|
||||
killTerminalsForWorkspace({ terminalManager, sessionLogger: logger }, workspaceIdToKill),
|
||||
stopWorkspaceSetup: (workspaceIdToStop) => workspaceSetupRuntime.stop(workspaceIdToStop),
|
||||
assertWorkspaceAutomationAllowed: (guardedWorkspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, guardedWorkspaceId),
|
||||
sessionLogger: logger,
|
||||
},
|
||||
{ scope: { kind: "workspace", workspaceId }, requestId },
|
||||
@@ -1293,6 +1298,8 @@ export async function createPaseoDaemon(
|
||||
workspaceIdToKill,
|
||||
),
|
||||
stopWorkspaceSetup: (workspaceIdToStop) => workspaceSetupRuntime.stop(workspaceIdToStop),
|
||||
assertWorkspaceAutomationAllowed: (guardedWorkspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, guardedWorkspaceId),
|
||||
sessionLogger: logger,
|
||||
},
|
||||
{
|
||||
@@ -1374,6 +1381,8 @@ export async function createPaseoDaemon(
|
||||
// status updates fan out to every connected client.
|
||||
emit: (message) => wsServer?.broadcast(wrapSessionMessage(message)),
|
||||
spawnWorkspaceScript,
|
||||
assertAutomationAllowed: (workspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, workspaceId),
|
||||
globalServicePorts: loadPersistedConfig(config.paseoHome).worktrees?.servicePorts,
|
||||
}),
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
|
||||
@@ -100,6 +100,17 @@ async function createPaseoWorktreeWithPriority(
|
||||
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
|
||||
title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
expectsInitialAgent: Boolean(input.firstAgentContext),
|
||||
...(createdWorktree.intent.kind === "checkout-change-request" &&
|
||||
createdWorktree.intent.headRepository
|
||||
? {
|
||||
untrustedSource: {
|
||||
kind: "change_request" as const,
|
||||
forge: createdWorktree.intent.forge,
|
||||
number: createdWorktree.intent.changeRequestNumber,
|
||||
headRepository: createdWorktree.intent.headRepository,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
||||
|
||||
@@ -204,6 +204,7 @@ describe("resolveWorktreeCreationIntent", () => {
|
||||
baseRefName: "main",
|
||||
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/526/head" }],
|
||||
headRepositoryOwner: "therainisme",
|
||||
headRepository: "therainisme/paseo",
|
||||
localBranchName: "therainisme/main",
|
||||
pushRemoteUrl: "git@github.com:therainisme/paseo.git",
|
||||
});
|
||||
@@ -285,6 +286,7 @@ describe("resolveWorktreeCreationIntent", () => {
|
||||
forge: "gitlab",
|
||||
changeRequestNumber: 7,
|
||||
headRef: "feature/mr-source",
|
||||
headRepository: "unknown repository",
|
||||
baseRefName: "main",
|
||||
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/merge-requests/7/head" }],
|
||||
});
|
||||
|
||||
@@ -187,12 +187,7 @@ async function resolvePrCheckoutIntent(
|
||||
headRef,
|
||||
}) ?? [{ remoteName: "origin", remoteRef: `refs/heads/${headRef}` }];
|
||||
const localBranchName = service.buildPrLocalBranchName?.({ headRef, checkoutTarget });
|
||||
const headRepositoryOwner = checkoutTarget.isCrossRepository
|
||||
? checkoutTarget.headOwnerLogin?.trim() || undefined
|
||||
: undefined;
|
||||
const pushRemoteUrl = checkoutTarget.isCrossRepository
|
||||
? checkoutTarget.headRepositorySshUrl || checkoutTarget.headRepositoryUrl || undefined
|
||||
: undefined;
|
||||
const crossRepository = resolveCrossRepositoryFields(checkoutTarget);
|
||||
const trackOriginHead = !checkoutTarget.isCrossRepository;
|
||||
|
||||
return {
|
||||
@@ -200,15 +195,42 @@ async function resolvePrCheckoutIntent(
|
||||
forge: deps.forge,
|
||||
changeRequestNumber: params.changeRequestNumber,
|
||||
headRef,
|
||||
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
|
||||
...crossRepository,
|
||||
baseRefName,
|
||||
checkoutRefs: checkoutTarget.checkoutRefs ?? defaultRefs,
|
||||
...(localBranchName && localBranchName !== headRef ? { localBranchName } : {}),
|
||||
...(pushRemoteUrl ? { pushRemoteUrl } : {}),
|
||||
...(trackOriginHead ? { trackOriginHead } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCrossRepositoryFields(target: PullRequestCheckoutTarget): {
|
||||
headRepositoryOwner?: string;
|
||||
headRepository?: string;
|
||||
pushRemoteUrl?: string;
|
||||
} {
|
||||
if (!target.isCrossRepository) return {};
|
||||
const headRepositoryOwner = target.headOwnerLogin?.trim() || undefined;
|
||||
const headRepository = resolveHeadRepository(target);
|
||||
const pushRemoteUrl = target.headRepositorySshUrl || target.headRepositoryUrl || undefined;
|
||||
return {
|
||||
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
|
||||
...(headRepository ? { headRepository } : {}),
|
||||
...(pushRemoteUrl ? { pushRemoteUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveHeadRepository(target: PullRequestCheckoutTarget): string | undefined {
|
||||
const url = target.headRepositoryUrl ?? target.headRepositorySshUrl;
|
||||
if (!url) return target.headOwnerLogin?.trim() || "unknown repository";
|
||||
const path = url
|
||||
.replace(/\.git$/, "")
|
||||
.split(/[/:]/)
|
||||
.filter(Boolean);
|
||||
const repository = path.at(-1);
|
||||
const owner = target.headOwnerLogin?.trim() || path.at(-2);
|
||||
return (owner && repository ? `${owner}/${repository}` : repository) ?? "unknown repository";
|
||||
}
|
||||
|
||||
function hasCheckoutRefs(target: PullRequestCheckoutTarget): boolean {
|
||||
return Array.isArray(target.checkoutRefs) && target.checkoutRefs.length > 0;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ import {
|
||||
normalizeClientRestartRpcReason,
|
||||
} from "./lifecycle-reasons.js";
|
||||
import { DirectorySyncService } from "./directory-sync/index.js";
|
||||
import {
|
||||
assertWorkspaceAutomationAllowedForWorkspace,
|
||||
clearWorkspaceAutomationBlock,
|
||||
formatWorkspaceAutomationBlockedMessage,
|
||||
} from "./workspace-automation-gate.js";
|
||||
import {
|
||||
WorkspaceLabelError,
|
||||
WorkspaceLabelStorageUncertainError,
|
||||
@@ -244,6 +249,7 @@ import {
|
||||
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
|
||||
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
|
||||
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
|
||||
handleWorkspaceSetupRunRequest as handleWorkspaceSetupRunRequestMessage,
|
||||
} from "./worktree-session.js";
|
||||
import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
|
||||
import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js";
|
||||
@@ -1053,6 +1059,8 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
emit: (message) => this.emit(message),
|
||||
spawnWorkspaceScript,
|
||||
assertAutomationAllowed: (workspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
|
||||
globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts,
|
||||
});
|
||||
this.subscribeToOptionalManagers();
|
||||
@@ -1950,9 +1958,7 @@ export class Session {
|
||||
this.dispatchAgentLifecycleMessage(msg) ??
|
||||
this.dispatchAgentConfigMessage(msg) ??
|
||||
this.dispatchCheckoutMessage(msg) ??
|
||||
this.dispatchWorkspaceRecoveryMessage(msg) ??
|
||||
this.dispatchWorkspaceLabelMessage(msg) ??
|
||||
this.dispatchWorkspaceAndProjectMessage(msg) ??
|
||||
this.dispatchWorkspaceLifecycleMessage(msg) ??
|
||||
this.dispatchWorkspaceFileMessage(msg, source) ??
|
||||
this.dispatchProviderMessage(msg) ??
|
||||
this.dispatchOrchestrationSkillsMessage(msg) ??
|
||||
@@ -1964,6 +1970,15 @@ export class Session {
|
||||
if (promise) await promise;
|
||||
}
|
||||
|
||||
private dispatchWorkspaceLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
return (
|
||||
this.dispatchWorkspaceRecoveryMessage(msg) ??
|
||||
this.dispatchWorkspaceLabelMessage(msg) ??
|
||||
this.dispatchWorkspaceSetupMessage(msg) ??
|
||||
this.dispatchWorkspaceAndProjectMessage(msg)
|
||||
);
|
||||
}
|
||||
|
||||
private dispatchOrchestrationSkillsMessage(
|
||||
msg: SessionInboundMessage,
|
||||
): Promise<void> | undefined {
|
||||
@@ -2476,8 +2491,6 @@ export class Session {
|
||||
return this.handlePaseoWorktreeArchiveRequest(msg);
|
||||
case "create_paseo_worktree_request":
|
||||
return this.handleCreatePaseoWorktreeRequest(msg);
|
||||
case "workspace_setup_status_request":
|
||||
return this.handleWorkspaceSetupStatusRequest(msg);
|
||||
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
|
||||
case "list_available_editors_request":
|
||||
return this.handleLegacyListAvailableEditorsRequest(msg);
|
||||
@@ -2510,6 +2523,16 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchWorkspaceSetupMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
if (msg.type === "workspace_setup_status_request") {
|
||||
return this.handleWorkspaceSetupStatusRequest(msg);
|
||||
}
|
||||
if (msg.type === "workspace.setup.run.request") {
|
||||
return this.handleWorkspaceSetupRunRequest(msg);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private dispatchWorkspaceLabelMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "workspace.label.list.request":
|
||||
@@ -6092,6 +6115,13 @@ export class Session {
|
||||
requestId: request.requestId,
|
||||
workspace: descriptor,
|
||||
setupTerminalId: null,
|
||||
...(result.workspace.untrustedSource
|
||||
? {
|
||||
setupSkippedReason: formatWorkspaceAutomationBlockedMessage(
|
||||
result.workspace.untrustedSource,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
@@ -6617,6 +6647,8 @@ export class Session {
|
||||
}),
|
||||
startWorkspaceSetup: (workspaceId, operation) =>
|
||||
this.workspaceSetupRuntime.start(workspaceId, operation),
|
||||
assertWorkspaceAutomationAllowed: (workspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
|
||||
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
|
||||
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
|
||||
@@ -6647,6 +6679,39 @@ export class Session {
|
||||
{
|
||||
emit: (message) => this.emit(message),
|
||||
workspaceSetupSnapshots: this.workspaceSetupSnapshots,
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
},
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleWorkspaceSetupRunRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.setup.run.request" }>,
|
||||
): Promise<void> {
|
||||
return handleWorkspaceSetupRunRequestMessage(
|
||||
{
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
clearAutomationBlock: (workspaceId) =>
|
||||
clearWorkspaceAutomationBlock(this.workspaceRegistry, workspaceId),
|
||||
startWorkspaceSetup: (workspaceId, operation) =>
|
||||
this.workspaceSetupRuntime.start(workspaceId, operation),
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
|
||||
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
|
||||
this.workspaceSetupSnapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => this.emit(message),
|
||||
sessionLogger: this.sessionLogger,
|
||||
terminalManager: this.terminalManager,
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
serviceProxy: this.serviceProxy,
|
||||
scriptRuntimeStore: this.scriptRuntimeStore,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort,
|
||||
getDaemonTcpHost: this.getDaemonTcpHost,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
onScriptsChanged: (workspaceId, workspaceDirectory) =>
|
||||
this.workspaceScripts.emitStatusUpdate(workspaceId, workspaceDirectory),
|
||||
},
|
||||
request,
|
||||
);
|
||||
@@ -6678,6 +6743,8 @@ export class Session {
|
||||
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
|
||||
this.markWorkspaceArchiving(workspaceIds, archivingAt),
|
||||
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
|
||||
assertWorkspaceAutomationAllowed: (workspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
|
||||
killTerminalsForWorkspace: (workspaceId) =>
|
||||
this.terminalController.killTerminalsForWorkspace(workspaceId),
|
||||
stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(workspaceId),
|
||||
|
||||
+3
@@ -16,6 +16,7 @@ import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
import { deriveProjectKey } from "../../project-key.js";
|
||||
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
import type { UntrustedWorkspaceSource } from "../../workspace-automation-gate.js";
|
||||
|
||||
export interface ResolveOrCreateWorkspaceIdInput {
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
||||
@@ -44,6 +45,7 @@ export interface CreateWorktreeWorkspaceInput {
|
||||
baseBranch: string | null;
|
||||
title: string | null;
|
||||
expectsInitialAgent?: boolean;
|
||||
untrustedSource?: UntrustedWorkspaceSource;
|
||||
}
|
||||
|
||||
export interface WorkspaceProvisioningService {
|
||||
@@ -244,6 +246,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
title: input.title,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}),
|
||||
});
|
||||
await workspaceRegistry.upsert(workspace, {
|
||||
expectsInitialAgent: input.expectsInitialAgent,
|
||||
|
||||
+26
@@ -77,6 +77,7 @@ interface BuildOptions {
|
||||
project?: PersistedProjectRecord | null;
|
||||
spawnThrows?: string;
|
||||
gitService?: Pick<WorkspaceGitService, "peekSnapshot">;
|
||||
automationError?: Error;
|
||||
}
|
||||
|
||||
function buildService(options: BuildOptions = {}) {
|
||||
@@ -120,6 +121,9 @@ function buildService(options: BuildOptions = {}) {
|
||||
terminalId: "terminal-1",
|
||||
};
|
||||
},
|
||||
assertAutomationAllowed: async () => {
|
||||
if (options.automationError) throw options.automationError;
|
||||
},
|
||||
});
|
||||
|
||||
return { service, emitted, spawnCalls };
|
||||
@@ -269,6 +273,28 @@ describe("stop", () => {
|
||||
});
|
||||
|
||||
describe("start", () => {
|
||||
test("refuses to start a script while repository automation is blocked", async () => {
|
||||
const { service, emitted, spawnCalls } = buildService({
|
||||
automationError: new Error(
|
||||
"Scripts are blocked for PR #42 from contributor/paseo. Run setup to allow them.",
|
||||
),
|
||||
});
|
||||
|
||||
await service.start(request);
|
||||
|
||||
expect(spawnCalls).toEqual([]);
|
||||
expect(emitted).toContainEqual({
|
||||
type: "start_workspace_script_response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
workspaceId: "ws-1",
|
||||
scriptName: "app",
|
||||
terminalId: null,
|
||||
error: "Scripts are blocked for PR #42 from contributor/paseo. Run setup to allow them.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("reports an error when workspace scripts are unavailable", async () => {
|
||||
const { service, emitted, spawnCalls } = buildService({ terminalManager: null });
|
||||
await service.start(request);
|
||||
|
||||
@@ -67,6 +67,7 @@ export function createWorkspaceScriptsService(deps: {
|
||||
logger: pino.Logger;
|
||||
emit: (message: SessionOutboundMessage) => void;
|
||||
spawnWorkspaceScript: (options: SpawnWorkspaceScriptOptions) => Promise<WorktreeScriptResult>;
|
||||
assertAutomationAllowed: (workspaceId: string) => Promise<void>;
|
||||
}): WorkspaceScriptsService {
|
||||
const {
|
||||
serviceProxy,
|
||||
@@ -83,6 +84,7 @@ export function createWorkspaceScriptsService(deps: {
|
||||
logger,
|
||||
emit,
|
||||
spawnWorkspaceScript,
|
||||
assertAutomationAllowed,
|
||||
} = deps;
|
||||
|
||||
function resolveGitMetadata(
|
||||
@@ -169,6 +171,7 @@ export function createWorkspaceScriptsService(deps: {
|
||||
async function launchProcess(input: { workspaceId: string; scriptName: string }) {
|
||||
const available = requireAvailable();
|
||||
const workspace = await getWorkspace(input.workspaceId);
|
||||
await assertAutomationAllowed(workspace.workspaceId);
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
const gitMetadata = resolveGitMetadata(workspace, project);
|
||||
const result = await spawnWorkspaceScript({
|
||||
|
||||
@@ -1632,6 +1632,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
directorySync: true,
|
||||
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
|
||||
...(this.workspaceLabelService ? { workspaceLabels: true } : {}),
|
||||
// COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02.
|
||||
workspaceSetupRun: true,
|
||||
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
||||
providersSnapshot: true,
|
||||
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type ArchiveResult,
|
||||
resolveWorkspaceIdAtPath,
|
||||
} from "./workspace-archive-service.js";
|
||||
import { WorkspaceAutomationBlockedError } from "./workspace-automation-gate.js";
|
||||
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
@@ -262,6 +263,56 @@ describe("archiveByScope", () => {
|
||||
expect(readFileSync(path.join(repoDir, "shared-teardown.log"), "utf8")).toBe("ok");
|
||||
});
|
||||
|
||||
test("workspace scope skips teardown while repository automation is blocked", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const marker = path.join(repoDir, "blocked-teardown.log");
|
||||
writeFileSync(
|
||||
path.join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
teardown: [`node -e "require('fs').writeFileSync('${marker}', 'unsafe')"`],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "blocked teardown"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "blocked-teardown");
|
||||
const workspaceId = "ws-blocked-teardown";
|
||||
const deps = createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [
|
||||
{
|
||||
workspaceId,
|
||||
cwd: worktree.worktreePath,
|
||||
kind: "worktree",
|
||||
worktreeRoot: worktree.worktreePath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: repoDir,
|
||||
},
|
||||
],
|
||||
});
|
||||
deps.assertWorkspaceAutomationAllowed = async () => {
|
||||
throw new WorkspaceAutomationBlockedError({
|
||||
kind: "change_request",
|
||||
forge: "github",
|
||||
number: 42,
|
||||
headRepository: "contributor/paseo",
|
||||
});
|
||||
};
|
||||
|
||||
const result = await archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
requestId: "req-blocked-teardown",
|
||||
});
|
||||
|
||||
expect(result.archivedWorkspaceIds).toEqual([workspaceId]);
|
||||
expect(existsSync(marker)).toBe(false);
|
||||
});
|
||||
|
||||
test("workspace scope keeps a worktree for an active workspace in a subdirectory", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
} from "./workspace-registry.js";
|
||||
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
import { runWithGitCommandPriority } from "../utils/run-git-command.js";
|
||||
import { WorkspaceAutomationBlockedError } from "./workspace-automation-gate.js";
|
||||
|
||||
export type ActiveWorkspaceRef = Pick<
|
||||
PersistedWorkspaceRecord,
|
||||
@@ -50,6 +51,7 @@ export interface ArchiveDependencies {
|
||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
|
||||
killTerminalsForWorkspace: (workspaceId: string) => Promise<void>;
|
||||
stopWorkspaceSetup?: (workspaceId: string) => Promise<void>;
|
||||
assertWorkspaceAutomationAllowed?: (workspaceId: string) => Promise<void>;
|
||||
sessionLogger?: Logger;
|
||||
}
|
||||
|
||||
@@ -357,21 +359,20 @@ async function maybeRemoveDirectory(
|
||||
}
|
||||
|
||||
const archivedWorkspaceIdSet = new Set(archivedWorkspaceIds);
|
||||
const teardownCwds = uniqueFilesystemPaths(
|
||||
target.teardownTargets
|
||||
.filter(
|
||||
(teardownTarget) =>
|
||||
teardownTarget.workspaceId === null ||
|
||||
archivedWorkspaceIdSet.has(teardownTarget.workspaceId),
|
||||
)
|
||||
.map((teardownTarget) => teardownTarget.cwd),
|
||||
const teardownTargets = target.teardownTargets.filter(
|
||||
(teardownTarget) =>
|
||||
teardownTarget.workspaceId === null || archivedWorkspaceIdSet.has(teardownTarget.workspaceId),
|
||||
);
|
||||
|
||||
try {
|
||||
for (const teardownCwd of teardownCwds) {
|
||||
const allowedTeardownTargets = await filterAllowedTeardownTargets(
|
||||
dependencies,
|
||||
teardownTargets,
|
||||
);
|
||||
for (const teardownTarget of uniqueTeardownTargets(allowedTeardownTargets)) {
|
||||
await runWorktreeTeardownCommands({
|
||||
worktreePath: backing.path,
|
||||
teardownCwd,
|
||||
teardownCwd: teardownTarget.cwd,
|
||||
repoRootPath: backing.mainRepoRoot ?? undefined,
|
||||
});
|
||||
}
|
||||
@@ -418,10 +419,31 @@ async function maybeRemoveDirectory(
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueFilesystemPaths(paths: string[]): string[] {
|
||||
const unique: string[] = [];
|
||||
for (const candidate of paths) {
|
||||
if (!unique.some((existing) => createRealpathAwarePathMatcher(existing)(candidate))) {
|
||||
async function filterAllowedTeardownTargets(
|
||||
dependencies: ArchiveDependencies,
|
||||
targets: Array<{ workspaceId: string | null; cwd: string }>,
|
||||
): Promise<Array<{ workspaceId: string | null; cwd: string }>> {
|
||||
const allowed: Array<{ workspaceId: string | null; cwd: string }> = [];
|
||||
const blockedCwds: string[] = [];
|
||||
for (const target of targets) {
|
||||
try {
|
||||
if (target.workspaceId)
|
||||
await dependencies.assertWorkspaceAutomationAllowed?.(target.workspaceId);
|
||||
allowed.push(target);
|
||||
} catch (error) {
|
||||
if (!(error instanceof WorkspaceAutomationBlockedError)) throw error;
|
||||
blockedCwds.push(target.cwd);
|
||||
}
|
||||
}
|
||||
return allowed.filter(
|
||||
(target) => !blockedCwds.some((cwd) => createRealpathAwarePathMatcher(cwd)(target.cwd)),
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueTeardownTargets<T extends { cwd: string }>(targets: T[]): T[] {
|
||||
const unique: T[] = [];
|
||||
for (const candidate of targets) {
|
||||
if (!unique.some((existing) => createRealpathAwarePathMatcher(existing.cwd)(candidate.cwd))) {
|
||||
unique.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
WorkspaceAutomationBlockedError,
|
||||
assertWorkspaceAutomationAllowed,
|
||||
formatWorkspaceAutomationBlockedMessage,
|
||||
} from "./workspace-automation-gate.js";
|
||||
|
||||
describe("workspace automation gate", () => {
|
||||
it("refuses executable repository automation for a cross-repository change request", () => {
|
||||
expect(() =>
|
||||
assertWorkspaceAutomationAllowed({
|
||||
kind: "change_request",
|
||||
forge: "github",
|
||||
number: 42,
|
||||
headRepository: "contributor/paseo",
|
||||
}),
|
||||
).toThrowError(
|
||||
new WorkspaceAutomationBlockedError({
|
||||
kind: "change_request",
|
||||
forge: "github",
|
||||
number: 42,
|
||||
headRepository: "contributor/paseo",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows executable repository automation for ordinary workspaces", () => {
|
||||
expect(() => assertWorkspaceAutomationAllowed(undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["github", "PR"],
|
||||
["gitea", "PR"],
|
||||
["forgejo", "PR"],
|
||||
["gitlab", "MR"],
|
||||
])("uses the %s change request noun", (forge, noun) => {
|
||||
expect(
|
||||
formatWorkspaceAutomationBlockedMessage({
|
||||
kind: "change_request",
|
||||
forge,
|
||||
number: 42,
|
||||
headRepository: "contributor/paseo",
|
||||
}),
|
||||
).toBe(`Scripts are blocked for ${noun} #42 from contributor/paseo. Run setup to allow them.`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { WorkspaceRegistry } from "./workspace-registry.js";
|
||||
|
||||
export interface UntrustedWorkspaceSource {
|
||||
kind: "change_request";
|
||||
forge: string;
|
||||
number: number;
|
||||
headRepository: string;
|
||||
}
|
||||
|
||||
export function formatWorkspaceAutomationBlockedMessage(source: UntrustedWorkspaceSource): string {
|
||||
const changeRequestNoun = source.forge === "gitlab" ? "MR" : "PR";
|
||||
return `Scripts are blocked for ${changeRequestNoun} #${source.number} from ${source.headRepository}. Run setup to allow them.`;
|
||||
}
|
||||
|
||||
export class WorkspaceAutomationBlockedError extends Error {
|
||||
constructor(readonly source: UntrustedWorkspaceSource) {
|
||||
super(formatWorkspaceAutomationBlockedMessage(source));
|
||||
this.name = "WorkspaceAutomationBlockedError";
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWorkspaceAutomationAllowed(
|
||||
source: UntrustedWorkspaceSource | undefined,
|
||||
): void {
|
||||
if (source) throw new WorkspaceAutomationBlockedError(source);
|
||||
}
|
||||
|
||||
export async function assertWorkspaceAutomationAllowedForWorkspace(
|
||||
registry: Pick<WorkspaceRegistry, "get">,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const workspace = await registry.get(workspaceId);
|
||||
if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
assertWorkspaceAutomationAllowed(workspace.untrustedSource);
|
||||
}
|
||||
|
||||
export async function clearWorkspaceAutomationBlock(
|
||||
registry: Pick<WorkspaceRegistry, "update">,
|
||||
workspaceId: string,
|
||||
): Promise<boolean> {
|
||||
let cleared = false;
|
||||
const workspace = await registry.update(workspaceId, (record) => {
|
||||
if (!record.untrustedSource) return record;
|
||||
cleared = true;
|
||||
const { untrustedSource: _removed, ...trusted } = record;
|
||||
return { ...trusted, updatedAt: new Date().toISOString() };
|
||||
});
|
||||
if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
return cleared;
|
||||
}
|
||||
@@ -10,6 +10,14 @@ import {
|
||||
type PersistedProjectKind,
|
||||
type PersistedWorkspaceKind,
|
||||
} from "./workspace-registry-model.js";
|
||||
import type { UntrustedWorkspaceSource } from "./workspace-automation-gate.js";
|
||||
|
||||
const UntrustedWorkspaceSourceSchema = z.object({
|
||||
kind: z.literal("change_request"),
|
||||
forge: z.string(),
|
||||
number: z.number().int().positive(),
|
||||
headRepository: z.string(),
|
||||
});
|
||||
|
||||
const PersistedProjectRecordSchema = z.object({
|
||||
projectId: z.string(),
|
||||
@@ -93,6 +101,7 @@ const PersistedWorkspaceRecordSchema = z.object({
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
labels: z.array(z.string()).optional(),
|
||||
untrustedSource: UntrustedWorkspaceSourceSchema.optional(),
|
||||
});
|
||||
|
||||
export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>;
|
||||
@@ -674,6 +683,7 @@ export function createPersistedWorkspaceRecord(input: {
|
||||
autoArchivedChangeRequestUrl?: string | null;
|
||||
pinnedAt?: string | null;
|
||||
labels?: string[];
|
||||
untrustedSource?: UntrustedWorkspaceSource;
|
||||
}): PersistedWorkspaceRecord {
|
||||
return PersistedWorkspaceRecordSchema.parse({
|
||||
...input,
|
||||
|
||||
@@ -589,6 +589,31 @@ async function runWorktreeTerminalBootstrap(
|
||||
);
|
||||
}
|
||||
|
||||
export async function runWorktreeAutoTerminals(options: {
|
||||
workspaceId: string;
|
||||
worktree: WorktreeConfig;
|
||||
workspaceCwd: string;
|
||||
terminalManager: TerminalManager | null;
|
||||
logger?: Logger;
|
||||
}): Promise<void> {
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: options.worktree.worktreePath,
|
||||
branchName: options.worktree.branchName,
|
||||
});
|
||||
await runWorktreeTerminalBootstrap(
|
||||
{
|
||||
agentId: options.workspaceId,
|
||||
workspaceId: options.workspaceId,
|
||||
worktree: options.worktree,
|
||||
workspaceCwd: options.workspaceCwd,
|
||||
terminalManager: options.terminalManager,
|
||||
appendTimelineItem: async () => true,
|
||||
logger: options.logger,
|
||||
},
|
||||
runtimeEnv,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runAsyncWorktreeBootstrap(
|
||||
options: RunAsyncWorktreeBootstrapOptions,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1237,6 +1237,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
forge: "github",
|
||||
changeRequestNumber: 526,
|
||||
headRef: "main",
|
||||
headRepository: "therainisme/therainisme",
|
||||
headRepositoryOwner: "therainisme",
|
||||
baseRefName: "main",
|
||||
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/526/head" }],
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
runWorktreeSetupInBackground,
|
||||
handleCreatePaseoWorktreeRequest,
|
||||
handleWorkspaceSetupStatusRequest,
|
||||
handleWorkspaceSetupRunRequest,
|
||||
} from "./worktree-session.js";
|
||||
import {
|
||||
createWorktree as createWorktreePrimitive,
|
||||
@@ -49,6 +50,7 @@ import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { WorkspaceAutomationBlockedError } from "./workspace-automation-gate.js";
|
||||
|
||||
interface LegacyCreateWorktreeTestOptions {
|
||||
branchName: string;
|
||||
@@ -451,6 +453,73 @@ describe("resolveGitCreateBaseBranch", () => {
|
||||
});
|
||||
|
||||
describe("create-agent worktree setup boundary", () => {
|
||||
test("blocked worktrees keep their workspace but skip setup and automatic terminals", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const setupMarker = path.join(tempDir, "setup-ran");
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
writeFileSync(
|
||||
path.join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [`node -e "require('fs').writeFileSync('${setupMarker}', 'ran')"`],
|
||||
terminals: [{ command: "unsafe-terminal" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await createPaseoWorktreeWorkflow(
|
||||
{
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||
warmWorkspaceGitData: async () => {},
|
||||
autoNameWorkspaceBranchForFirstAgent: () => {},
|
||||
assertWorkspaceAutomationAllowed: async () => {
|
||||
throw new WorkspaceAutomationBlockedError({
|
||||
kind: "change_request",
|
||||
forge: "github",
|
||||
number: 42,
|
||||
headRepository: "contributor/paseo",
|
||||
});
|
||||
},
|
||||
emitWorkspaceUpdateForWorkspaceId: async () => {},
|
||||
cacheWorkspaceSetupSnapshot: () => {},
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: createTerminalManagerStub().manager,
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
serviceProxy: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
getDaemonTcpHost: null,
|
||||
onScriptsChanged: null,
|
||||
},
|
||||
{ cwd: repoDir, worktreeSlug: "blocked-fork", runSetup: false, paseoHome },
|
||||
{
|
||||
setupContinuation: {
|
||||
kind: "agent",
|
||||
terminalManager: createTerminalManagerStub().manager,
|
||||
appendTimelineItem: async () => true,
|
||||
logger: createLogger(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.setupContinuation?.kind).toBe("agent");
|
||||
result.setupContinuation?.startAfterAgentCreate({ agentId: "agent-fork" });
|
||||
expect(existsSync(setupMarker)).toBe(false);
|
||||
expect(emitted).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "workspace_setup_progress",
|
||||
payload: expect.objectContaining({ status: "blocked" }),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("agent setup continuation starts setup for the created agent timeline", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
@@ -1222,6 +1291,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
{
|
||||
emit: (message) => emitted.push(message),
|
||||
workspaceSetupSnapshots: snapshots,
|
||||
getWorkspace: async () => null,
|
||||
},
|
||||
{
|
||||
type: "workspace_setup_status_request",
|
||||
@@ -1257,6 +1327,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
{
|
||||
emit: (message) => emitted.push(message),
|
||||
workspaceSetupSnapshots: new Map(),
|
||||
getWorkspace: async () => null,
|
||||
},
|
||||
{
|
||||
type: "workspace_setup_status_request",
|
||||
@@ -1274,6 +1345,121 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("reports persisted fork provenance as blocked after restart", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
await handleWorkspaceSetupStatusRequest(
|
||||
{
|
||||
emit: (message) => emitted.push(message),
|
||||
workspaceSetupSnapshots: new Map(),
|
||||
getWorkspace: async () =>
|
||||
({
|
||||
workspaceId: "ws-fork",
|
||||
cwd: "/repo/fork",
|
||||
worktreeRoot: "/repo/fork",
|
||||
branch: "fork-branch",
|
||||
untrustedSource: {
|
||||
kind: "change_request",
|
||||
forge: "github",
|
||||
number: 42,
|
||||
headRepository: "contributor/paseo",
|
||||
},
|
||||
}) as PersistedWorkspaceRecord,
|
||||
},
|
||||
{
|
||||
type: "workspace_setup_status_request",
|
||||
workspaceId: "ws-fork",
|
||||
requestId: "req-blocked",
|
||||
},
|
||||
);
|
||||
|
||||
expect(emitted[0]).toMatchObject({
|
||||
type: "workspace_setup_status_response",
|
||||
payload: {
|
||||
snapshot: {
|
||||
status: "blocked",
|
||||
blockedSource: { number: 42, headRepository: "contributor/paseo" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("run setup clears the block and starts the existing setup runtime once", async () => {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), "workspace-setup-run-"));
|
||||
cleanupPaths.push(tempDir);
|
||||
execFileSync("git", ["init", "-b", "fork-branch"], { cwd: tempDir, stdio: "ignore" });
|
||||
const setupMarker = path.join(tempDir, "setup-ran");
|
||||
writeFileSync(
|
||||
path.join(tempDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [`node -e "require('fs').writeFileSync('setup-ran', 'ran')"`],
|
||||
terminals: [{ command: "start-preview" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
let blocked = true;
|
||||
const operations: Array<(signal: AbortSignal) => Promise<void>> = [];
|
||||
const workspace = {
|
||||
workspaceId: "ws-fork",
|
||||
cwd: tempDir,
|
||||
worktreeRoot: tempDir,
|
||||
branch: "fork-branch",
|
||||
mainRepoRoot: tempDir,
|
||||
archivedAt: null,
|
||||
} as PersistedWorkspaceRecord;
|
||||
const terminalManager = createTerminalManagerStub();
|
||||
const dependencies = {
|
||||
getWorkspace: async () => workspace,
|
||||
clearAutomationBlock: async () => {
|
||||
if (!blocked) return false;
|
||||
blocked = false;
|
||||
return true;
|
||||
},
|
||||
startWorkspaceSetup: (
|
||||
_workspaceId: string,
|
||||
operation: (signal: AbortSignal) => Promise<void>,
|
||||
) => operations.push(operation),
|
||||
emitWorkspaceUpdateForWorkspaceId: vi.fn(async () => {}),
|
||||
cacheWorkspaceSetupSnapshot: vi.fn(),
|
||||
emit: (message: SessionOutboundMessage) => emitted.push(message),
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: terminalManager.manager,
|
||||
archiveWorkspaceRecord: vi.fn(async () => {}),
|
||||
serviceProxy: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
getDaemonTcpHost: null,
|
||||
onScriptsChanged: null,
|
||||
};
|
||||
|
||||
await handleWorkspaceSetupRunRequest(dependencies, {
|
||||
type: "workspace.setup.run.request",
|
||||
workspaceId: "ws-fork",
|
||||
requestId: "req-run-1",
|
||||
});
|
||||
await handleWorkspaceSetupRunRequest(dependencies, {
|
||||
type: "workspace.setup.run.request",
|
||||
workspaceId: "ws-fork",
|
||||
requestId: "req-run-2",
|
||||
});
|
||||
|
||||
expect(operations).toHaveLength(1);
|
||||
await operations[0]!(new AbortController().signal);
|
||||
expect(readFileSync(setupMarker, "utf8")).toBe("ran");
|
||||
expect(terminalManager.terminals[0]?.sent).toEqual(["start-preview\r"]);
|
||||
expect(emitted.filter((message) => message.type === "workspace.setup.run.response")).toEqual([
|
||||
{
|
||||
type: "workspace.setup.run.response",
|
||||
payload: { requestId: "req-run-1", workspaceId: "ws-fork", started: true, error: null },
|
||||
},
|
||||
{
|
||||
type: "workspace.setup.run.response",
|
||||
payload: { requestId: "req-run-2", workspaceId: "ws-fork", started: false, error: null },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import {
|
||||
runAsyncWorktreeBootstrap,
|
||||
runWorktreeAutoTerminals,
|
||||
applyWorktreeSetupProgressEvent,
|
||||
buildWorktreeSetupDetail,
|
||||
createWorktreeSetupProgressAccumulator,
|
||||
@@ -48,6 +49,10 @@ import {
|
||||
listPaseoWorktreesCommand,
|
||||
} from "./worktree/commands.js";
|
||||
import type { WorkspaceSetupOperation } from "./workspace-setup-runtime.js";
|
||||
import {
|
||||
formatWorkspaceAutomationBlockedMessage,
|
||||
WorkspaceAutomationBlockedError,
|
||||
} from "./workspace-automation-gate.js";
|
||||
|
||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/;
|
||||
|
||||
@@ -125,6 +130,7 @@ interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInB
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}) => void;
|
||||
startWorkspaceSetup?: (workspaceId: string, operation: WorkspaceSetupOperation) => void;
|
||||
assertWorkspaceAutomationAllowed?: (workspaceId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface AgentWorktreeSetupContinuationInput {
|
||||
@@ -159,6 +165,13 @@ export type CreatePaseoWorktreeWorkflowFn = (
|
||||
interface HandleWorkspaceSetupStatusRequestDependencies {
|
||||
emit: EmitSessionMessage;
|
||||
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
|
||||
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||
}
|
||||
|
||||
interface HandleWorkspaceSetupRunRequestDependencies extends CreatePaseoWorktreeInBackgroundDependencies {
|
||||
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||
clearAutomationBlock: (workspaceId: string) => Promise<boolean>;
|
||||
startWorkspaceSetup: (workspaceId: string, operation: WorkspaceSetupOperation) => void;
|
||||
}
|
||||
|
||||
interface HandleCreatePaseoWorktreeRequestDependencies {
|
||||
@@ -556,6 +569,13 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
workspace: descriptor,
|
||||
error: null,
|
||||
setupTerminalId: null,
|
||||
...(createdWorktree.workspace.untrustedSource
|
||||
? {
|
||||
setupSkippedReason: formatWorkspaceAutomationBlockedMessage(
|
||||
createdWorktree.workspace.untrustedSource,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
requestId: request.requestId,
|
||||
},
|
||||
});
|
||||
@@ -608,6 +628,33 @@ export async function createPaseoWorktreeWorkflow(
|
||||
const workspace = createdWorktree.workspace;
|
||||
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
|
||||
|
||||
try {
|
||||
await dependencies.assertWorkspaceAutomationAllowed?.(workspace.workspaceId);
|
||||
} catch (error) {
|
||||
if (!(error instanceof WorkspaceAutomationBlockedError)) throw error;
|
||||
const snapshot: WorkspaceSetupSnapshot = {
|
||||
status: "blocked",
|
||||
detail: buildWorktreeSetupDetail({ worktree: createdWorktree.worktree, results: [] }),
|
||||
error: null,
|
||||
blockedSource: error.source,
|
||||
};
|
||||
dependencies.cacheWorkspaceSetupSnapshot(workspace.workspaceId, snapshot);
|
||||
dependencies.emit({
|
||||
type: "workspace_setup_progress",
|
||||
payload: { workspaceId: workspace.workspaceId, ...snapshot },
|
||||
});
|
||||
if (setupContinuation.kind === "agent") {
|
||||
return {
|
||||
...createdWorktree,
|
||||
setupContinuation: {
|
||||
kind: "agent",
|
||||
startAfterAgentCreate: () => undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
return createdWorktree;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (input.firstAgentContext) {
|
||||
dependencies.autoNameWorkspaceBranchForFirstAgent({
|
||||
@@ -676,7 +723,24 @@ export async function handleWorkspaceSetupStatusRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
|
||||
): Promise<void> {
|
||||
const workspaceId = request.workspaceId;
|
||||
const snapshot = dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null;
|
||||
let snapshot = dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null;
|
||||
if (!snapshot) {
|
||||
const workspace = await dependencies.getWorkspace(workspaceId);
|
||||
if (workspace?.untrustedSource) {
|
||||
snapshot = {
|
||||
status: "blocked",
|
||||
detail: buildWorktreeSetupDetail({
|
||||
worktree: {
|
||||
worktreePath: workspace.worktreeRoot ?? workspace.cwd,
|
||||
branchName: workspace.branch ?? "",
|
||||
},
|
||||
results: [],
|
||||
}),
|
||||
error: null,
|
||||
blockedSource: workspace.untrustedSource,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
dependencies.emit({
|
||||
type: "workspace_setup_status_response",
|
||||
@@ -688,6 +752,62 @@ export async function handleWorkspaceSetupStatusRequest(
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleWorkspaceSetupRunRequest(
|
||||
dependencies: HandleWorkspaceSetupRunRequestDependencies,
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.setup.run.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const workspace = await dependencies.getWorkspace(request.workspaceId);
|
||||
if (!workspace || workspace.archivedAt) {
|
||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
||||
}
|
||||
const started = await dependencies.clearAutomationBlock(request.workspaceId);
|
||||
if (started) {
|
||||
const worktree: WorktreeConfig = {
|
||||
worktreePath: workspace.worktreeRoot ?? workspace.cwd,
|
||||
branchName: workspace.branch ?? "",
|
||||
};
|
||||
dependencies.startWorkspaceSetup(request.workspaceId, (signal) =>
|
||||
runWorktreeSetupInBackground(
|
||||
dependencies,
|
||||
{
|
||||
requestCwd: workspace.cwd,
|
||||
repoRoot: workspace.mainRepoRoot ?? workspace.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
worktree,
|
||||
shouldBootstrap: true,
|
||||
slug: basename(worktree.worktreePath),
|
||||
worktreePath: worktree.worktreePath,
|
||||
workspaceCwd: workspace.cwd,
|
||||
runAutoTerminals: true,
|
||||
},
|
||||
signal,
|
||||
),
|
||||
);
|
||||
await dependencies.emitWorkspaceUpdateForWorkspaceId(request.workspaceId);
|
||||
}
|
||||
dependencies.emit({
|
||||
type: "workspace.setup.run.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspaceId: request.workspaceId,
|
||||
started,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
dependencies.emit({
|
||||
type: "workspace.setup.run.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspaceId: request.workspaceId,
|
||||
started: false,
|
||||
error: error instanceof Error ? error.message : "Failed to run workspace setup",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWorktreeSetupInBackground(
|
||||
dependencies: CreatePaseoWorktreeInBackgroundDependencies,
|
||||
options: {
|
||||
@@ -699,6 +819,7 @@ export async function runWorktreeSetupInBackground(
|
||||
slug: string;
|
||||
worktreePath: string;
|
||||
workspaceCwd?: string;
|
||||
runAutoTerminals?: boolean;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
@@ -768,6 +889,15 @@ export async function runWorktreeSetupInBackground(
|
||||
});
|
||||
emitSetupProgress("completed", null);
|
||||
}
|
||||
if (options.runAutoTerminals) {
|
||||
await runWorktreeAutoTerminals({
|
||||
workspaceId,
|
||||
worktree,
|
||||
workspaceCwd,
|
||||
terminalManager: dependencies.terminalManager,
|
||||
logger: dependencies.sessionLogger,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WorktreeSetupError) {
|
||||
|
||||
@@ -186,6 +186,7 @@ export type WorktreeSource =
|
||||
changeRequestNumber: number;
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
headRepository?: string;
|
||||
baseRefName: string;
|
||||
checkoutRefs?: WorktreeCheckoutRef[];
|
||||
localBranchName?: string;
|
||||
|
||||
Reference in New Issue
Block a user