Run multiple independent workspaces per directory (#1539)
* feat(workspace): bind agents/terminals/status to workspaceId (Model B phase 1)
Model B EX-Phase 1: the binding layer. Records gain an additive
workspaceId that answers "which workspace does this belong to" without
replacing the existing required cwd.
- protocol: add optional workspaceId to agent/terminal records and a
workspaceOwnership capability under server_info.features.*
- server: resolve and persist workspaceId on agent and terminal
creation; carry it through agent-manager, projections, storage,
loading, sessions, and the terminal manager/worker pipeline
- workspace-directory / workspace-registry-model: derive and expose
workspace ownership; bind status to workspaceId
- client: surface workspaceId via daemon-client
- app: filter terminals and agent visibility by workspaceId, thread it
through workspace screen / terminal panel / session store
- tests: cover workspaceId binding across create, agent-manager,
mcp-server, workspace-directory, workspace-registry-model, and
agent-visibility
cwd stays required on every record; workspaceId is purely additive and
back-compatible (optional/defaulted), so old and new peers still parse
each other's messages.
Known follow-ups: none.
* feat(workspace): empty projects + editable titles, fix same-cwd terminal isolation (Model B phase 2)
- Address Phase 1 review fixes and add same-cwd terminal isolation e2e coverage
- Isolate terminal subscriptions per workspaceId so two workspaces sharing one cwd no longer cross-wire terminals (terminal-subscription-key)
- Add editable workspace title, decoupled from the backing directory/branch
- Persist empty projects (no workspaces yet) across daemon restarts
- Add e2e coverage: empty-project-persists (server + app), workspace-same-cwd-isolation, sidebar-workspace-rename
Known follow-ups: none
* feat(workspace): create multiple workspaces per directory — local or worktree (Model B phase 3)
- workspace.create RPC always creates a new workspace record; no directory dedup
- dropped directory dedup in open_project and createPaseoWorktree paths
- non-git directories are first-class: local-checkout workspaces no longer require a repo
- service-proxy collision resolved by defaulting to the owning workspace instead of failing
- new creation UI: choose backing directory (local checkout or worktree) per workspace
- e2e coverage for creating multiple workspaces over the same directory
Known follow-ups:
- COMPAT cwd->workspaceId resolver remains until client floor advances
- prune any remaining workspace==directory assumptions surfaced by usage
* feat(cli): paseo run workspace policy — bare run creates a workspace, --workspace/PASEO_WORKSPACE_ID target (Model B phase 4)
- A workspace is now the explicit home of a `paseo run`: run.ts resolves a
workspace before creating any agent, then stamps the agent with that
workspaceId — no run leans on createAgent's legacy cwd->workspace fallback.
- Precedence: --workspace <id> > $PASEO_WORKSPACE_ID > --worktree > bare run.
--worktree mints its own workspace and overrides the ambient
PASEO_WORKSPACE_ID; --worktree + --workspace is rejected upstream.
- New-workspace-per-bare-run: every bare run mints a fresh local-backed
workspace for its cwd rather than reattaching to an existing one in that dir.
- Env support: $PASEO_WORKSPACE_ID (exported by workspace terminals) targets an
existing workspace, same as --workspace.
- Help text: new --workspace option documents the default (new workspace per
run) and the env fallback; created runs print the workspace id plus a tip.
- e2e: cli-run-workspace-precedence.e2e.test.ts covers bare, distinct-per-cwd,
--workspace attach, and env attach against an isolated daemon.
Known follow-ups:
- createAgent's COMPAT cwd->workspace resolver stays for old clients; the CLI no
longer relies on it but it is not yet removable.
* feat(workspace): archive removes the workspace record, never the directory; last worktree ref offers disk delete (Model B phase 5)
- Archive is now scoped to a single workspace RECORD (by workspaceId), not by cwd/worktree path. It tears down only the agents and terminals owned by the target workspaceId.
- Sibling isolation: a directory can back multiple workspaces, so archiving one workspace no longer destroys a sibling workspace's agents, terminals, or directory.
- Decoupled on-disk worktree deletion into an explicit, last-reference-only option (deleteWorktreeFromDisk). The directory is removed only when the archived workspace was the last active reference to a Paseo-owned worktree; local checkouts are never deleted.
- Unified archive UI with a keep/delete prompt: "Keep on disk" is the default non-destructive choice, "Delete" removes the worktree directory.
- Protocol: new optional, defaulted deleteWorktreeFromDisk field (COMPAT-tagged, back-compat preserved).
- Tests: record-scoped archive e2e (sibling isolation, last-ref disk delete, sibling-keeps-directory) and an app e2e for the keep prompt.
Known follow-ups:
- Surface the keep/delete prompt's "delete" path coverage in app e2e (only the keep path is exercised today).
- COMPAT(worktreeDiskDeletion): drop the optional gate when floor >= v0.1.97.
* feat(workspace): uniform expandable projects + status inbox; merged worktrees cleaned, explicit-id archive (Model B phase 6)
- P5 archive fixes: worktree archive now targets an explicit workspaceId, and auto-archive-on-merge cleans the worktree directory from disk when it is the last reference.
- Dropped non-git project flattening: every project — git or non-git, single- or multi-workspace — renders as the same expandable parent.
- Every project is expandable and every workspace is archivable; each carries its own "+ New workspace" affordance regardless of kind.
- Added a status inbox grouping (Ready for review / Working / Done) where each workspace is bucketed independently by last update.
- The deepest sidebar level is the workspace row: tabs, agents, and terminals never appear in the sidebar.
- Added Model B sidebar e2e coverage proving the expandable-parent, no-leaf, and independent-status-bucketing invariants.
Known follow-ups: the inline header FolderPlus worktree shortcut stays git-only (canCreateWorktreeForProjectKind); non-git projects create workspaces via the "+ New workspace" row.
* fix(protocol): use literal :: separator in terminal subscription key
The committed blob contained a NUL byte as the separator instead of the
:: the comment documents, which broke text diffs and review tooling
(git flagged the file as binary). Functionally the key is an in-memory
Map key only, but source must stay text.
* feat(workspace): shared directory right-sidebar boundary + docs; final consolidation (Model B phase 7)
- Pin and document the directory-backed vs workspace-owned right-sidebar boundary:
same-cwd workspaces share directory-keyed git/PR/file surfaces but never share
workspace-owned drafts, attachments, or file-explorer state. New docs section in
architecture.md plus data-model.md keying convention and two glossary terms.
- Add e2e coverage for the boundary: same-directory-workspaces.spec.ts proves the
right sidebar is shared (a directory change appears in both same-dir workspaces)
while tabs/terminals stay independent per workspace.
- Accumulated follow-up fixes:
- MCP child agents created in a new worktree are stamped with the new worktree's
workspaceId (not the parent's, not unstamped), mirroring the session path so
workspaceId-scoped archive can find and tear them down.
- session-store mergeWorkspaces prunes a stale empty-project descriptor when a
workspace lands in that project, so it stops governing the project's metadata.
- CLI run: reject --worktree alongside an ambient PASEO_WORKSPACE_ID (not just
--workspace), so worktree resolution never races an existing-workspace select.
New run.test.ts pins the three validation outcomes.
- Full-branch reshape outcomes:
- Consolidate the four placeholder server_info.features flags (workspaceOwnership,
workspaceTitles, workspaceProjects, workspaceMultiplicity) into the single
workspaceMultiplicity capability gate.
- Remove the now-dead ensureLocalCheckoutWorkspace and its deps interface; explicit
creation always mints a new same-cwd record via createLocalCheckoutWorkspace.
- Resolve every Model B COMPAT marker from v0.1.X to v0.1.97.
- Drop the obsolete workspaceOwnership feature assertion from the same-cwd
isolation e2e; broaden a sidebar-shortcuts test to cover both project kinds.
- Add workspace-create-errors.e2e.test.ts pinning each early-reject error branch.
- typecheck, lint, format all green.
Known follow-ups: pre-existing COMPAT(rewind) markers still carry v0.1.X placeholders
(out of Model B scope, left untouched).
* test(workspace): de-slop Model B tests per audit-tests (reshape mocks/assertions)
Reshape Model B test changes flagged by audit-tests so they exercise real
behavior and observable state instead of mock scaffolding.
Categories fixed:
- Mocks -> real dependencies + persisted state: create.test.ts and
mcp-server.test.ts dropped hand-built AgentManager/AgentStorage mock objects
and mock.calls[...] assertions, now run the real AgentManager/AgentStorage
with a fake agent client and assert on the stored agent record (workspaceId,
parent label).
- Module-internal spying -> injected seam: acp-agent stopped spying on the
tree-kill module (vi.spyOn(treeKillModule, ...)). Added a ProcessTerminator
injection seam to ACPAgentClient/ACPAgentSession (production defaults to
terminateWithTreeKill); tests inject a typed FakeTerminator and assert on the
recorded children plus observable stream .destroyed state.
- Poking internals -> public API: acp close()/killTerminal/releaseTerminal
tests now create terminals through the public createTerminal API instead of
mutating internals.terminalEntries.
- Reimplemented production logic deleted: cli-run-workspace-precedence dropped
its inline copy of resolveRunWorkspace flag precedence (a fake reimplementation
it then asserted against) and now proves only the daemon behaviors the CLI
builds on; flag precedence stays covered in the CLI's own run.test.ts.
- Sleep -> poll: workspace-same-cwd-isolation replaced a fixed setTimeout with
expect.poll on the observed snapshot state to remove the race.
- Internal protocol-frame assertions removed: sidebar-workspace-rename dropped
the captureWsSessionFrames workspace.title.set.request assertions, relying on
the user-visible rename + reload checks already present.
* fix: green CI — new-workspace status bucket regression + branch-picker flow tests + sdk emptyProjects
Source regression:
- packages/app/src/screens/new-workspace-screen.tsx
- packages/app/src/screens/new-workspace-empty.ts
When a new workspace is created with an initial agent, optimistically merge it
with status "running" (statusEnteredAt now) so it lands in the "Working" bucket
instead of defaulting into the wrong bucket. The empty-workspace path passes
withInitialAgent: false so a bare workspace keeps its descriptor status.
Test-drift (intentional P3 flow change — backing picker now required):
- packages/app/e2e/new-workspace.spec.ts
- packages/app/e2e/helpers/new-workspace.ts
The reshaped creation flow requires choosing a backing ("New worktree") before
the branch / starting-ref picker is reachable. Branch-picker specs now call
selectWorkspaceBacking(page, "worktree") first, and the helper waits for the
worktree control to drop aria-disabled (it stays disabled until the checkout
status query confirms the project is a git repo) before clicking.
Test-drift (protocol field added — sdk emptyProjects):
- packages/client/src/index.test.ts
fetch_workspaces response now carries emptyProjects; the toEqual expectation
includes emptyProjects: [].
Flake hardening:
- packages/app/e2e/same-directory-workspaces.spec.ts
- packages/app/e2e/helpers/seed-client.ts
Out-of-band working-tree writes raced the daemon's debounced filesystem
watcher, so the UI could subscribe before the new file was in the git snapshot.
Force a checkout refresh (same path as the UI's manual refresh) to make the
write authoritative before asserting, removing the timing dependency.
* test(app): expect withInitialAgent:false in empty-workspace create call
The withInitialAgent flag (fix for the new-workspace Done-bucket regression)
added a field to the ensureWorkspace call; the empty-path unit test pinned the
exact args. Test-drift, not behavior — update the expectation.
* feat(app): stack new-workspace creation params (Project / Isolation dropdown / Base) with reserved base row + keyboard avoidance
Restructure the new-workspace creation screen into a vertical stack of
Project / Isolation / Base parameters instead of the previous mixed
layout.
- Isolation/backing is now a dropdown (Local vs New worktree) matching the
app's existing dropdown/combobox primitives, replacing the inline
segmented switcher.
- The Base (starting ref) row now reserves its space even when the backing
is Local and the picker is hidden, so switching backing no longer causes
the form to shift vertically.
- The form avoids the keyboard so the title input and submit stay visible
while typing on native and web.
- Updated the e2e new-workspace helper to drive the dropdown-based
backing selector and the reserved Base row.
* fix(app): simplify new-workspace form — stacked ghost rows above composer, no card/title, project-matched dropdowns
- Render the three rows (formStack) at the top, under the "New workspace"
heading and above the composer input, not in the composer footer.
- Drop the card/surface/border chrome; rows sit on the plain background.
- Stack Project, Isolation (multiplicity only), and Base; remove the title
field — the title is set server-side and create works with no user title.
- Each row is a Label immediately followed by its dropdown control; no
columns, fixed label widths, or reserved horizontal space, with the label
glyph aligned to the heading's text x.
- All three triggers share the ghost badge style of the Project control
(ProjectPickerTrigger / IsolationPickerTrigger / RefPickerTrigger +
Combobox); keep the workspace-create-backing-* test IDs.
- Omit "New worktree" from Isolation entirely when the project is non-git.
- Reserve the Base row height always but render nothing on Local backing,
showing the Base label + ref picker only for New worktree.
- Keep keyboard avoidance on the centered content.
- E2E: drive selectWorkspaceBacking via the Isolation trigger + Combobox and
remove the now-gone workspace-create-title-input usage and title field.
* feat(app): sidebar new-workspace entry points + non-git isolation hidden + project auto-select
- Q1: hide the Isolation control on non-git projects — gate the row on canCreateWorktree (multiplicity && selectedIsGit) so a project with no git checkout never offers a worktree backing choice.
- Q2: reset the stale project preselect across the reused 'new' screen — clear the manual picker choice on route project identity change so each route-driven navigation preselects its own project; align the nav verb to router.navigate.
- Q3: remove the per-project "+ New workspace" sidebar row and add one global "New workspace" entry above Sessions in both mobile and desktop sidebars (testID sidebar-global-new-workspace); creation stays reachable per-project via the existing git new-worktree icon.
- Q4: match the Sessions / New-workspace header button sizing to the workspace rows — SidebarHeaderRow icon md->sm, label fontSize base->sm.
- e2e: new new-workspace-entry.spec covering global entry, project preselect reset across reused screen, and non-git isolation hidden; update sidebar-model-b, empty-project-persists, workspace-multiplicity, and helpers for the removed per-project row.
* fix(app): group New-workspace/Sessions header (no divider, workspace-row sized) + remove banned useUnistyles
- Q5: wrap the New-workspace and Sessions header entries in a single sidebarHeaderGroup that owns one bottom divider, so the two rows sit tight together with no gap and no per-row separator (both mobile and desktop sidebars).
- Add a compact variant to SidebarHeaderRow: workspace-row sized (minHeight 36, surfaceSidebarHover, borderRadius.lg) with horizontal padding that aligns its icon/label with the Workspaces section title and the workspace rows below; the default header variant (settings Back-to-workspace) keeps its sidebar-height row and own separator.
- Remove the banned useUnistyles() from sidebar-header-row.tsx per docs/unistyles.md: theme-reactive icon color now goes through withUnistyles(Icon) + uniProps mappings; static sizing reads ICON_SIZE.
- Taste: new-workspace-screen dedupes the project-icon styles (single projectIcon/projectIconFallback/projectIconFallbackText) and replaces repeated magic numbers with BADGE_HEIGHT and a named fallback-font-size constant.
* fix(app): no layout shift on git<->non-git (reserve Isolation row) + symmetric sidebar divider spacing
- Q6: Isolation row reserves its height and renders an invisible spacer for non-git projects, matching the Base-row pattern, so switching between git and non-git projects keeps a constant form height with no layout shift.
- Q7: sidebar header group splits paddingVertical into paddingTop/paddingBottom so the Sessions-row-to-divider gap equals the divider-to-Workspaces-header gap, centering the divider.
* feat(app): rename Sessions to History (clock icon, Agent history header)
- Sidebar label now reads "History" with a clock icon (was Sessions / MessagesSquare)
- Sessions screen header now reads "Agent history"
- Updated i18n strings across all 6 locales (en, ar, ru, zh-CN, fr, es)
- Route and testIDs unchanged (sidebar-sessions, /sessions)
* fix(app): symmetric sidebar header padding (top == bottom), traffic-light inset preserved
The sidebarHeaderGroup wrapper (New-workspace + History rows) had paddingTop: theme.spacing[1] against paddingBottom: theme.spacing[2]. Equalize paddingTop to theme.spacing[2] so the header group's top padding matches its bottom padding. The divider stays centered since paddingBottom still matches WorkspacesSectionHeader's paddingTop. The desktop window-controls (traffic-light) spacer — paddingTopSpacerStyle plus TitlebarDragRegion — is a separate inset and is left untouched.
* fix(app): hover card shows branch; sidebar copy-branch copies the real branch (not the title)
- Add SidebarWorkspaceEntry.currentBranch, sourced from gitRuntime.currentBranch (normalized; detached HEAD/blank/missing -> null)
- Fix copy-branch smear: handleCopyBranchName copied workspace.name (the title); now copies the real currentBranch (guarded)
- Workspace hover card: add a branch row (GitBranch icon + branch name), shown only when it differs from the title
- Header branch-switcher left untouched; diff-pane re-home decision still pending
* feat(app): branch switcher moves into the git diff panel; header title is static (Model B coherence)
- branch switcher now lives in the diff panel Changes header on desktop+mobile
- workspace header title is a plain static title; branch removed from it
- no new workspace-screen header row
- git diff-stat unchanged, stays where it is
- no duplicate git actions
- unified descriptor name fallbacks via resolveWorkspaceName
- rename-then-switch e2e proves header title and real branch stay independent
This commit is contained in:
@@ -63,7 +63,10 @@ export async function ensureAgentLoaded(
|
||||
if (!config) {
|
||||
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
|
||||
}
|
||||
snapshot = await deps.agentManager.createAgent(config, agentId, { labels: record.labels });
|
||||
snapshot = await deps.agentManager.createAgent(config, agentId, {
|
||||
labels: record.labels,
|
||||
workspaceId: record.workspaceId,
|
||||
});
|
||||
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ManagedAgent,
|
||||
} from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { formatSystemNotificationPrompt } from "./agent-prompt.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
@@ -930,6 +931,39 @@ test("createAgent passes persistSession to provider create options", async () =>
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("createAgent persists workspaceId on the stored record and emits it in the snapshot", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-0000000000a1",
|
||||
});
|
||||
|
||||
try {
|
||||
const agent = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: "wks_owner" },
|
||||
);
|
||||
|
||||
expect(agent.workspaceId).toBe("wks_owner");
|
||||
expect(toAgentPayload(agent).workspaceId).toBe("wks_owner");
|
||||
|
||||
const record = await storage.get(agent.id);
|
||||
expect(record?.workspaceId).toBe("wks_owner");
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createAgent injects paseo MCP server only into provider launch config", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -250,6 +250,12 @@ interface ManagedAgentBase {
|
||||
id: string;
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
/**
|
||||
* Workspace this agent belongs to, stamped at creation. Independent of cwd:
|
||||
* cwd answers "where does it run", workspaceId answers "which workspace owns it".
|
||||
* Null/undefined for legacy agents created before ownership stamping.
|
||||
*/
|
||||
workspaceId?: string;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
config: AgentSessionConfig;
|
||||
runtimeInfo?: AgentRuntimeInfo;
|
||||
@@ -853,6 +859,7 @@ export class AgentManager {
|
||||
env?: Record<string, string>;
|
||||
persistSession?: boolean;
|
||||
initialTitle?: string | null;
|
||||
workspaceId?: string;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||
@@ -867,6 +874,7 @@ export class AgentManager {
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
labels: options?.labels,
|
||||
initialTitle: options?.initialTitle,
|
||||
workspaceId: options?.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -889,6 +897,7 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
const resolvedAgentId = validateAgentId(
|
||||
@@ -1019,6 +1028,7 @@ export class AgentManager {
|
||||
// Preserve existing labels and timeline during reload.
|
||||
return this.registerSession(session, storedConfig, agentId, {
|
||||
labels: existing.labels,
|
||||
workspaceId: existing.workspaceId,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
lastUserMessageAt: existing.lastUserMessageAt,
|
||||
@@ -1202,6 +1212,7 @@ export class AgentManager {
|
||||
id: record.id,
|
||||
provider: record.provider,
|
||||
cwd: record.cwd,
|
||||
workspaceId: record.workspaceId,
|
||||
session: null,
|
||||
capabilities: STORED_AGENT_CAPABILITIES,
|
||||
config: buildStoredAgentConfig(record),
|
||||
@@ -2333,6 +2344,7 @@ export class AgentManager {
|
||||
attention?: AttentionState;
|
||||
initialTitle?: string | null;
|
||||
publishWhenReady?: boolean;
|
||||
workspaceId?: string;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
||||
@@ -2432,6 +2444,7 @@ export class AgentManager {
|
||||
lastError?: string;
|
||||
attention?: AttentionState;
|
||||
persistence?: AgentPersistenceHandle;
|
||||
workspaceId?: string;
|
||||
}
|
||||
| undefined;
|
||||
}): ActiveManagedAgent {
|
||||
@@ -2440,6 +2453,7 @@ export class AgentManager {
|
||||
id: resolvedAgentId,
|
||||
provider: config.provider,
|
||||
cwd: config.cwd,
|
||||
workspaceId: options?.workspaceId,
|
||||
session,
|
||||
capabilities: session.capabilities,
|
||||
config,
|
||||
|
||||
@@ -73,6 +73,7 @@ export function toStoredAgentRecord(
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
workspaceId: agent.workspaceId,
|
||||
createdAt,
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
lastActivityAt: agent.updatedAt.toISOString(),
|
||||
@@ -110,6 +111,7 @@ export function toAgentPayload(
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}),
|
||||
model: agent.config.model ?? null,
|
||||
thinkingOptionId,
|
||||
effectiveThinkingOptionId,
|
||||
@@ -210,6 +212,7 @@ export function buildStoredAgentPayload(
|
||||
id: record.id,
|
||||
provider: record.provider,
|
||||
cwd: record.cwd,
|
||||
...(record.workspaceId ? { workspaceId: record.workspaceId } : {}),
|
||||
model: record.config?.model ?? null,
|
||||
thinkingOptionId: record.config?.thinkingOptionId ?? null,
|
||||
effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({
|
||||
|
||||
@@ -36,6 +36,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
id: z.string(),
|
||||
provider: z.string(),
|
||||
cwd: z.string(),
|
||||
workspaceId: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
lastActivityAt: z.string().optional(),
|
||||
|
||||
@@ -3,7 +3,10 @@ import type pino from "pino";
|
||||
|
||||
import type { GitHubService } from "../../services/github-service.js";
|
||||
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
|
||||
import { archivePaseoWorktree } from "../paseo-worktree-archive-service.js";
|
||||
import {
|
||||
type ActiveWorkspaceRef,
|
||||
archivePaseoWorktree,
|
||||
} from "../paseo-worktree-archive-service.js";
|
||||
import type {
|
||||
CreatePaseoWorktreeWorkflowFn,
|
||||
CreatePaseoWorktreeWorkflowResult,
|
||||
@@ -27,14 +30,14 @@ interface CreateAgentLifecycleDispatchDependencies {
|
||||
createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn;
|
||||
archiveAgentForClose: (agentId: string) => Promise<unknown>;
|
||||
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
emit: (message: SessionOutboundMessage) => void;
|
||||
emitAgentRemove: (agentId: string) => void;
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
|
||||
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
|
||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
|
||||
killTerminalsForWorkspace: (workspaceId: string) => Promise<void>;
|
||||
logger: pino.Logger;
|
||||
}
|
||||
|
||||
@@ -209,12 +212,12 @@ export class CreateAgentLifecycleDispatch {
|
||||
agentManager: this.dependencies.agentManager,
|
||||
agentStorage: this.dependencies.agentStorage,
|
||||
resolveWorkspaceIdForCwd: this.dependencies.resolveWorkspaceIdForCwd,
|
||||
listActiveWorkspaces: this.dependencies.listActiveWorkspaces,
|
||||
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
|
||||
clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving,
|
||||
isPathWithinRoot: this.dependencies.isPathWithinRoot,
|
||||
killTerminalsUnderPath: this.dependencies.killTerminalsUnderPath,
|
||||
killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace,
|
||||
sessionLogger: this.dependencies.logger,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,9 +1,44 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { createTestAgentClients } from "../../test-utils/fake-agent-client.js";
|
||||
import { createProviderSnapshotManagerStub } from "../../test-utils/session-stubs.js";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import { AgentStorage } from "../agent-storage.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
import { createAgentCommand } from "./create.js";
|
||||
import type { ManagedAgent } from "../agent-manager.js";
|
||||
|
||||
const logger = createTestLogger();
|
||||
|
||||
function createRealAgentManager(storage: AgentStorage): AgentManager {
|
||||
return new AgentManager({
|
||||
clients: createTestAgentClients(),
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
// Creates a worktree directory under repoRoot and reports it back as a fresh
|
||||
// workspace so the command can stamp the agent with it (mirrors the production
|
||||
// worktree service).
|
||||
function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: string }) {
|
||||
const worktreePath = join(args.repoRoot, "worktree");
|
||||
mkdirSync(worktreePath, { recursive: true });
|
||||
return async (): Promise<CreatePaseoWorktreeWorkflowResult> =>
|
||||
({
|
||||
worktree: { worktreePath },
|
||||
intent: {},
|
||||
workspace: { workspaceId: args.createdWorkspaceId },
|
||||
repoRoot: args.repoRoot,
|
||||
created: true,
|
||||
setupContinuation: { kind: "agent" as const, startAfterAgentCreate: () => {} },
|
||||
}) as unknown as CreatePaseoWorktreeWorkflowResult;
|
||||
}
|
||||
|
||||
test("session create forwards clientMessageId to the initial prompt run options", async () => {
|
||||
const snapshot = {
|
||||
id: "agent-1",
|
||||
@@ -44,3 +79,122 @@ test("session create forwards clientMessageId to the initial prompt run options"
|
||||
messageId: "msg-create-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("session create stamps the requested workspaceId when no worktree setup runs", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const agentManager = createRealAgentManager(storage);
|
||||
|
||||
try {
|
||||
const { snapshot } = await createAgentCommand(
|
||||
{
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
||||
},
|
||||
{
|
||||
kind: "session",
|
||||
config: { provider: "codex", cwd: workdir },
|
||||
workspaceId: "ws-source",
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
explicitTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
},
|
||||
);
|
||||
|
||||
const stored = await storage.get(snapshot.id);
|
||||
expect(stored?.workspaceId).toBe("ws-source");
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("session create stamps the new worktree's workspaceId when a setup continuation runs", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const agentManager = createRealAgentManager(storage);
|
||||
|
||||
try {
|
||||
const { snapshot } = await createAgentCommand(
|
||||
{
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
||||
},
|
||||
{
|
||||
kind: "session",
|
||||
config: { provider: "codex", cwd: workdir },
|
||||
workspaceId: "ws-source",
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
explicitTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({
|
||||
sessionConfig: config,
|
||||
setupContinuation: { kind: "agent", startAfterAgentCreate: () => {} },
|
||||
createdWorkspaceId: "ws-new-worktree",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const stored = await storage.get(snapshot.id);
|
||||
expect(stored?.workspaceId).toBe("ws-new-worktree");
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("mcp create stamps the new worktree's workspaceId, not the parent's", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const agentManager = createRealAgentManager(storage);
|
||||
const providerSnapshotManager = createProviderSnapshotManagerStub().manager;
|
||||
|
||||
try {
|
||||
const { snapshot: parent } = await createAgentCommand(
|
||||
{ agentManager, agentStorage: storage, logger, providerSnapshotManager },
|
||||
{
|
||||
kind: "session",
|
||||
config: { provider: "codex", cwd: workdir },
|
||||
workspaceId: "ws-parent",
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
explicitTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
},
|
||||
);
|
||||
|
||||
const { snapshot: child } = await createAgentCommand(
|
||||
{
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
providerSnapshotManager,
|
||||
createPaseoWorktree: fakeWorktreeCreator({
|
||||
repoRoot: workdir,
|
||||
createdWorkspaceId: "ws-new-worktree",
|
||||
}),
|
||||
},
|
||||
{
|
||||
kind: "mcp",
|
||||
provider: "codex/gpt-5.4",
|
||||
title: "child",
|
||||
initialPrompt: "do the thing",
|
||||
background: true,
|
||||
notifyOnFinish: false,
|
||||
callerAgentId: parent.id,
|
||||
worktree: { worktreeName: "feature", baseBranch: "main" },
|
||||
},
|
||||
);
|
||||
|
||||
const storedChild = await storage.get(child.id);
|
||||
expect(storedChild?.workspaceId).toBe("ws-new-worktree");
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
export interface CreateAgentSessionWorktreeResult {
|
||||
sessionConfig: AgentSessionConfig;
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
// Set when this build created a fresh worktree workspace. The agent must be
|
||||
// stamped with it so workspaceId-scoped archive can find the agent later.
|
||||
createdWorkspaceId?: string;
|
||||
}
|
||||
|
||||
interface CreateAgentCommandDependencies {
|
||||
@@ -132,6 +135,7 @@ interface AgentCreateOptions {
|
||||
initialPrompt?: string;
|
||||
env?: Record<string, string>;
|
||||
initialTitle?: string | null;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export async function createAgentCommand(
|
||||
@@ -184,7 +188,7 @@ async function resolveSessionCreateAgent(
|
||||
input: CreateAgentFromSessionInput,
|
||||
): Promise<ResolvedCreateAgent> {
|
||||
const trimmedPrompt = input.initialPrompt?.trim();
|
||||
const { sessionConfig, setupContinuation } = await input.buildSessionConfig(
|
||||
const { sessionConfig, setupContinuation, createdWorkspaceId } = await input.buildSessionConfig(
|
||||
input.config,
|
||||
input.git,
|
||||
input.worktreeName,
|
||||
@@ -208,6 +212,10 @@ async function resolveSessionCreateAgent(
|
||||
initialPrompt: trimmedPrompt,
|
||||
env: input.env,
|
||||
initialTitle: input.provisionalTitle,
|
||||
// A legacy git/worktreeName worktree creates a fresh workspace, so the
|
||||
// agent belongs to that workspace, not the source one (mirrors the MCP
|
||||
// path). createdWorkspaceId is the freshly created worktree's workspace.
|
||||
workspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId,
|
||||
},
|
||||
metadataInitialPrompt: trimmedPrompt,
|
||||
prompt: hasPromptContent ? prompt : undefined,
|
||||
@@ -239,13 +247,20 @@ async function resolveMcpCreateAgent(
|
||||
allowCustomCwd: input.callerContext?.allowCustomCwd ?? true,
|
||||
})
|
||||
: expandUserPath(input.cwd ?? process.cwd());
|
||||
const { resolvedCwd, setupContinuation } = await resolveMcpCwd({
|
||||
const { resolvedCwd, setupContinuation, createdWorkspaceId } = await resolveMcpCwd({
|
||||
dependencies,
|
||||
cwd,
|
||||
worktree: input.worktree,
|
||||
initialPrompt: input.initialPrompt,
|
||||
});
|
||||
|
||||
// A child agent created in its parent's working tree belongs to the parent's
|
||||
// workspace. When a new worktree is created the child lives in that fresh
|
||||
// workspace, so it is stamped with the new worktree's workspaceId instead
|
||||
// (mirrors the session path) — keeping the agent discoverable by
|
||||
// workspaceId-scoped archive.
|
||||
const workspaceId = setupContinuation ? createdWorkspaceId : parentAgent?.workspaceId;
|
||||
|
||||
const { modeId: resolvedMode, featureValues: resolvedFeatures } =
|
||||
await dependencies.providerSnapshotManager.resolveCreateConfig({
|
||||
cwd: resolvedCwd,
|
||||
@@ -274,7 +289,13 @@ async function resolveMcpCreateAgent(
|
||||
thinkingOptionId: input.thinking,
|
||||
...(resolvedFeatures ? { featureValues: resolvedFeatures } : {}),
|
||||
},
|
||||
createOptions: labels ? { labels } : undefined,
|
||||
createOptions:
|
||||
labels || workspaceId
|
||||
? {
|
||||
...(labels ? { labels } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
}
|
||||
: undefined,
|
||||
metadataInitialPrompt: trimmedPrompt,
|
||||
prompt: trimmedPrompt,
|
||||
explicitTitle: input.title.trim(),
|
||||
@@ -387,7 +408,11 @@ async function resolveMcpCwd(params: {
|
||||
cwd: string;
|
||||
initialPrompt: string;
|
||||
worktree: CreateAgentFromMcpInput["worktree"];
|
||||
}): Promise<{ resolvedCwd: string; setupContinuation?: AgentWorktreeSetupContinuation }> {
|
||||
}): Promise<{
|
||||
resolvedCwd: string;
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
createdWorkspaceId?: string;
|
||||
}> {
|
||||
const { dependencies, worktree } = params;
|
||||
if (!worktree) {
|
||||
return { resolvedCwd: params.cwd };
|
||||
@@ -443,6 +468,7 @@ async function resolveMcpCwd(params: {
|
||||
return {
|
||||
resolvedCwd: createdWorktree.worktree.worktreePath,
|
||||
setupContinuation: createdWorktree.setupContinuation,
|
||||
createdWorkspaceId: createdWorktree.workspace.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { realpathSync, rmSync } from "node:fs";
|
||||
import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join, resolve as resolvePath } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -10,8 +10,9 @@ import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { createAgentMcpServer } from "./mcp-server.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
import { AgentManager, type ManagedAgent } from "./agent-manager.js";
|
||||
import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import type { AgentMode, AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js";
|
||||
import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.js";
|
||||
import {
|
||||
@@ -1068,12 +1069,14 @@ describe("create_agent MCP tool", () => {
|
||||
expect(broadcasts[0]).toBe(createdWorkspaceIds[0]);
|
||||
expect(setupContinuations).toEqual(["agent"]);
|
||||
expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]);
|
||||
// The agent is stamped with the freshly created worktree's workspaceId so
|
||||
// workspaceId-scoped archive can find and tear it down later.
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: expect.stringContaining("agent-worktree"),
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
{ workspaceId: createdWorkspaceIds[0] },
|
||||
);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
@@ -1329,7 +1332,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/tmp/worktrees/pr-123" }),
|
||||
undefined,
|
||||
undefined,
|
||||
{ workspaceId: "ws-pr-123" },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
@@ -1445,6 +1448,7 @@ describe("create_agent MCP tool", () => {
|
||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
|
||||
>,
|
||||
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"),
|
||||
listActiveWorkspaces: vi.fn(async () => []),
|
||||
archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving,
|
||||
@@ -1528,6 +1532,7 @@ describe("create_agent MCP tool", () => {
|
||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
|
||||
>,
|
||||
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-mcp"),
|
||||
listActiveWorkspaces: vi.fn(async () => []),
|
||||
archiveWorkspaceRecord: vi.fn(async () => undefined),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined),
|
||||
markWorkspaceArchiving: vi.fn(),
|
||||
@@ -1871,6 +1876,45 @@ describe("create_agent MCP tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("inherits the parent's workspaceId when an MCP child is created in the parent's working tree", async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-workspace-inherit-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const agentManager = new AgentManager({
|
||||
clients: createTestAgentClients(),
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const parent = await agentManager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
const result = await tool.handler({
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
});
|
||||
|
||||
const childId = z.object({ agentId: z.string() }).parse(result.structuredContent).agentId;
|
||||
const storedChild = await storage.get(childId);
|
||||
expect(storedChild?.workspaceId).toBe("wks_parent");
|
||||
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBe(parent.id);
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("delegates MCP injection to AgentManager and passes through an undefined agent ID", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
|
||||
@@ -30,7 +30,7 @@ import type { AgentStorage } from "./agent-storage.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { isStoredAgentProviderAvailable } from "../persistence-hooks.js";
|
||||
import {
|
||||
killTerminalsUnderPath,
|
||||
killTerminalsForWorkspace,
|
||||
type ArchivePaseoWorktreeDependencies,
|
||||
} from "../paseo-worktree-archive-service.js";
|
||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||
@@ -94,6 +94,7 @@ export interface AgentMcpServerOptions {
|
||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
|
||||
>;
|
||||
resolveWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["resolveWorkspaceIdForCwd"];
|
||||
listActiveWorkspaces?: ArchivePaseoWorktreeDependencies["listActiveWorkspaces"];
|
||||
archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"];
|
||||
emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
|
||||
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
|
||||
@@ -2218,6 +2219,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
worktreeSlug,
|
||||
// This tool's contract is to delete the worktree; on-disk removal still
|
||||
// only happens when no sibling workspace references the directory.
|
||||
deleteWorktreeFromDisk: true,
|
||||
},
|
||||
);
|
||||
if (!result.ok) {
|
||||
@@ -2410,6 +2414,9 @@ function archiveWorktreeDependencies(
|
||||
if (!options.resolveWorkspaceIdForCwd) {
|
||||
throw new Error("Workspace resolver is required to archive worktrees");
|
||||
}
|
||||
if (!options.listActiveWorkspaces) {
|
||||
throw new Error("Active workspace lister is required to archive worktrees");
|
||||
}
|
||||
if (!options.emitWorkspaceUpdatesForWorkspaceIds) {
|
||||
throw new Error("Workspace update emitter is required to archive worktrees");
|
||||
}
|
||||
@@ -2427,20 +2434,18 @@ function archiveWorktreeDependencies(
|
||||
agentManager: context.agentManager,
|
||||
agentStorage: context.agentStorage,
|
||||
resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd,
|
||||
listActiveWorkspaces: options.listActiveWorkspaces,
|
||||
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving: options.markWorkspaceArchiving,
|
||||
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
|
||||
isPathWithinRoot: isSameOrDescendantPath,
|
||||
killTerminalsUnderPath: (rootPath: string) =>
|
||||
killTerminalsUnderPath(
|
||||
killTerminalsForWorkspace: (workspaceId: string) =>
|
||||
killTerminalsForWorkspace(
|
||||
{
|
||||
terminalManager: context.terminalManager,
|
||||
isPathWithinRoot: isSameOrDescendantPath,
|
||||
killTrackedTerminal: () => {},
|
||||
sessionLogger: context.logger,
|
||||
},
|
||||
rootPath,
|
||||
workspaceId,
|
||||
),
|
||||
sessionLogger: context.logger,
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
resolveACPModelSelection,
|
||||
summarizeACPRequestError,
|
||||
} from "./acp-agent.js";
|
||||
import * as treeKillModule from "../../../utils/tree-kill.js";
|
||||
import type { ProcessTerminator, TreeKillTarget } from "../../../utils/tree-kill.js";
|
||||
import {
|
||||
COPILOT_ALLOW_ALL_MODE_ID,
|
||||
COPILOT_MODES,
|
||||
@@ -86,7 +86,7 @@ interface ACPConfiguredOverrideInternals {
|
||||
applyConfiguredOverrides(): Promise<void>;
|
||||
}
|
||||
|
||||
function createSession(): ACPAgentSession {
|
||||
function createSession(terminateProcess?: ProcessTerminator): ACPAgentSession {
|
||||
return new ACPAgentSession(
|
||||
{
|
||||
provider: "claude-acp",
|
||||
@@ -105,10 +105,36 @@ function createSession(): ACPAgentSession {
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
...(terminateProcess ? { terminateProcess } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Typed substitute for the real tree-kill terminator. Records which child
|
||||
// processes it was asked to terminate, so tests assert on observable state
|
||||
// instead of spying on the production function. In "deferred" mode the
|
||||
// terminations hang until releaseAll(), letting tests observe parallelism.
|
||||
class FakeTerminator {
|
||||
readonly terminated: TreeKillTarget[] = [];
|
||||
private readonly pending: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly mode: "immediate" | "deferred" = "immediate") {}
|
||||
|
||||
readonly terminate: ProcessTerminator = async (child) => {
|
||||
this.terminated.push(child);
|
||||
if (this.mode === "deferred") {
|
||||
await new Promise<void>((resolve) => this.pending.push(resolve));
|
||||
}
|
||||
return "terminated";
|
||||
};
|
||||
|
||||
releaseAll(): void {
|
||||
for (const resolve of this.pending.splice(0)) {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionWithConfig(
|
||||
config: { provider?: string; modeId?: string | null; model?: string | null } = {},
|
||||
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
|
||||
@@ -145,33 +171,23 @@ function createTerminalChildStub(): ChildProcess {
|
||||
return child;
|
||||
}
|
||||
|
||||
function createProbeChildStub(order: string[]): ChildProcessWithoutNullStreams {
|
||||
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
|
||||
child.stdin = {
|
||||
destroy: vi.fn(() => {
|
||||
order.push("stdin.destroy");
|
||||
}),
|
||||
} as ChildProcessWithoutNullStreams["stdin"];
|
||||
child.stdout = {
|
||||
destroy: vi.fn(() => {
|
||||
order.push("stdout.destroy");
|
||||
}),
|
||||
} as ChildProcessWithoutNullStreams["stdout"];
|
||||
child.stderr = {
|
||||
destroy: vi.fn(() => {
|
||||
order.push("stderr.destroy");
|
||||
}),
|
||||
} as ChildProcessWithoutNullStreams["stderr"];
|
||||
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
|
||||
return child;
|
||||
function createDestroyableStream(): { destroyed: boolean; destroy: () => void } {
|
||||
const stream = {
|
||||
destroyed: false,
|
||||
destroy() {
|
||||
stream.destroyed = true;
|
||||
},
|
||||
};
|
||||
return stream;
|
||||
}
|
||||
|
||||
function createDeferredTermination(): { promise: Promise<"terminated">; resolve: () => void } {
|
||||
let resolveTermination: () => void = () => {};
|
||||
const promise = new Promise<"terminated">((resolve) => {
|
||||
resolveTermination = () => resolve("terminated");
|
||||
});
|
||||
return { promise, resolve: resolveTermination };
|
||||
function createProbeChildStub(): ChildProcessWithoutNullStreams {
|
||||
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
|
||||
child.stdin = createDestroyableStream() as unknown as ChildProcessWithoutNullStreams["stdin"];
|
||||
child.stdout = createDestroyableStream() as unknown as ChildProcessWithoutNullStreams["stdout"];
|
||||
child.stderr = createDestroyableStream() as unknown as ChildProcessWithoutNullStreams["stderr"];
|
||||
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
|
||||
return child;
|
||||
}
|
||||
|
||||
function selectConfigOption(
|
||||
@@ -2007,12 +2023,23 @@ describe("ACPAgentSession", () => {
|
||||
|
||||
interface ACPCloseInternals {
|
||||
child: ChildProcess | null;
|
||||
closed: boolean;
|
||||
connection: unknown;
|
||||
sessionId: string | null;
|
||||
terminalEntries: Map<string, { child: ChildProcess; exit: unknown }>;
|
||||
subscribers: Map<unknown, unknown>;
|
||||
activeForegroundTurnId: string | null;
|
||||
}
|
||||
|
||||
async function startTerminal(
|
||||
session: ACPAgentSession,
|
||||
child: ChildProcess,
|
||||
command = "sleep",
|
||||
): Promise<string> {
|
||||
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child as ChildProcessWithoutNullStreams);
|
||||
const terminal = await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command,
|
||||
args: ["60"],
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
return terminal.terminalId;
|
||||
}
|
||||
|
||||
describe("ACPAgentSession close() tree-kill", () => {
|
||||
@@ -2020,128 +2047,82 @@ describe("ACPAgentSession close() tree-kill", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("close() uses terminateWithTreeKill for the main child process", async () => {
|
||||
const terminateWithTreeKill = vi
|
||||
.spyOn(treeKillModule, "terminateWithTreeKill")
|
||||
.mockResolvedValue("terminated");
|
||||
const session = createSession();
|
||||
test("close() terminates the main child process via the process tree", async () => {
|
||||
const terminator = new FakeTerminator();
|
||||
const session = createSession(terminator.terminate);
|
||||
const internals = asInternals<ACPCloseInternals>(session);
|
||||
|
||||
const child = createTerminalChildStub();
|
||||
// The ACP host process is set by the live connect handshake, which has no
|
||||
// in-test seam; everything else is driven through the public API.
|
||||
internals.child = child;
|
||||
internals.connection = null;
|
||||
internals.sessionId = null;
|
||||
|
||||
await session.close();
|
||||
|
||||
expect(terminateWithTreeKill).toHaveBeenCalledWith(child, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
});
|
||||
expect(terminator.terminated).toContain(child);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("close() uses terminateWithTreeKill for terminal child processes", async () => {
|
||||
const terminateWithTreeKill = vi
|
||||
.spyOn(treeKillModule, "terminateWithTreeKill")
|
||||
.mockResolvedValue("terminated");
|
||||
const session = createSession();
|
||||
const internals = asInternals<ACPCloseInternals>(session);
|
||||
test("close() terminates running terminal child processes", async () => {
|
||||
const terminator = new FakeTerminator();
|
||||
const session = createSession(terminator.terminate);
|
||||
|
||||
const terminalChild = createTerminalChildStub();
|
||||
internals.terminalEntries = new Map([["terminal-1", { child: terminalChild, exit: null }]]);
|
||||
internals.child = null;
|
||||
internals.connection = null;
|
||||
internals.sessionId = null;
|
||||
await startTerminal(session, terminalChild);
|
||||
|
||||
await session.close();
|
||||
|
||||
expect(terminateWithTreeKill).toHaveBeenCalledWith(terminalChild, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
});
|
||||
expect(terminator.terminated).toContain(terminalChild);
|
||||
expect(terminalChild.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("close() terminates terminal child processes in parallel", async () => {
|
||||
const resolvers: Array<() => void> = [];
|
||||
const terminateWithTreeKill = vi
|
||||
.spyOn(treeKillModule, "terminateWithTreeKill")
|
||||
.mockImplementation(() => {
|
||||
const termination = createDeferredTermination();
|
||||
resolvers.push(termination.resolve);
|
||||
return termination.promise;
|
||||
});
|
||||
const session = createSession();
|
||||
const internals = asInternals<ACPCloseInternals>(session);
|
||||
const terminator = new FakeTerminator("deferred");
|
||||
const session = createSession(terminator.terminate);
|
||||
|
||||
const firstTerminalChild = createTerminalChildStub();
|
||||
const secondTerminalChild = createTerminalChildStub();
|
||||
internals.terminalEntries = new Map([
|
||||
["terminal-1", { child: firstTerminalChild, exit: null }],
|
||||
["terminal-2", { child: secondTerminalChild, exit: null }],
|
||||
]);
|
||||
internals.child = null;
|
||||
internals.connection = null;
|
||||
internals.sessionId = null;
|
||||
const firstChild = createTerminalChildStub();
|
||||
const secondChild = createTerminalChildStub();
|
||||
await startTerminal(session, firstChild);
|
||||
await startTerminal(session, secondChild);
|
||||
|
||||
const close = session.close();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(terminateWithTreeKill).toHaveBeenCalledTimes(2);
|
||||
expect(terminator.terminated).toEqual([firstChild, secondChild]);
|
||||
|
||||
for (const resolve of resolvers) {
|
||||
resolve();
|
||||
}
|
||||
terminator.releaseAll();
|
||||
await close;
|
||||
});
|
||||
|
||||
test("killTerminal uses terminateWithTreeKill instead of direct SIGTERM", async () => {
|
||||
const terminateWithTreeKill = vi
|
||||
.spyOn(treeKillModule, "terminateWithTreeKill")
|
||||
.mockResolvedValue("terminated");
|
||||
const session = createSession();
|
||||
const internals = asInternals<ACPCloseInternals>(session);
|
||||
test("killTerminal terminates the terminal process tree without a direct SIGTERM", async () => {
|
||||
const terminator = new FakeTerminator();
|
||||
const session = createSession(terminator.terminate);
|
||||
|
||||
const child = createTerminalChildStub();
|
||||
internals.terminalEntries = new Map([["terminal-1", { child, exit: null }]]);
|
||||
const terminalId = await startTerminal(session, child);
|
||||
|
||||
await session.killTerminal({ sessionId: "session-1", terminalId: "terminal-1" });
|
||||
await session.killTerminal({ sessionId: "session-1", terminalId });
|
||||
|
||||
expect(terminateWithTreeKill).toHaveBeenCalledWith(child, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
});
|
||||
expect(terminator.terminated).toContain(child);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("releaseTerminal uses terminateWithTreeKill before removing a running terminal", async () => {
|
||||
const terminateWithTreeKill = vi
|
||||
.spyOn(treeKillModule, "terminateWithTreeKill")
|
||||
.mockResolvedValue("terminated");
|
||||
test("releaseTerminal terminates and removes a running terminal", async () => {
|
||||
const terminator = new FakeTerminator();
|
||||
const session = createSession(terminator.terminate);
|
||||
|
||||
const child = createTerminalChildStub();
|
||||
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
const session = createSession();
|
||||
const terminalId = await startTerminal(session, child);
|
||||
|
||||
const terminal = await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: "sleep",
|
||||
args: ["60"],
|
||||
});
|
||||
await session.releaseTerminal({ sessionId: "session-1", terminalId });
|
||||
|
||||
await session.releaseTerminal({
|
||||
sessionId: "session-1",
|
||||
terminalId: terminal.terminalId,
|
||||
});
|
||||
|
||||
expect(terminateWithTreeKill).toHaveBeenCalledWith(child, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
});
|
||||
expect(terminator.terminated).toContain(child);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }),
|
||||
).rejects.toThrow(`Unknown terminal '${terminal.terminalId}'`);
|
||||
await expect(session.terminalOutput({ sessionId: "session-1", terminalId })).rejects.toThrow(
|
||||
`Unknown terminal '${terminalId}'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2150,15 +2131,9 @@ describe("ACPAgentClient probe cleanup", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("signals the probe process tree before destroying stdio", async () => {
|
||||
const order: string[] = [];
|
||||
const terminateWithTreeKill = vi
|
||||
.spyOn(treeKillModule, "terminateWithTreeKill")
|
||||
.mockImplementation(async () => {
|
||||
order.push("tree-kill");
|
||||
return "terminated";
|
||||
});
|
||||
const child = createProbeChildStub(order);
|
||||
test("terminates the probe process tree and closes its stdio", async () => {
|
||||
const terminator = new FakeTerminator();
|
||||
const child = createProbeChildStub();
|
||||
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
@@ -2181,14 +2156,14 @@ describe("ACPAgentClient probe cleanup", () => {
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["claude", "--acp"],
|
||||
defaultModes: [],
|
||||
terminateProcess: terminator.terminate,
|
||||
});
|
||||
|
||||
await client.listModels({ cwd: "/tmp/acp-models", force: false });
|
||||
|
||||
expect(terminateWithTreeKill).toHaveBeenCalledWith(child, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
});
|
||||
expect(order).toEqual(["tree-kill", "stdin.destroy", "stdout.destroy", "stderr.destroy"]);
|
||||
expect(terminator.terminated).toContain(child);
|
||||
expect(child.stdin.destroyed).toBe(true);
|
||||
expect(child.stdout.destroyed).toBe(true);
|
||||
expect(child.stderr.destroyed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
import type { ProcessTerminator } from "../../../utils/tree-kill.js";
|
||||
import type {
|
||||
ReadableStream as NodeReadableStream,
|
||||
WritableStream as NodeWritableStream,
|
||||
@@ -331,6 +332,7 @@ interface ACPAgentClientOptions {
|
||||
capabilities?: AgentCapabilityFlags;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
terminateProcess?: ProcessTerminator;
|
||||
}
|
||||
|
||||
interface ACPAgentSessionOptions {
|
||||
@@ -359,6 +361,7 @@ interface ACPAgentSessionOptions {
|
||||
launchEnv?: Record<string, string>;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
terminateProcess?: ProcessTerminator;
|
||||
}
|
||||
|
||||
export interface SpawnedACPProcess {
|
||||
@@ -606,9 +609,11 @@ export class ACPAgentClient implements AgentClient {
|
||||
) => Promise<void>;
|
||||
private readonly waitForInitialCommands: boolean;
|
||||
private readonly initialCommandsWaitTimeoutMs: number;
|
||||
protected readonly terminateProcess: ProcessTerminator;
|
||||
|
||||
constructor(options: ACPAgentClientOptions) {
|
||||
this.provider = options.provider;
|
||||
this.terminateProcess = options.terminateProcess ?? terminateWithTreeKill;
|
||||
this.capabilities = options.capabilities ?? DEFAULT_ACP_CAPABILITIES;
|
||||
this.logger = options.logger.child({
|
||||
module: "agent",
|
||||
@@ -868,7 +873,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
await terminateChildProcess(child, 2_000);
|
||||
await terminateChildProcess(child, 2_000, this.terminateProcess);
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeout) {
|
||||
@@ -906,7 +911,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
// No active session to close here; ignore capability.
|
||||
}
|
||||
} finally {
|
||||
await terminateChildProcess(probe.child, 2_000);
|
||||
await terminateChildProcess(probe.child, 2_000, this.terminateProcess);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1016,9 +1021,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private historyPending = false;
|
||||
private replayingHistory = false;
|
||||
private bootstrapThreadEventPending = false;
|
||||
private readonly terminateProcess: ProcessTerminator;
|
||||
|
||||
constructor(config: AgentSessionConfig, options: ACPAgentSessionOptions) {
|
||||
this.provider = options.provider;
|
||||
this.terminateProcess = options.terminateProcess ?? terminateWithTreeKill;
|
||||
this.capabilities = options.capabilities;
|
||||
this.logger = options.logger.child({ module: "agent", provider: options.provider });
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
@@ -1667,7 +1674,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
const terminalTerminations = Array.from(this.terminalEntries.values(), (terminal) =>
|
||||
terminateWithTreeKill(terminal.child, {
|
||||
this.terminateProcess(terminal.child, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
}),
|
||||
@@ -1676,7 +1683,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.terminalEntries.clear();
|
||||
|
||||
if (this.child) {
|
||||
await terminateWithTreeKill(this.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
|
||||
await this.terminateProcess(this.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
|
||||
}
|
||||
|
||||
this.subscribers.clear();
|
||||
@@ -1858,7 +1865,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
async releaseTerminal(params: { sessionId: string; terminalId: string }): Promise<void> {
|
||||
const entry = this.getTerminalEntry(params.terminalId);
|
||||
if (!entry.exit) {
|
||||
await terminateWithTreeKill(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
|
||||
await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
|
||||
}
|
||||
this.terminalEntries.delete(params.terminalId);
|
||||
}
|
||||
@@ -1866,7 +1873,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
async killTerminal(params: KillTerminalRequest): Promise<Record<string, never>> {
|
||||
const entry = this.getTerminalEntry(params.terminalId);
|
||||
if (!entry.exit) {
|
||||
await terminateWithTreeKill(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
|
||||
await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -2892,9 +2899,10 @@ function coerceSessionConfigMetadata(
|
||||
async function terminateChildProcess(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
timeoutMs: number,
|
||||
terminate: ProcessTerminator,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await terminateWithTreeKill(child, { gracefulTimeoutMs: timeoutMs, forceTimeoutMs: timeoutMs });
|
||||
await terminate(child, { gracefulTimeoutMs: timeoutMs, forceTimeoutMs: timeoutMs });
|
||||
} finally {
|
||||
child.stdin.destroy();
|
||||
child.stdout.destroy();
|
||||
|
||||
@@ -94,6 +94,7 @@ function createHarness(overrides?: {
|
||||
agentStorage: {} as AutoArchiveArchiveOptions["agentStorage"],
|
||||
terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"],
|
||||
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"),
|
||||
listActiveWorkspaces: vi.fn(async () => []),
|
||||
archiveWorkspaceRecord: vi.fn(),
|
||||
markWorkspaceArchiving: vi.fn(),
|
||||
clearWorkspaceArchiving: vi.fn(),
|
||||
@@ -114,8 +115,7 @@ function createHarness(overrides?: {
|
||||
const deps: ArchiveIfSafeDependencies = {
|
||||
archivePaseoWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
killTerminalsUnderPath: vi.fn(),
|
||||
isPathWithinRoot: vi.fn(() => true),
|
||||
killTerminalsForWorkspace: vi.fn(),
|
||||
};
|
||||
const log = createLogger();
|
||||
const inFlight = new Set<string>();
|
||||
@@ -289,6 +289,10 @@ describe("archiveIfSafe", () => {
|
||||
targetPath: CWD,
|
||||
repoRoot: "/tmp/repo",
|
||||
worktreesRoot: WORKTREES_ROOT,
|
||||
// A merged worktree is the last reference to its directory; remove it from
|
||||
// disk so merged worktrees do not accumulate. Sibling protection still
|
||||
// happens inside the service (last-reference + ownership gated).
|
||||
deleteWorktreeFromDisk: true,
|
||||
requestId: "auto-archive-on-merge",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,8 +3,11 @@ import type { Logger } from "pino";
|
||||
import type { AgentManager } from "../agent/agent-manager.js";
|
||||
import type { AgentStorage } from "../agent/agent-storage.js";
|
||||
import type { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import { archivePaseoWorktree, killTerminalsUnderPath } from "../paseo-worktree-archive-service.js";
|
||||
import { isSameOrDescendantPath } from "../path-utils.js";
|
||||
import {
|
||||
type ActiveWorkspaceRef,
|
||||
archivePaseoWorktree,
|
||||
killTerminalsForWorkspace,
|
||||
} from "../paseo-worktree-archive-service.js";
|
||||
import type {
|
||||
WorkspaceGitRuntimeSnapshot,
|
||||
WorkspaceGitServiceImpl,
|
||||
@@ -23,6 +26,7 @@ export interface AutoArchiveArchiveOptions {
|
||||
agentStorage: AgentStorage;
|
||||
terminalManager: TerminalManager;
|
||||
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
|
||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
|
||||
@@ -32,15 +36,13 @@ export interface AutoArchiveArchiveOptions {
|
||||
export interface ArchiveIfSafeDependencies {
|
||||
archivePaseoWorktree: typeof archivePaseoWorktree;
|
||||
isPaseoOwnedWorktreeCwd: typeof isPaseoOwnedWorktreeCwd;
|
||||
killTerminalsUnderPath: typeof killTerminalsUnderPath;
|
||||
isPathWithinRoot: typeof isSameOrDescendantPath;
|
||||
killTerminalsForWorkspace: typeof killTerminalsForWorkspace;
|
||||
}
|
||||
|
||||
const defaultDependencies: ArchiveIfSafeDependencies = {
|
||||
archivePaseoWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
killTerminalsUnderPath,
|
||||
isPathWithinRoot: isSameOrDescendantPath,
|
||||
killTerminalsForWorkspace,
|
||||
};
|
||||
|
||||
export async function archiveIfSafe(input: {
|
||||
@@ -104,20 +106,18 @@ export async function archiveIfSafe(input: {
|
||||
agentManager: options.agentManager,
|
||||
agentStorage: options.agentStorage,
|
||||
resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd,
|
||||
listActiveWorkspaces: options.listActiveWorkspaces,
|
||||
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving: options.markWorkspaceArchiving,
|
||||
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
|
||||
isPathWithinRoot: deps.isPathWithinRoot,
|
||||
killTerminalsUnderPath: (rootPath) =>
|
||||
deps.killTerminalsUnderPath(
|
||||
killTerminalsForWorkspace: (workspaceId) =>
|
||||
deps.killTerminalsForWorkspace(
|
||||
{
|
||||
terminalManager: options.terminalManager,
|
||||
isPathWithinRoot: deps.isPathWithinRoot,
|
||||
killTrackedTerminal: () => {},
|
||||
sessionLogger: log,
|
||||
},
|
||||
rootPath,
|
||||
workspaceId,
|
||||
),
|
||||
sessionLogger: log,
|
||||
},
|
||||
@@ -126,6 +126,10 @@ export async function archiveIfSafe(input: {
|
||||
repoRoot: ownership.repoRoot ?? null,
|
||||
worktreesRoot: ownership.worktreeRoot,
|
||||
worktreesBaseRoot: options.worktreesRoot,
|
||||
// Last-reference + Paseo-ownership gated inside the service, so sibling
|
||||
// workspaces sharing the directory stay protected. Removing the merged
|
||||
// worktree off disk prevents merged worktrees from accumulating.
|
||||
deleteWorktreeFromDisk: true,
|
||||
requestId: "auto-archive-on-merge",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -113,6 +113,7 @@ import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
import { resolveRegisteredWorkspaceIdForCwd } from "./workspace-directory.js";
|
||||
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
|
||||
import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js";
|
||||
import type { ActiveWorkspaceRef } from "./paseo-worktree-archive-service.js";
|
||||
import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js";
|
||||
@@ -745,12 +746,21 @@ export async function createPaseoDaemon(
|
||||
await archivePersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
});
|
||||
};
|
||||
const resolveWorkspaceIdForCwdExternal = async (cwd: string): Promise<string | null> => {
|
||||
return resolveRegisteredWorkspaceIdForCwd(cwd, await workspaceRegistry.list());
|
||||
};
|
||||
const listActiveWorkspacesExternal = async (): Promise<ActiveWorkspaceRef[]> => {
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
return workspaces
|
||||
.filter((workspace) => !workspace.archivedAt)
|
||||
.map((workspace) => ({
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.cwd,
|
||||
kind: workspace.kind,
|
||||
}));
|
||||
};
|
||||
const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => {
|
||||
const workspaceIdList = Array.from(workspaceIds);
|
||||
for (const session of wsServer?.listActiveSessions() ?? []) {
|
||||
@@ -786,6 +796,7 @@ export async function createPaseoDaemon(
|
||||
terminalManager,
|
||||
logger,
|
||||
resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal,
|
||||
listActiveWorkspaces: listActiveWorkspacesExternal,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
@@ -809,6 +820,7 @@ export async function createPaseoDaemon(
|
||||
github,
|
||||
workspaceGitService,
|
||||
resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal,
|
||||
listActiveWorkspaces: listActiveWorkspacesExternal,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { DaemonClient } from "./test-utils/index.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
|
||||
|
||||
// The daemon-level workspace contract that `paseo run` depends on: each
|
||||
// local-backed createWorkspace for a cwd mints a fresh, distinct workspace,
|
||||
// createAgent stamps the agent with the workspaceId it is given, and attaching
|
||||
// to an existing workspace by id creates no new record. The CLI's own flag
|
||||
// precedence (--workspace > $PASEO_WORKSPACE_ID > --worktree > bare) is covered
|
||||
// in packages/cli/src/commands/agent/run.test.ts; this test only proves the
|
||||
// daemon behaviors the CLI builds on.
|
||||
|
||||
async function workspaceIds(client: DaemonClient): Promise<Set<string>> {
|
||||
const workspaces = await client.fetchWorkspaces();
|
||||
return new Set(workspaces.entries.map((entry) => entry.id));
|
||||
}
|
||||
|
||||
async function mintLocalWorkspace(client: DaemonClient, cwd: string): Promise<string> {
|
||||
const result = await client.createWorkspace({ backing: "local", cwd });
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? "Failed to create workspace");
|
||||
}
|
||||
return result.workspace.id;
|
||||
}
|
||||
|
||||
test("daemon mints a distinct local workspace per run and stamps agents by id", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-cwd-"));
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.82",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
|
||||
// A bare run mints a fresh local workspace for the cwd, then the agent is
|
||||
// stamped with that workspace's id.
|
||||
const firstWorkspaceId = await mintLocalWorkspace(client, cwd);
|
||||
|
||||
const firstAgent = await client.createAgent({
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd,
|
||||
workspaceId: firstWorkspaceId,
|
||||
title: "First run agent",
|
||||
});
|
||||
expect(firstAgent.workspaceId).toBe(firstWorkspaceId);
|
||||
expect(await workspaceIds(client)).toContain(firstWorkspaceId);
|
||||
|
||||
const fetchedFirst = await client.fetchAgent(firstAgent.id);
|
||||
expect(fetchedFirst?.agent.workspaceId).toBe(firstWorkspaceId);
|
||||
|
||||
// A second bare run in the SAME cwd mints a DISTINCT workspace; each run
|
||||
// owns its own workspace rather than reattaching to the first.
|
||||
const secondWorkspaceId = await mintLocalWorkspace(client, cwd);
|
||||
expect(secondWorkspaceId).not.toBe(firstWorkspaceId);
|
||||
|
||||
const secondAgent = await client.createAgent({
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd,
|
||||
workspaceId: secondWorkspaceId,
|
||||
title: "Second run agent",
|
||||
});
|
||||
expect(secondAgent.workspaceId).toBe(secondWorkspaceId);
|
||||
expect(secondAgent.workspaceId).not.toBe(firstAgent.workspaceId);
|
||||
|
||||
const idsAfterTwoMints = await workspaceIds(client);
|
||||
expect(idsAfterTwoMints).toContain(firstWorkspaceId);
|
||||
expect(idsAfterTwoMints).toContain(secondWorkspaceId);
|
||||
|
||||
// Attaching to an existing workspace by id (how --workspace and
|
||||
// $PASEO_WORKSPACE_ID land a run) creates no new workspace record: the
|
||||
// agent lands in the named workspace and the workspace set is unchanged.
|
||||
const idsBeforeAttach = await workspaceIds(client);
|
||||
const attachedAgent = await client.createAgent({
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd,
|
||||
workspaceId: firstWorkspaceId,
|
||||
title: "Attached agent",
|
||||
});
|
||||
expect(attachedAgent.workspaceId).toBe(firstWorkspaceId);
|
||||
expect(await workspaceIds(client)).toEqual(idsBeforeAttach);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 180000);
|
||||
@@ -289,8 +289,8 @@ describe("daemon checkout PR merge loop", () => {
|
||||
});
|
||||
expect(archiveResult.error).toBeNull();
|
||||
expect(archiveResult.success).toBe(true);
|
||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
||||
worktreePath = null;
|
||||
// Archiving leaves the worktree on disk; disk deletion is a separate step.
|
||||
expect(existsSync(worktree.worktreePath)).toBe(true);
|
||||
|
||||
const remainingAgents = await ctx.client.fetchAgents();
|
||||
expect(remainingAgents.entries.some((entry) => entry.agent.id === agent.id)).toBe(false);
|
||||
|
||||
@@ -255,13 +255,15 @@ describe("daemon checkout ship loop", () => {
|
||||
expect(archiveResult.error).toBeNull();
|
||||
expect(archiveResult.success).toBe(true);
|
||||
|
||||
// Archiving removes the agent from the active list but leaves the
|
||||
// worktree on disk — disk deletion is a separate, explicit step.
|
||||
const worktreeListAfter = await ctx.client.getPaseoWorktreeList({
|
||||
cwd: repoDir,
|
||||
});
|
||||
expect(
|
||||
worktreeListAfter.worktrees.some((entry) => entry.worktreePath === worktree.worktreePath),
|
||||
).toBe(false);
|
||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
||||
).toBe(true);
|
||||
expect(existsSync(worktree.worktreePath)).toBe(true);
|
||||
|
||||
const remainingAgents = await ctx.client.fetchAgents();
|
||||
expect(remainingAgents.entries.some((entry) => entry.agent.id === agent.id)).toBe(false);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
type PersistedProjectRecord,
|
||||
} from "../workspace-registry.js";
|
||||
|
||||
const cleanupPaths = new Set<string>();
|
||||
const cleanupDaemons = new Set<TestPaseoDaemon>();
|
||||
const cleanupClients = new Set<DaemonClient>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupClients, (client) => client.close().catch(() => undefined)));
|
||||
cleanupClients.clear();
|
||||
await Promise.all(Array.from(cleanupDaemons, (daemon) => daemon.close().catch(() => undefined)));
|
||||
cleanupDaemons.clear();
|
||||
await Promise.all(
|
||||
Array.from(cleanupPaths, (target) => rm(target, { recursive: true, force: true })),
|
||||
);
|
||||
cleanupPaths.clear();
|
||||
});
|
||||
|
||||
// Unit 2.2: a project persists as a first-class empty project after its last
|
||||
// workspace is archived, and is exposed to clients via fetch_workspaces.
|
||||
test("archiving the last workspace leaves the project as an empty project parent", async () => {
|
||||
const previousSupervised = process.env.PASEO_SUPERVISED;
|
||||
process.env.PASEO_SUPERVISED = "0";
|
||||
try {
|
||||
const repoRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-empty-project-repo-")));
|
||||
const paseoHomeRoot = realpathSync(
|
||||
mkdtempSync(path.join(os.tmpdir(), "paseo-empty-project-home-")),
|
||||
);
|
||||
cleanupPaths.add(repoRoot);
|
||||
cleanupPaths.add(paseoHomeRoot);
|
||||
|
||||
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@getpaseo.dev'", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git config user.name 'Paseo Test'", { cwd: repoRoot, stdio: "pipe" });
|
||||
writeFileSync(path.join(repoRoot, "README.md"), "# repo\n", "utf8");
|
||||
execSync("git add README.md", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgSign=false commit -m 'initial'", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
const projectsPath = path.join(paseoHome, "projects", "projects.json");
|
||||
const workspacesPath = path.join(paseoHome, "projects", "workspaces.json");
|
||||
const timestamp = "2026-04-24T09:46:43.146Z";
|
||||
|
||||
await mkdir(path.dirname(projectsPath), { recursive: true });
|
||||
await writeFile(
|
||||
projectsPath,
|
||||
JSON.stringify(
|
||||
[
|
||||
createPersistedProjectRecord({
|
||||
projectId: repoRoot,
|
||||
rootPath: repoRoot,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
workspacesPath,
|
||||
JSON.stringify(
|
||||
[
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: repoRoot,
|
||||
projectId: repoRoot,
|
||||
cwd: repoRoot,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
|
||||
cleanupDaemons.add(daemon);
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
cleanupClients.add(client);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "empty-project-agents" } });
|
||||
|
||||
const beforeArchive = await client.fetchWorkspaces();
|
||||
expect(beforeArchive.entries.map((entry) => entry.id)).toContain(repoRoot);
|
||||
expect(beforeArchive.emptyProjects.map((project) => project.projectId)).not.toContain(repoRoot);
|
||||
|
||||
await client.archiveWorkspace(repoRoot);
|
||||
|
||||
const afterArchive = await client.fetchWorkspaces();
|
||||
expect(afterArchive.entries.map((entry) => entry.id)).not.toContain(repoRoot);
|
||||
expect(afterArchive.emptyProjects.map((project) => project.projectId)).toContain(repoRoot);
|
||||
|
||||
const persistedProjects = JSON.parse(
|
||||
await readFile(projectsPath, "utf8"),
|
||||
) as PersistedProjectRecord[];
|
||||
expect(
|
||||
persistedProjects.find((project) => project.projectId === repoRoot)?.archivedAt,
|
||||
).toBeNull();
|
||||
} finally {
|
||||
process.env.PASEO_SUPERVISED = previousSupervised;
|
||||
}
|
||||
}, 30_000);
|
||||
@@ -684,7 +684,7 @@ test("creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", as
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
test("archives worktree by running teardown commands and shutting down worktree terminals", async () => {
|
||||
test("archiving a worktree shuts down its terminals but leaves the worktree on disk", async () => {
|
||||
const repoRoot = tmpCwd();
|
||||
|
||||
const { execSync } = await import("child_process");
|
||||
@@ -766,9 +766,11 @@ test("archives worktree by running teardown commands and shutting down worktree
|
||||
expect(archive.success).toBe(true);
|
||||
expect(archive.removedAgents).toContain(agent.id);
|
||||
|
||||
expect(existsSync(agent.cwd)).toBe(false);
|
||||
expect(existsSync(teardownMarkerPath)).toBe(true);
|
||||
expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd);
|
||||
// Archiving tears down the workspace's terminals but never deletes the
|
||||
// worktree from disk and never runs teardown commands — on-disk removal is a
|
||||
// separate, explicit step.
|
||||
expect(existsSync(agent.cwd)).toBe(true);
|
||||
expect(existsSync(teardownMarkerPath)).toBe(false);
|
||||
|
||||
const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories();
|
||||
expect(afterArchiveDirectories).not.toContain(agent.cwd);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
@@ -6,11 +8,18 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import type { GitHubService } from "../services/github-service.js";
|
||||
import {
|
||||
deletePaseoWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
resolvePaseoWorktreeRootForCwd,
|
||||
WorktreeTeardownError,
|
||||
} from "../utils/worktree.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
|
||||
export interface ActiveWorkspaceRef {
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
kind?: "local_checkout" | "worktree" | "directory";
|
||||
}
|
||||
|
||||
export interface ArchivePaseoWorktreeDependencies {
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
@@ -19,23 +28,34 @@ export interface ArchivePaseoWorktreeDependencies {
|
||||
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
|
||||
agentStorage: Pick<AgentStorage, "list">;
|
||||
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
// Active (non-archived) workspaces, used to decide whether the workspace being
|
||||
// archived is the last reference to its backing worktree directory, and to
|
||||
// break a same-cwd tie in favor of the worktree-kind record when archiving by
|
||||
// path (no explicit workspaceId).
|
||||
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
|
||||
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
|
||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
|
||||
killTerminalsForWorkspace: (workspaceId: string) => Promise<void>;
|
||||
sessionLogger?: Logger;
|
||||
}
|
||||
|
||||
export interface KillTerminalsUnderPathDependencies {
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
killTrackedTerminal: (terminalId: string, options?: { emitExit: boolean }) => void;
|
||||
export interface KillTerminalsForWorkspaceDependencies {
|
||||
detachTerminalStream?: (terminalId: string, options: { emitExit: boolean }) => void;
|
||||
sessionLogger: Logger;
|
||||
terminalManager: TerminalManager | null;
|
||||
}
|
||||
|
||||
// Archiving is scoped to a single workspace RECORD (by workspaceId), not to a
|
||||
// directory. A directory can back multiple workspaces (Model B), so cwd-scoped
|
||||
// teardown would destroy a sibling workspace's agents and terminals. We tear
|
||||
// down only the agents and terminals owned by the target workspaceId.
|
||||
//
|
||||
// On-disk worktree removal is opt-in (deleteWorktreeFromDisk) and only happens
|
||||
// when this workspace is the LAST active reference to a Paseo-owned worktree
|
||||
// directory. If a sibling workspace still references the directory, it is kept.
|
||||
// Local checkouts are never deleted.
|
||||
export async function archivePaseoWorktree(
|
||||
dependencies: ArchivePaseoWorktreeDependencies,
|
||||
options: {
|
||||
@@ -43,6 +63,8 @@ export async function archivePaseoWorktree(
|
||||
repoRoot: string | null;
|
||||
worktreesRoot?: string;
|
||||
worktreesBaseRoot?: string;
|
||||
workspaceId?: string;
|
||||
deleteWorktreeFromDisk?: boolean;
|
||||
requestId: string;
|
||||
},
|
||||
): Promise<string[]> {
|
||||
@@ -55,84 +77,31 @@ export async function archivePaseoWorktree(
|
||||
targetPath = resolvedWorktree.worktreePath;
|
||||
}
|
||||
|
||||
const archivedAgents = new Set<string>();
|
||||
const affectedWorkspaceCwds = new Set<string>([targetPath]);
|
||||
|
||||
const liveAgents = dependencies.agentManager
|
||||
.listAgents()
|
||||
.filter((agent) => dependencies.isPathWithinRoot(targetPath, agent.cwd));
|
||||
for (const agent of liveAgents) {
|
||||
archivedAgents.add(agent.id);
|
||||
affectedWorkspaceCwds.add(agent.cwd);
|
||||
}
|
||||
|
||||
let storedRecords: StoredAgentRecord[] = [];
|
||||
try {
|
||||
storedRecords = await dependencies.agentStorage.list();
|
||||
} catch (error) {
|
||||
// A directory can back multiple workspaces (Model B), so resolving the target
|
||||
// by cwd alone picks an arbitrary record. Prefer the explicit workspaceId the
|
||||
// caller supplied; otherwise resolve by path, breaking a same-cwd tie toward
|
||||
// the worktree-kind record.
|
||||
const targetWorkspaceId =
|
||||
options.workspaceId ?? (await resolveTargetWorkspaceId(dependencies, targetPath));
|
||||
if (!targetWorkspaceId) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, targetPath },
|
||||
"Failed to list stored agents during worktree archive; continuing",
|
||||
{ targetPath },
|
||||
"Skipping workspace archive for unregistered directory",
|
||||
);
|
||||
}
|
||||
const liveAgentIds = new Set(liveAgents.map((agent) => agent.id));
|
||||
const matchingStoredRecords = storedRecords.filter((record) =>
|
||||
dependencies.isPathWithinRoot(targetPath, record.cwd),
|
||||
);
|
||||
for (const record of matchingStoredRecords) {
|
||||
archivedAgents.add(record.id);
|
||||
affectedWorkspaceCwds.add(record.cwd);
|
||||
return [];
|
||||
}
|
||||
|
||||
const affectedWorkspaceIdList = await resolveAffectedWorkspaceIds(
|
||||
dependencies,
|
||||
affectedWorkspaceCwds,
|
||||
);
|
||||
const affectedWorkspaceIdList = [targetWorkspaceId];
|
||||
dependencies.markWorkspaceArchiving(affectedWorkspaceIdList, new Date().toISOString());
|
||||
|
||||
let archivedAgents = new Set<string>();
|
||||
|
||||
try {
|
||||
await dependencies.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIdList);
|
||||
|
||||
const archivedAt = new Date().toISOString();
|
||||
const archiveResults = await Promise.allSettled([
|
||||
...liveAgents.map((agent) => dependencies.agentManager.archiveAgent(agent.id)),
|
||||
...matchingStoredRecords
|
||||
.filter((record) => !liveAgentIds.has(record.id) && !record.archivedAt)
|
||||
.map((record) => dependencies.agentManager.archiveSnapshot(record.id, archivedAt)),
|
||||
dependencies.killTerminalsUnderPath(targetPath),
|
||||
]);
|
||||
archivedAgents = await archiveWorkspaceContents(dependencies, targetWorkspaceId);
|
||||
|
||||
for (const result of archiveResults) {
|
||||
if (result.status === "rejected") {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: result.reason, targetPath },
|
||||
"Worktree archive teardown step failed; continuing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let teardownError: WorktreeTeardownError | null = null;
|
||||
try {
|
||||
await deletePaseoWorktree({
|
||||
cwd: options.repoRoot,
|
||||
worktreePath: targetPath,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesBaseRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorktreeTeardownError) {
|
||||
teardownError = error;
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, targetPath },
|
||||
"Worktree teardown failed during archive; archiving workspace record anyway",
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!teardownError && options.repoRoot) {
|
||||
if (options.repoRoot) {
|
||||
try {
|
||||
await dependencies.workspaceGitService.getSnapshot(options.repoRoot, {
|
||||
force: true,
|
||||
@@ -146,27 +115,25 @@ export async function archivePaseoWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
for (const cwd of affectedWorkspaceCwds) {
|
||||
dependencies.github.invalidate({ cwd });
|
||||
dependencies.github.invalidate({ cwd: targetPath });
|
||||
|
||||
try {
|
||||
await dependencies.archiveWorkspaceRecord(targetWorkspaceId);
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, workspaceId: targetWorkspaceId },
|
||||
"Failed to archive workspace record",
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
affectedWorkspaceIdList.map(async (workspaceId) => {
|
||||
try {
|
||||
await dependencies.archiveWorkspaceRecord(workspaceId);
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, workspaceId },
|
||||
teardownError
|
||||
? "Failed to archive workspace record after teardown failed"
|
||||
: "Failed to archive workspace record; worktree FS already removed",
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (teardownError) {
|
||||
throw teardownError;
|
||||
if (options.deleteWorktreeFromDisk) {
|
||||
await deleteWorktreeFromDiskIfLastReference(dependencies, {
|
||||
targetPath,
|
||||
targetWorkspaceId,
|
||||
repoRoot: options.repoRoot,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
worktreesBaseRoot: options.worktreesBaseRoot,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
dependencies.clearWorkspaceArchiving(affectedWorkspaceIdList);
|
||||
@@ -176,31 +143,147 @@ export async function archivePaseoWorktree(
|
||||
return Array.from(archivedAgents);
|
||||
}
|
||||
|
||||
async function resolveAffectedWorkspaceIds(
|
||||
// Resolves the workspace record to archive when no explicit workspaceId was
|
||||
// supplied. When several active workspaces share the exact target cwd, prefer
|
||||
// the worktree-kind record so archiving-by-path tears down the worktree rather
|
||||
// than an arbitrary sibling. Falls back to the path-based resolver otherwise.
|
||||
async function resolveTargetWorkspaceId(
|
||||
dependencies: Pick<
|
||||
ArchivePaseoWorktreeDependencies,
|
||||
"resolveWorkspaceIdForCwd" | "sessionLogger"
|
||||
"resolveWorkspaceIdForCwd" | "listActiveWorkspaces"
|
||||
>,
|
||||
cwds: Iterable<string>,
|
||||
): Promise<string[]> {
|
||||
const workspaceIds = new Set<string>();
|
||||
for (const cwd of cwds) {
|
||||
const workspaceId = await dependencies.resolveWorkspaceIdForCwd(cwd);
|
||||
if (!workspaceId) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ cwd },
|
||||
"Skipping workspace archive update for unregistered directory",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
workspaceIds.add(workspaceId);
|
||||
targetPath: string,
|
||||
): Promise<string | null> {
|
||||
const targetDir = resolve(targetPath);
|
||||
const exactMatches = (await dependencies.listActiveWorkspaces()).filter(
|
||||
(workspace) => resolve(workspace.cwd) === targetDir,
|
||||
);
|
||||
const worktreeMatch = exactMatches.find((workspace) => workspace.kind === "worktree");
|
||||
if (worktreeMatch) {
|
||||
return worktreeMatch.workspaceId;
|
||||
}
|
||||
return Array.from(workspaceIds);
|
||||
return dependencies.resolveWorkspaceIdForCwd(targetPath);
|
||||
}
|
||||
|
||||
export async function killTerminalsUnderPath(
|
||||
dependencies: KillTerminalsUnderPathDependencies,
|
||||
rootPath: string,
|
||||
export type ArchiveWorkspaceContentsDependencies = Pick<
|
||||
ArchivePaseoWorktreeDependencies,
|
||||
"agentManager" | "agentStorage" | "killTerminalsForWorkspace" | "sessionLogger"
|
||||
>;
|
||||
|
||||
// Tears down everything OWNED by a single workspace record: its live agents,
|
||||
// its persisted-but-not-running agent snapshots, and its terminals. Scoped by
|
||||
// workspaceId so a sibling workspace sharing the same directory is untouched.
|
||||
// Returns the set of archived agent ids.
|
||||
export async function archiveWorkspaceContents(
|
||||
dependencies: ArchiveWorkspaceContentsDependencies,
|
||||
workspaceId: string,
|
||||
): Promise<Set<string>> {
|
||||
const archivedAgents = new Set<string>();
|
||||
|
||||
const liveAgents = dependencies.agentManager
|
||||
.listAgents()
|
||||
.filter((agent) => agent.workspaceId === workspaceId);
|
||||
for (const agent of liveAgents) {
|
||||
archivedAgents.add(agent.id);
|
||||
}
|
||||
|
||||
let storedRecords: StoredAgentRecord[] = [];
|
||||
try {
|
||||
storedRecords = await dependencies.agentStorage.list();
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, workspaceId },
|
||||
"Failed to list stored agents during workspace archive; continuing",
|
||||
);
|
||||
}
|
||||
const liveAgentIds = new Set(liveAgents.map((agent) => agent.id));
|
||||
const matchingStoredRecords = storedRecords.filter(
|
||||
(record) => record.workspaceId === workspaceId,
|
||||
);
|
||||
for (const record of matchingStoredRecords) {
|
||||
archivedAgents.add(record.id);
|
||||
}
|
||||
|
||||
const archivedAt = new Date().toISOString();
|
||||
const archiveResults = await Promise.allSettled([
|
||||
...liveAgents.map((agent) => dependencies.agentManager.archiveAgent(agent.id)),
|
||||
...matchingStoredRecords
|
||||
.filter((record) => !liveAgentIds.has(record.id) && !record.archivedAt)
|
||||
.map((record) => dependencies.agentManager.archiveSnapshot(record.id, archivedAt)),
|
||||
dependencies.killTerminalsForWorkspace(workspaceId),
|
||||
]);
|
||||
|
||||
for (const result of archiveResults) {
|
||||
if (result.status === "rejected") {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: result.reason, workspaceId },
|
||||
"Workspace archive teardown step failed; continuing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return archivedAgents;
|
||||
}
|
||||
|
||||
// Removes the worktree directory from disk, but only when the just-archived
|
||||
// workspace was the last active reference to a Paseo-owned worktree. A directory
|
||||
// can back multiple workspaces (Model B), so a sibling still referencing it must
|
||||
// keep the directory. Local checkouts are never Paseo-owned and so never deleted.
|
||||
async function deleteWorktreeFromDiskIfLastReference(
|
||||
dependencies: Pick<
|
||||
ArchivePaseoWorktreeDependencies,
|
||||
"paseoHome" | "worktreesRoot" | "listActiveWorkspaces" | "github" | "sessionLogger"
|
||||
>,
|
||||
options: {
|
||||
targetPath: string;
|
||||
targetWorkspaceId: string;
|
||||
repoRoot: string | null;
|
||||
worktreesRoot?: string;
|
||||
worktreesBaseRoot?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(options.targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot,
|
||||
});
|
||||
if (!ownership.allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDir = resolve(options.targetPath);
|
||||
const activeWorkspaces = await dependencies.listActiveWorkspaces();
|
||||
const siblingStillReferences = activeWorkspaces.some(
|
||||
(workspace) =>
|
||||
workspace.workspaceId !== options.targetWorkspaceId && resolve(workspace.cwd) === targetDir,
|
||||
);
|
||||
if (siblingStillReferences) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deletePaseoWorktree({
|
||||
cwd: options.repoRoot,
|
||||
worktreePath: options.targetPath,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesBaseRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot,
|
||||
});
|
||||
dependencies.github.invalidate({ cwd: options.targetPath });
|
||||
} catch (error) {
|
||||
if (error instanceof WorktreeTeardownError) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, targetPath: options.targetPath },
|
||||
"Worktree disk removal failed during archive; workspace already archived",
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function killTerminalsForWorkspace(
|
||||
dependencies: KillTerminalsForWorkspaceDependencies,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const terminalManager = dependencies.terminalManager;
|
||||
if (!terminalManager) {
|
||||
@@ -208,17 +291,14 @@ export async function killTerminalsUnderPath(
|
||||
}
|
||||
|
||||
const terminalIds: string[] = [];
|
||||
const relevantCwds = [...terminalManager.listDirectories()].filter((terminalCwd) =>
|
||||
dependencies.isPathWithinRoot(rootPath, terminalCwd),
|
||||
);
|
||||
const terminalLists = await Promise.all(
|
||||
relevantCwds.map(async (terminalCwd) => {
|
||||
terminalManager.listDirectories().map(async (terminalCwd) => {
|
||||
try {
|
||||
return await terminalManager.getTerminals(terminalCwd);
|
||||
return await terminalManager.getTerminals(terminalCwd, { workspaceId });
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger.warn(
|
||||
{ err: error, cwd: terminalCwd },
|
||||
"Failed to enumerate worktree terminals during archive",
|
||||
"Failed to enumerate workspace terminals during archive",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
@@ -226,7 +306,9 @@ export async function killTerminalsUnderPath(
|
||||
);
|
||||
for (const terminals of terminalLists) {
|
||||
for (const terminal of terminals) {
|
||||
terminalIds.push(terminal.id);
|
||||
if (terminal.workspaceId === workspaceId) {
|
||||
terminalIds.push(terminal.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,14 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import type { GitHubService } from "../services/github-service.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import type {
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import {
|
||||
attemptFirstAgentBranchAutoName,
|
||||
createLocalCheckoutWorkspace,
|
||||
createPaseoWorktree,
|
||||
type CreatePaseoWorktreeDeps,
|
||||
} from "./paseo-worktree-service.js";
|
||||
@@ -138,9 +143,25 @@ test.skipIf(isPlatform("win32"))(
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
|
||||
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
|
||||
// Creation never dedupes by directory: the same worktree path yields a
|
||||
// distinct workspace record on the second call.
|
||||
expect(second.workspace.workspaceId).not.toBe(first.workspace.workspaceId);
|
||||
},
|
||||
);
|
||||
|
||||
test("creates a distinct local checkout workspace for the same cwd on every call", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps();
|
||||
|
||||
const first = await createLocalCheckoutWorkspace({ cwd: repoDir }, deps);
|
||||
const second = await createLocalCheckoutWorkspace({ cwd: repoDir }, deps);
|
||||
|
||||
expect(first.cwd).toBe(second.cwd);
|
||||
expect(first.workspaceId).not.toBe(second.workspaceId);
|
||||
expect(deps.workspaces.size).toBe(2);
|
||||
});
|
||||
|
||||
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
@@ -479,6 +500,7 @@ test("does not mutate registries or broadcast when core worktree creation fails"
|
||||
});
|
||||
|
||||
interface TestDeps extends CreatePaseoWorktreeDeps {
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "list" | "upsert">;
|
||||
projects: Map<string, PersistedProjectRecord>;
|
||||
workspaces: Map<string, PersistedWorkspaceRecord>;
|
||||
}
|
||||
@@ -498,6 +520,7 @@ function createDeps(options?: {
|
||||
workspaces,
|
||||
projectRegistry: {
|
||||
get: async (projectId) => projects.get(projectId) ?? null,
|
||||
list: async () => Array.from(projects.values()),
|
||||
upsert: async (record) => {
|
||||
events.push(`project:${record.projectId}`);
|
||||
projects.set(record.projectId, record);
|
||||
@@ -583,6 +606,15 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd),
|
||||
getCheckout: async (cwd) => ({
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
getSnapshot: async (cwd) => createWorkspaceGitSnapshot(cwd),
|
||||
resolveRepoRoot: async (cwd) => {
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import { deriveProjectGroupingName, generateWorkspaceId } from "./workspace-registry-model.js";
|
||||
import {
|
||||
classifyDirectoryForProjectMembership,
|
||||
deriveProjectGroupingName,
|
||||
generateWorkspaceId,
|
||||
} from "./workspace-registry-model.js";
|
||||
import {
|
||||
createWorktreeCore,
|
||||
type CreateWorktreeCoreDeps,
|
||||
@@ -204,18 +208,17 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
const normalizedCwd = resolve(options.worktree.worktreePath);
|
||||
const normalizedInputCwd = resolve(options.inputCwd);
|
||||
const normalizedRepoRoot = resolve(options.repoRoot);
|
||||
const existingWorkspace = await findWorkspaceByDirectory(
|
||||
normalizedCwd,
|
||||
options.deps.workspaceRegistry,
|
||||
);
|
||||
// Creation never deduplicates by directory: a worktree directory may back
|
||||
// more than one workspace. We still resolve the source project from the
|
||||
// originating checkout, but always mint a fresh workspace record.
|
||||
const sourceProject = await resolveSourceProjectForWorktree({
|
||||
inputCwd: normalizedInputCwd,
|
||||
projectId: options.projectId,
|
||||
repoRoot: normalizedRepoRoot,
|
||||
existingWorkspace,
|
||||
existingWorkspace: null,
|
||||
deps: options.deps,
|
||||
});
|
||||
const workspaceId = existingWorkspace?.workspaceId ?? generateWorkspaceId();
|
||||
const workspaceId = generateWorkspaceId();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await options.deps.projectRegistry.upsert(
|
||||
@@ -237,7 +240,7 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
cwd: normalizedCwd,
|
||||
kind: "worktree",
|
||||
displayName: options.worktree.branchName || normalizedCwd,
|
||||
createdAt: existingWorkspace?.createdAt ?? now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
});
|
||||
@@ -246,6 +249,77 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
return (await options.deps.workspaceRegistry.get(workspace.workspaceId)) ?? workspace;
|
||||
}
|
||||
|
||||
export interface CreateLocalCheckoutWorkspaceDeps {
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "list" | "upsert">;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "list" | "upsert">;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">;
|
||||
}
|
||||
|
||||
// Always create a NEW workspace record backed by the existing directory `cwd`.
|
||||
// Never reuses a same-cwd record: a directory may back any number of
|
||||
// workspaces. Used by explicit user creation.
|
||||
export async function createLocalCheckoutWorkspace(
|
||||
options: { cwd: string; title?: string | null },
|
||||
deps: CreateLocalCheckoutWorkspaceDeps,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedCwd = resolve(options.cwd);
|
||||
const checkout = await deps.workspaceGitService.getCheckout(normalizedCwd);
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout });
|
||||
const now = new Date().toISOString();
|
||||
const projectRecord = await resolveProjectRecordForMembership({
|
||||
membership,
|
||||
timestamp: now,
|
||||
projectRegistry: deps.projectRegistry,
|
||||
});
|
||||
await deps.projectRegistry.upsert(projectRecord);
|
||||
|
||||
const trimmedTitle = options.title?.trim();
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: projectRecord.projectId,
|
||||
cwd: normalizedCwd,
|
||||
kind: membership.workspaceKind,
|
||||
displayName: membership.workspaceDisplayName,
|
||||
title: trimmedTitle ? trimmedTitle : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await deps.workspaceRegistry.upsert(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function resolveProjectRecordForMembership(options: {
|
||||
membership: ReturnType<typeof classifyDirectoryForProjectMembership>;
|
||||
timestamp: string;
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "list">;
|
||||
}) {
|
||||
const rootPath = options.membership.projectRootPath;
|
||||
const projects = await options.projectRegistry.list();
|
||||
const existingProject =
|
||||
projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ??
|
||||
projects.find((project) => project.rootPath === rootPath) ??
|
||||
null;
|
||||
|
||||
if (!existingProject) {
|
||||
return createPersistedProjectRecord({
|
||||
projectId: options.membership.projectKey,
|
||||
rootPath,
|
||||
kind: options.membership.projectKind,
|
||||
displayName: options.membership.projectName,
|
||||
createdAt: options.timestamp,
|
||||
updatedAt: options.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...existingProject,
|
||||
rootPath,
|
||||
kind: options.membership.projectKind,
|
||||
archivedAt: null,
|
||||
updatedAt: options.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
interface SourceProjectForWorktree {
|
||||
projectId: string;
|
||||
rootPath: string;
|
||||
@@ -362,11 +436,3 @@ async function findWorkspaceForSource(options: {
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function findWorkspaceByDirectory(
|
||||
cwd: string,
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "list">,
|
||||
): Promise<PersistedWorkspaceRecord | null> {
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
return workspaces.find((workspace) => workspace.cwd === cwd) ?? null;
|
||||
}
|
||||
|
||||
@@ -98,12 +98,14 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
updatedAt: Date;
|
||||
lastUserMessageAt: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
} {
|
||||
return {
|
||||
createdAt: new Date(record.createdAt),
|
||||
updatedAt: new Date(record.lastActivityAt ?? record.updatedAt),
|
||||
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
|
||||
labels: record.labels,
|
||||
workspaceId: record.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,5 +3,6 @@ export {
|
||||
createScriptProxyUpgradeHandler,
|
||||
findFreePort,
|
||||
ScriptRouteStore,
|
||||
ServiceProxyRouteCollisionError,
|
||||
} from "./service-proxy.js";
|
||||
export type { ScriptRoute, ScriptRouteEntry } from "./service-proxy.js";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "nod
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ScriptRouteStore } from "./script-proxy.js";
|
||||
import { ScriptRouteStore, ServiceProxyRouteCollisionError } from "./script-proxy.js";
|
||||
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
|
||||
|
||||
function createWorkspaceRepo(options?: {
|
||||
@@ -338,7 +338,7 @@ describe("script-route-branch-handler", () => {
|
||||
});
|
||||
|
||||
expect(() => handleBranchChange("workspace-a", "feature/auth", "feature/billing")).toThrow(
|
||||
"Service proxy hostname collision",
|
||||
ServiceProxyRouteCollisionError,
|
||||
);
|
||||
|
||||
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
|
||||
@@ -393,7 +393,7 @@ describe("script-route-branch-handler", () => {
|
||||
});
|
||||
|
||||
expect(() => handleBranchChange("workspace-a", "feature/one", "feature/collide")).toThrow(
|
||||
"Service proxy hostname collision",
|
||||
ServiceProxyRouteCollisionError,
|
||||
);
|
||||
expect(routeStore.getRouteEntry("api--feature-one--repo.localhost")).toMatchObject({
|
||||
port: 3001,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildServiceProxyLabel,
|
||||
createServiceProxySubsystem,
|
||||
findFreePort,
|
||||
ServiceProxyRouteCollisionError,
|
||||
ServiceProxyRouteRegistry,
|
||||
} from "./service-proxy.js";
|
||||
|
||||
@@ -179,7 +180,7 @@ describe("service proxy subsystem shape", () => {
|
||||
scriptName: "api",
|
||||
port: 4000,
|
||||
}),
|
||||
).toThrow("Service proxy hostname collision");
|
||||
).toThrow(ServiceProxyRouteCollisionError);
|
||||
|
||||
expect(serviceProxy.getHealthTargetForHostname("api--repo.localhost")).toMatchObject({
|
||||
workspaceId: "workspace-a",
|
||||
@@ -207,7 +208,7 @@ describe("service proxy subsystem shape", () => {
|
||||
port: 4000,
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
}),
|
||||
).toThrow("Service proxy hostname collision");
|
||||
).toThrow(ServiceProxyRouteCollisionError);
|
||||
|
||||
expect(serviceProxy.getHealthTargetForHostname("api--repo.services.example.com")).toMatchObject(
|
||||
{
|
||||
@@ -239,7 +240,7 @@ describe("service proxy subsystem shape", () => {
|
||||
projectSlug: "repo-b",
|
||||
scriptName: "api",
|
||||
}),
|
||||
).toThrow("Service proxy hostname collision");
|
||||
).toThrow(ServiceProxyRouteCollisionError);
|
||||
|
||||
expect(serviceProxy.getRouteEntry("api--repo-a.localhost")).toMatchObject({ port: 3000 });
|
||||
expect(serviceProxy.getRouteEntry("api--repo-b.localhost")).toBeNull();
|
||||
@@ -265,7 +266,7 @@ describe("service proxy subsystem shape", () => {
|
||||
projectSlug: "other",
|
||||
scriptName: "api",
|
||||
}),
|
||||
).toThrow("Service proxy hostname collision");
|
||||
).toThrow(ServiceProxyRouteCollisionError);
|
||||
|
||||
expect(serviceProxy.getRouteEntry("api--repo.localhost")).toMatchObject({ port: 3000 });
|
||||
expect(serviceProxy.getRouteEntry("api.services.example.com")).toMatchObject({ port: 3000 });
|
||||
|
||||
@@ -360,7 +360,7 @@ export class ServiceProxyRouteCollisionError extends Error {
|
||||
public readonly incoming: Pick<ServiceProxyRouteEntry, "workspaceId" | "scriptName">,
|
||||
) {
|
||||
super(
|
||||
`Service proxy hostname collision for ${hostname}: ${existing.workspaceId}/${existing.scriptName} already owns it`,
|
||||
`Another workspace is already serving "${incoming.scriptName}" at ${hostname}. Stop that service or run this one on a different branch to free the address.`,
|
||||
);
|
||||
this.name = "ServiceProxyRouteCollisionError";
|
||||
}
|
||||
|
||||
@@ -64,18 +64,6 @@ async function expectActiveAgentListEmpty(): Promise<void> {
|
||||
expect(active.entries).toEqual([]);
|
||||
}
|
||||
|
||||
async function expectWorktreeAbsentFromList(repoDir: string, worktreePath: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir });
|
||||
return listed.worktrees.map((worktree) => worktree.worktreePath).includes(worktreePath);
|
||||
},
|
||||
{ timeout: 15000, interval: 100 },
|
||||
)
|
||||
.toBe(false);
|
||||
}
|
||||
|
||||
async function expectWorktreePresentInList(repoDir: string, worktreePath: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
@@ -148,8 +136,10 @@ test("create_agent_request creates a worktree and auto-archives both after the f
|
||||
|
||||
await ctx.client.waitForFinish(created.id, 10000);
|
||||
|
||||
// Archiving removes the task and its agents, but never deletes the worktree
|
||||
// from disk — a directory can back multiple workspaces.
|
||||
await expectAgentAbsentFromActiveList(created.id);
|
||||
await expectWorktreeAbsentFromList(repoDir, created.cwd);
|
||||
await expectWorktreePresentInList(repoDir, created.cwd);
|
||||
}, 30000);
|
||||
|
||||
test("create_agent_request with autoArchive archives only the agent when no worktree was created", async () => {
|
||||
@@ -216,14 +206,14 @@ test("create_agent_request with worktree but no autoArchive leaves agent and wor
|
||||
await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath });
|
||||
});
|
||||
|
||||
test("archiving a created worktree still archives nested agents", async () => {
|
||||
test("archiving a created worktree archives its agents but leaves the worktree on disk", async () => {
|
||||
const created = await createAgentInBranchOffWorktree();
|
||||
|
||||
await ctx.client.waitForFinish(created.agentId, 10000);
|
||||
await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath });
|
||||
|
||||
await expectAgentAbsentFromActiveList(created.agentId);
|
||||
await expectWorktreeAbsentFromList(created.repoDir, created.worktreePath);
|
||||
await expectWorktreePresentInList(created.repoDir, created.worktreePath);
|
||||
});
|
||||
|
||||
test("create_agent_request rejects legacy git options before creating a worktree", async () => {
|
||||
|
||||
@@ -164,6 +164,8 @@ import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
resolveProjectDisplayName,
|
||||
resolveWorkspaceDisplayName,
|
||||
resolveWorkspaceName,
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
type ProjectRegistry,
|
||||
@@ -241,6 +243,7 @@ import {
|
||||
import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js";
|
||||
import {
|
||||
attemptFirstAgentBranchAutoName,
|
||||
createLocalCheckoutWorkspace,
|
||||
createPaseoWorktree,
|
||||
type CreatePaseoWorktreeInput,
|
||||
type CreatePaseoWorktreeResult,
|
||||
@@ -257,6 +260,10 @@ import {
|
||||
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
|
||||
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
|
||||
} from "./worktree-session.js";
|
||||
import {
|
||||
type ActiveWorkspaceRef,
|
||||
archiveWorkspaceContents,
|
||||
} from "./paseo-worktree-archive-service.js";
|
||||
import { toWorktreeWireError } from "./worktree-errors.js";
|
||||
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
|
||||
|
||||
@@ -493,6 +500,7 @@ type FetchWorkspacesResponsePayload = Extract<
|
||||
>["payload"];
|
||||
type FetchWorkspacesResponseEntry = FetchWorkspacesResponsePayload["entries"][number];
|
||||
type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload["pageInfo"];
|
||||
type WorkspaceProjectDescriptorPayload = FetchWorkspacesResponsePayload["emptyProjects"][number];
|
||||
type WorkspaceUpdatePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_update" }
|
||||
@@ -967,6 +975,7 @@ export class Session {
|
||||
this.createPaseoWorktreeWorkflow(input, workflowOptions),
|
||||
archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId),
|
||||
resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
emit: (message) => this.emit(message),
|
||||
emitAgentRemove: (agentId) => {
|
||||
@@ -982,9 +991,8 @@ export class Session {
|
||||
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
|
||||
this.markWorkspaceArchiving(workspaceIds, archivingAt),
|
||||
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
|
||||
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
|
||||
killTerminalsUnderPath: (rootPath) =>
|
||||
this.terminalController.killTerminalsUnderPath(rootPath),
|
||||
killTerminalsForWorkspace: (workspaceId) =>
|
||||
this.terminalController.killTerminalsForWorkspace(workspaceId),
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.providerSnapshotManager = providerSnapshotManager;
|
||||
@@ -2174,8 +2182,12 @@ export class Session {
|
||||
return this.handleOpenProjectRequest(msg);
|
||||
case "archive_workspace_request":
|
||||
return this.handleArchiveWorkspaceRequest(msg);
|
||||
case "workspace.create.request":
|
||||
return this.handleWorkspaceCreateRequest(msg);
|
||||
case "workspace.clear_attention.request":
|
||||
return this.handleWorkspaceClearAttentionRequest(msg);
|
||||
case "workspace.title.set.request":
|
||||
return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId);
|
||||
case "file_explorer_request":
|
||||
return this.handleFileExplorerRequest(msg);
|
||||
case "project_icon_request":
|
||||
@@ -2667,6 +2679,82 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspaceTitleSetRequest(
|
||||
workspaceId: string,
|
||||
title: string | null,
|
||||
requestId: string,
|
||||
): Promise<void> {
|
||||
this.sessionLogger.info(
|
||||
{ workspaceId, requestId, hasTitle: typeof title === "string" },
|
||||
"session: workspace.title.set.request",
|
||||
);
|
||||
|
||||
try {
|
||||
const existing = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!existing) {
|
||||
this.emit({
|
||||
type: "workspace.title.set.response",
|
||||
payload: {
|
||||
requestId,
|
||||
workspaceId,
|
||||
accepted: false,
|
||||
title: null,
|
||||
error: "Workspace not found",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = title?.trim() ?? "";
|
||||
const nextTitle = trimmed.length === 0 ? null : trimmed;
|
||||
|
||||
await this.workspaceRegistry.upsert({
|
||||
...existing,
|
||||
title: nextTitle,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
this.emit({
|
||||
type: "workspace.title.set.response",
|
||||
payload: {
|
||||
requestId,
|
||||
workspaceId,
|
||||
accepted: true,
|
||||
title: nextTitle,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], {
|
||||
skipReconcile: true,
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, workspaceId, requestId },
|
||||
"session: workspace.title.set.request error",
|
||||
);
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Failed to set workspace title: ${getErrorMessage(error)}`,
|
||||
},
|
||||
});
|
||||
this.emit({
|
||||
type: "workspace.title.set.response",
|
||||
payload: {
|
||||
requestId,
|
||||
workspaceId,
|
||||
accepted: false,
|
||||
title: null,
|
||||
error: getErrorMessageOr(error, "Failed to set workspace title"),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private toVoiceFeatureUnavailableContext(
|
||||
state: SpeechReadinessState,
|
||||
): VoiceFeatureUnavailableContext {
|
||||
@@ -3175,7 +3263,7 @@ export class Session {
|
||||
{
|
||||
kind: "session",
|
||||
config: createAgentConfig,
|
||||
workspaceId: msg.workspaceId,
|
||||
workspaceId: createdWorktree ? createdWorktree.workspace.workspaceId : msg.workspaceId,
|
||||
worktreeName,
|
||||
initialPrompt,
|
||||
clientMessageId,
|
||||
@@ -3527,6 +3615,7 @@ export class Session {
|
||||
): Promise<{
|
||||
sessionConfig: AgentSessionConfig;
|
||||
setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"];
|
||||
createdWorkspaceId?: string;
|
||||
}> {
|
||||
return buildWorktreeAgentSessionConfig(
|
||||
{
|
||||
@@ -5826,6 +5915,7 @@ export class Session {
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
emit: (message) => this.emit(message),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
||||
@@ -5833,10 +5923,8 @@ export class Session {
|
||||
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
|
||||
this.markWorkspaceArchiving(workspaceIds, archivingAt),
|
||||
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
|
||||
isPathWithinRoot: (rootPath, candidatePath) =>
|
||||
this.isPathWithinRoot(rootPath, candidatePath),
|
||||
killTerminalsUnderPath: (rootPath) =>
|
||||
this.terminalController.killTerminalsUnderPath(rootPath),
|
||||
killTerminalsForWorkspace: (workspaceId) =>
|
||||
this.terminalController.killTerminalsForWorkspace(workspaceId),
|
||||
sessionLogger: this.sessionLogger,
|
||||
},
|
||||
msg,
|
||||
@@ -6076,7 +6164,7 @@ export class Session {
|
||||
}
|
||||
|
||||
private async listTerminalActivityContributions(): Promise<
|
||||
Array<{ cwd: string; activity: TerminalActivity | null }>
|
||||
Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }>
|
||||
> {
|
||||
const terminalManager = this.terminalManager;
|
||||
if (!terminalManager) {
|
||||
@@ -6086,10 +6174,17 @@ export class Session {
|
||||
const terminalsByDirectory = await Promise.all(
|
||||
directories.map((cwd) => terminalManager.getTerminals(cwd)),
|
||||
);
|
||||
return terminalsByDirectory.flat().map((session) => ({
|
||||
cwd: session.cwd,
|
||||
activity: session.getActivity(),
|
||||
}));
|
||||
return terminalsByDirectory.flat().map((session) => {
|
||||
const contribution: { cwd: string; workspaceId?: string; activity: TerminalActivity | null } =
|
||||
{
|
||||
cwd: session.cwd,
|
||||
activity: session.getActivity(),
|
||||
};
|
||||
if (session.workspaceId) {
|
||||
contribution.workspaceId = session.workspaceId;
|
||||
}
|
||||
return contribution;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6415,7 +6510,8 @@ export class Session {
|
||||
workspaceDirectory: workspace.cwd,
|
||||
projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git",
|
||||
workspaceKind: workspace.kind,
|
||||
name: workspace.displayName,
|
||||
name: resolveWorkspaceDisplayName(workspace),
|
||||
title: workspace.title,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
@@ -6486,7 +6582,7 @@ export class Session {
|
||||
|
||||
return {
|
||||
...base,
|
||||
name: displayName,
|
||||
name: resolveWorkspaceName({ title: workspace.title, derivedDisplayName: displayName }),
|
||||
diffStat: snapshot.git.diffStat ?? null,
|
||||
gitRuntime: this.buildWorkspaceGitRuntimePayload(snapshot) ?? undefined,
|
||||
githubRuntime: this.buildWorkspaceGitHubRuntimePayload(snapshot),
|
||||
@@ -6508,7 +6604,11 @@ export class Session {
|
||||
workspaceDirectory: result.workspace.cwd,
|
||||
projectKind: "git",
|
||||
workspaceKind: result.workspace.kind,
|
||||
name: result.worktree.branchName || result.workspace.displayName,
|
||||
name: resolveWorkspaceName({
|
||||
title: result.workspace.title,
|
||||
derivedDisplayName: result.worktree.branchName || result.workspace.displayName,
|
||||
}),
|
||||
title: result.workspace.title,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
@@ -6577,6 +6677,7 @@ export class Session {
|
||||
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
|
||||
): Promise<{
|
||||
entries: FetchWorkspacesResponseEntry[];
|
||||
emptyProjects: WorkspaceProjectDescriptorPayload[];
|
||||
pageInfo: FetchWorkspacesResponsePageInfo;
|
||||
}> {
|
||||
try {
|
||||
@@ -6844,13 +6945,23 @@ export class Session {
|
||||
return result;
|
||||
}
|
||||
|
||||
private async listActiveWorkspaceRefs(): Promise<ActiveWorkspaceRef[]> {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
return workspaces
|
||||
.filter((workspace) => !workspace.archivedAt)
|
||||
.map((workspace) => ({
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.cwd,
|
||||
kind: workspace.kind,
|
||||
}));
|
||||
}
|
||||
|
||||
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
||||
const archiveTimestamp = archivedAt ?? new Date().toISOString();
|
||||
const existingWorkspace = await archivePersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
archivedAt: archiveTimestamp,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
projectRegistry: this.projectRegistry,
|
||||
});
|
||||
if (!existingWorkspace) {
|
||||
const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId);
|
||||
@@ -6999,6 +7110,7 @@ export class Session {
|
||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||
kind: "remove",
|
||||
id: workspaceId,
|
||||
...(await this.resolveEmptyProjectForArchivedWorkspace(workspaceId)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -7025,6 +7137,22 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
// When a workspace is archived its project may become empty. Resolve the
|
||||
// now-empty project parent so the `remove` update can carry it, keeping the
|
||||
// sidebar's empty project row in sync without a full re-hydration.
|
||||
private async resolveEmptyProjectForArchivedWorkspace(
|
||||
workspaceId: string,
|
||||
): Promise<{ emptyProject: WorkspaceProjectDescriptorPayload } | null> {
|
||||
const archivedWorkspace = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!archivedWorkspace) {
|
||||
return null;
|
||||
}
|
||||
const emptyProject = (await this.workspaceDirectory.listEmptyProjects()).find(
|
||||
(project) => project.projectId === archivedWorkspace.projectId,
|
||||
);
|
||||
return emptyProject ? { emptyProject } : null;
|
||||
}
|
||||
|
||||
private async emitWorkspaceUpdateForCwd(
|
||||
cwd: string,
|
||||
options?: {
|
||||
@@ -7280,6 +7408,168 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspaceCreateRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (request.backing === "local") {
|
||||
await this.handleWorkspaceCreateLocal(request);
|
||||
return;
|
||||
}
|
||||
await this.handleWorkspaceCreateWorktree(request);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create workspace";
|
||||
this.sessionLogger.error(
|
||||
{ err: error, backing: request.backing, requestId: request.requestId },
|
||||
"Failed to create workspace",
|
||||
);
|
||||
this.emit({
|
||||
type: "workspace.create.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspace: null,
|
||||
setupTerminalId: null,
|
||||
error: message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspaceCreateLocal(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
||||
): Promise<void> {
|
||||
if (!request.cwd) {
|
||||
this.emit({
|
||||
type: "workspace.create.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspace: null,
|
||||
setupTerminalId: null,
|
||||
error: "cwd is required for a local-backed workspace",
|
||||
errorCode: "cwd_required",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cwd = expandTilde(request.cwd);
|
||||
const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false);
|
||||
if (!directoryExists) {
|
||||
this.emit({
|
||||
type: "workspace.create.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspace: null,
|
||||
setupTerminalId: null,
|
||||
error: `Directory not found: ${cwd}`,
|
||||
errorCode: "directory_not_found",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd, title: request.title ?? null },
|
||||
{
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
},
|
||||
);
|
||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||
const descriptor = await this.describeWorkspaceRecord(workspace);
|
||||
this.emit({
|
||||
type: "workspace.create.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspace: descriptor,
|
||||
setupTerminalId: null,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
|
||||
void this.workspaceGitService
|
||||
.getSnapshot(workspace.cwd, { force: true, includeGitHub: true, reason: "open_project" })
|
||||
.catch((error) => {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, cwd: workspace.cwd },
|
||||
"Background snapshot refresh failed after workspace.create",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleWorkspaceCreateWorktree(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
||||
): Promise<void> {
|
||||
if (!request.cwd && !request.projectId) {
|
||||
this.emit({
|
||||
type: "workspace.create.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspace: null,
|
||||
setupTerminalId: null,
|
||||
error: "cwd or projectId is required for a worktree-backed workspace",
|
||||
errorCode: "source_required",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceCwd = await this.resolveWorktreeSourceCwd({
|
||||
cwd: request.cwd,
|
||||
projectId: request.projectId,
|
||||
});
|
||||
|
||||
const result = await this.createPaseoWorktreeWorkflow(
|
||||
{
|
||||
cwd: sourceCwd,
|
||||
projectId: request.projectId,
|
||||
worktreeSlug: request.branch,
|
||||
},
|
||||
request.baseBranch
|
||||
? { resolveDefaultBranch: async () => request.baseBranch as string }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (request.title?.trim()) {
|
||||
await this.workspaceRegistry.upsert({
|
||||
...result.workspace,
|
||||
title: request.title.trim(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
result.workspace.title = request.title.trim();
|
||||
}
|
||||
|
||||
const descriptor = await this.describeCreatedWorktreeWorkspace(result);
|
||||
this.emit({
|
||||
type: "workspace.create.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspace: descriptor,
|
||||
setupTerminalId: null,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
this.emit({
|
||||
type: "workspace_update",
|
||||
payload: { kind: "upsert", workspace: descriptor },
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveWorktreeSourceCwd(input: {
|
||||
cwd?: string;
|
||||
projectId?: string;
|
||||
}): Promise<string> {
|
||||
if (input.cwd) {
|
||||
return expandTilde(input.cwd);
|
||||
}
|
||||
const project = await this.projectRegistry.get(input.projectId as string);
|
||||
if (!project || project.archivedAt) {
|
||||
throw new Error(`Project not found: ${input.projectId}`);
|
||||
}
|
||||
return project.rootPath;
|
||||
}
|
||||
|
||||
private async handleOpenProjectRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -7586,7 +7876,26 @@ export class Session {
|
||||
throw new Error("Use worktree archive for Paseo worktrees");
|
||||
}
|
||||
const archivedAt = new Date().toISOString();
|
||||
await this.archiveWorkspaceRecord(existing.workspaceId, archivedAt);
|
||||
// Archive removes the task (the workspace record) and everything the
|
||||
// workspace owns — its agents and terminals — scoped by workspaceId so a
|
||||
// sibling workspace sharing the same directory is untouched. The directory
|
||||
// itself is never deleted here; local checkouts are not Paseo-owned.
|
||||
this.markWorkspaceArchiving([existing.workspaceId], archivedAt);
|
||||
try {
|
||||
await archiveWorkspaceContents(
|
||||
{
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
killTerminalsForWorkspace: (workspaceId) =>
|
||||
this.terminalController.killTerminalsForWorkspace(workspaceId),
|
||||
sessionLogger: this.sessionLogger,
|
||||
},
|
||||
existing.workspaceId,
|
||||
);
|
||||
await this.archiveWorkspaceRecord(existing.workspaceId, archivedAt);
|
||||
} finally {
|
||||
this.clearWorkspaceArchiving([existing.workspaceId]);
|
||||
}
|
||||
await this.emitWorkspaceUpdateForCwd(existing.cwd);
|
||||
this.emit({
|
||||
type: "archive_workspace_response",
|
||||
|
||||
@@ -857,7 +857,7 @@ test("workspace reconciliation reports archived workspaces to subscribed clients
|
||||
|
||||
expect(changedWorkspaceIds).toEqual(new Set(["ws-missing"]));
|
||||
expect(workspaces.get("ws-missing")?.archivedAt).toBeTruthy();
|
||||
expect(projects.get("proj-missing")?.archivedAt).toBeTruthy();
|
||||
expect(projects.get("proj-missing")?.archivedAt).toBeFalsy();
|
||||
});
|
||||
|
||||
test("agent_update placement does not refresh git snapshots", async () => {
|
||||
@@ -2765,6 +2765,67 @@ test("workspace update stream keeps persisted workspace visible after agents sto
|
||||
});
|
||||
});
|
||||
|
||||
test("archiving the last workspace emits a remove carrying the now-empty project", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => emitted.push(message),
|
||||
});
|
||||
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-empty-after-archive",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const archivedWorkspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-last",
|
||||
projectId: project.projectId,
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
archivedAt: "2026-03-02T12:00:00.000Z",
|
||||
});
|
||||
|
||||
session.projectRegistry.list = async () => [project];
|
||||
session.workspaceRegistry.list = async () => [archivedWorkspace];
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaceId === archivedWorkspace.workspaceId ? archivedWorkspace : null;
|
||||
|
||||
session.workspaceUpdatesSubscription = {
|
||||
subscriptionId: "sub-1",
|
||||
filter: undefined,
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
lastEmittedByWorkspaceId: new Map(),
|
||||
};
|
||||
session.reconcileActiveWorkspaceRecords = async () => new Set();
|
||||
// The archived workspace no longer resolves to an active descriptor.
|
||||
session.buildWorkspaceDescriptorMap = async () => new Map();
|
||||
|
||||
await session.emitWorkspaceUpdatesForWorkspaceIds([archivedWorkspace.workspaceId], {
|
||||
skipReconcile: true,
|
||||
});
|
||||
|
||||
const removeUpdate = filterByType(emitted, "workspace_update").find(
|
||||
(message) => message.payload.kind === "remove",
|
||||
);
|
||||
expect(removeUpdate?.payload).toEqual({
|
||||
kind: "remove",
|
||||
id: archivedWorkspace.workspaceId,
|
||||
emptyProject: {
|
||||
projectId: project.projectId,
|
||||
projectDisplayName: "repo",
|
||||
projectCustomName: null,
|
||||
projectRootPath: REPO_CWD,
|
||||
projectKind: "git",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("create paseo worktree request returns a registered workspace descriptor", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-")));
|
||||
@@ -5155,6 +5216,145 @@ test("project.rename.request returns accepted=false when project is not found",
|
||||
expect(response?.payload.error).toBeTruthy();
|
||||
});
|
||||
|
||||
test("workspace.title.set.request stores the title and emits an updated descriptor", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
||||
);
|
||||
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-1",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-1",
|
||||
projectId: project.projectId,
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
|
||||
const projects = new Map([[project.projectId, project]]);
|
||||
const workspaces = new Map([[workspace.workspaceId, workspace]]);
|
||||
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
|
||||
session.projectRegistry.list = async () => Array.from(projects.values());
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
|
||||
session.workspaceRegistry.upsert = async (record: unknown) => {
|
||||
const parsed = record as typeof workspace;
|
||||
workspaces.set(parsed.workspaceId, parsed);
|
||||
};
|
||||
|
||||
session.workspaceUpdatesSubscription = {
|
||||
subscriptionId: "sub-workspaces",
|
||||
filter: {},
|
||||
isBootstrapping: false,
|
||||
lastEmittedByWorkspaceId: new Map(),
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
};
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.title.set.request",
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: " Payments work ",
|
||||
requestId: "req-title-1",
|
||||
});
|
||||
|
||||
const response = findByType(emitted, "workspace.title.set.response");
|
||||
expect(response?.payload).toEqual({
|
||||
requestId: "req-title-1",
|
||||
workspaceId: workspace.workspaceId,
|
||||
accepted: true,
|
||||
title: "Payments work",
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(workspaces.get(workspace.workspaceId)?.title).toBe("Payments work");
|
||||
|
||||
const update = findByType(emitted, "workspace_update");
|
||||
expect(update?.payload).toMatchObject({
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: "ws-1",
|
||||
name: "Payments work",
|
||||
title: "Payments work",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace.title.set.request with whitespace-only title clears the title", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
||||
);
|
||||
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-1",
|
||||
projectId: "proj-1",
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
title: "Old title",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
|
||||
const workspaces = new Map([[workspace.workspaceId, workspace]]);
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
|
||||
session.workspaceRegistry.upsert = async (record: unknown) => {
|
||||
const parsed = record as typeof workspace;
|
||||
workspaces.set(parsed.workspaceId, parsed);
|
||||
};
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.title.set.request",
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: " ",
|
||||
requestId: "req-title-clear",
|
||||
});
|
||||
|
||||
const response = findByType(emitted, "workspace.title.set.response");
|
||||
expect(response?.payload).toEqual({
|
||||
requestId: "req-title-clear",
|
||||
workspaceId: workspace.workspaceId,
|
||||
accepted: true,
|
||||
title: null,
|
||||
error: null,
|
||||
});
|
||||
expect(workspaces.get(workspace.workspaceId)?.title).toBeNull();
|
||||
});
|
||||
|
||||
test("workspace.title.set.request returns accepted=false when workspace is not found", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
||||
);
|
||||
session.workspaceRegistry.get = async () => null;
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.title.set.request",
|
||||
workspaceId: "does-not-exist",
|
||||
title: "X",
|
||||
requestId: "req-title-missing",
|
||||
});
|
||||
|
||||
const response = findByType(emitted, "workspace.title.set.response");
|
||||
expect(response?.payload).toMatchObject({
|
||||
requestId: "req-title-missing",
|
||||
workspaceId: "does-not-exist",
|
||||
accepted: false,
|
||||
title: null,
|
||||
});
|
||||
expect(response?.payload.error).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a prefix", () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const home = homedir();
|
||||
|
||||
@@ -1150,6 +1150,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
rewind: true,
|
||||
// COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29.
|
||||
checkoutRefresh: true,
|
||||
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
workspaceMultiplicity: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
|
||||
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js";
|
||||
|
||||
// Model B archive is scoped to a single workspace RECORD (by workspaceId), not
|
||||
// to a directory on disk. A directory can back multiple workspaces, so archiving
|
||||
// one must never tear down a sibling's agents/terminals, and must never delete a
|
||||
// directory another workspace still references. On-disk worktree removal is an
|
||||
// explicit, last-reference-only opt-in (deleteWorktreeFromDisk).
|
||||
|
||||
let ctx: DaemonTestContext;
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
for (const tempRoot of tempRoots.splice(0)) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), prefix));
|
||||
tempRoots.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function createGitRepo(): string {
|
||||
const tempRoot = makeTempDir("workspace-archive-repo-");
|
||||
const repoDir = path.join(tempRoot, "repo");
|
||||
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
async function createLocalWorkspace(cwd: string, title: string): Promise<string> {
|
||||
const result = await ctx.client.createWorkspace({ backing: "local", cwd, title });
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? "Failed to create local workspace");
|
||||
}
|
||||
return result.workspace.id;
|
||||
}
|
||||
|
||||
async function activeWorkspaceIds(): Promise<Set<string>> {
|
||||
const workspaces = await ctx.client.fetchWorkspaces();
|
||||
return new Set(workspaces.entries.map((entry) => entry.id));
|
||||
}
|
||||
|
||||
async function activeAgentIds(): Promise<Set<string>> {
|
||||
const agents = await ctx.client.fetchAgents();
|
||||
return new Set(agents.entries.map((entry) => entry.agent.id));
|
||||
}
|
||||
|
||||
async function archivedAgentIds(): Promise<Set<string>> {
|
||||
const agents = await ctx.client.fetchAgents({ filter: { includeArchived: true } });
|
||||
return new Set(
|
||||
agents.entries
|
||||
.filter((entry) => entry.agent.archivedAt !== null && entry.agent.archivedAt !== undefined)
|
||||
.map((entry) => entry.agent.id),
|
||||
);
|
||||
}
|
||||
|
||||
async function terminalIdsForWorkspace(cwd: string, workspaceId: string): Promise<Set<string>> {
|
||||
const listed = await ctx.client.listTerminals(cwd, undefined, { workspaceId });
|
||||
return new Set(listed.terminals.map((terminal) => terminal.id));
|
||||
}
|
||||
|
||||
test("archiving one of two workspaces sharing a cwd spares the sibling and the directory", async () => {
|
||||
const cwd = makeTempDir("workspace-archive-shared-cwd-");
|
||||
|
||||
const workspaceA = await createLocalWorkspace(cwd, "workspace-a");
|
||||
const workspaceB = await createLocalWorkspace(cwd, "workspace-b");
|
||||
expect(workspaceA).not.toBe(workspaceB);
|
||||
|
||||
const agentA = await ctx.client.createAgent({
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd,
|
||||
workspaceId: workspaceA,
|
||||
title: "A agent",
|
||||
initialPrompt: "Say done.",
|
||||
});
|
||||
const agentB = await ctx.client.createAgent({
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd,
|
||||
workspaceId: workspaceB,
|
||||
title: "B agent",
|
||||
initialPrompt: "Say done.",
|
||||
});
|
||||
expect(agentA.workspaceId).toBe(workspaceA);
|
||||
expect(agentB.workspaceId).toBe(workspaceB);
|
||||
|
||||
const terminalA = await ctx.client.createTerminal(cwd, "A terminal", undefined, {
|
||||
workspaceId: workspaceA,
|
||||
});
|
||||
const terminalB = await ctx.client.createTerminal(cwd, "B terminal", undefined, {
|
||||
workspaceId: workspaceB,
|
||||
});
|
||||
const terminalAId = terminalA.terminal?.id;
|
||||
const terminalBId = terminalB.terminal?.id;
|
||||
if (!terminalAId || !terminalBId) {
|
||||
throw new Error("Expected both terminals to be created");
|
||||
}
|
||||
|
||||
// Both workspaces, both agents, and both terminals exist before the archive.
|
||||
expect(await activeWorkspaceIds()).toContain(workspaceA);
|
||||
expect(await activeWorkspaceIds()).toContain(workspaceB);
|
||||
expect(await activeAgentIds()).toContain(agentA.id);
|
||||
expect(await activeAgentIds()).toContain(agentB.id);
|
||||
expect(await terminalIdsForWorkspace(cwd, workspaceA)).toContain(terminalAId);
|
||||
expect(await terminalIdsForWorkspace(cwd, workspaceB)).toContain(terminalBId);
|
||||
|
||||
// Archive workspace A by its workspaceId. Because two workspaces share the cwd,
|
||||
// teardown must be scoped to A's workspaceId, not the directory.
|
||||
const archive = await ctx.client.archiveWorkspace(workspaceA);
|
||||
expect(archive.error).toBe(null);
|
||||
|
||||
await expect
|
||||
.poll(async () => (await activeWorkspaceIds()).has(workspaceA), {
|
||||
timeout: 10000,
|
||||
interval: 100,
|
||||
})
|
||||
.toBe(false);
|
||||
|
||||
// A's record is gone; B's survives.
|
||||
const remainingWorkspaces = await activeWorkspaceIds();
|
||||
expect(remainingWorkspaces.has(workspaceA)).toBe(false);
|
||||
expect(remainingWorkspaces.has(workspaceB)).toBe(true);
|
||||
|
||||
// A's agent is archived; B's agent stays active.
|
||||
expect((await activeAgentIds()).has(agentA.id)).toBe(false);
|
||||
expect(await archivedAgentIds()).toContain(agentA.id);
|
||||
expect((await activeAgentIds()).has(agentB.id)).toBe(true);
|
||||
|
||||
// A's terminal is killed; B's terminal survives.
|
||||
expect((await terminalIdsForWorkspace(cwd, workspaceA)).has(terminalAId)).toBe(false);
|
||||
expect((await terminalIdsForWorkspace(cwd, workspaceB)).has(terminalBId)).toBe(true);
|
||||
|
||||
// The shared directory is never deleted — a sibling still references it.
|
||||
expect(existsSync(cwd)).toBe(true);
|
||||
|
||||
await ctx.client.killTerminal(terminalBId);
|
||||
}, 60000);
|
||||
|
||||
test("archiving the last reference to a worktree honors deleteWorktreeFromDisk", async () => {
|
||||
const repoDir = createGitRepo();
|
||||
|
||||
const keepResult = await ctx.client.createWorkspace({
|
||||
backing: "worktree",
|
||||
cwd: repoDir,
|
||||
branch: "keep-on-disk",
|
||||
baseBranch: "main",
|
||||
});
|
||||
const keepWorkspace = keepResult.workspace;
|
||||
if (!keepWorkspace?.workspaceDirectory) {
|
||||
throw new Error(keepResult.error ?? "Failed to create worktree workspace");
|
||||
}
|
||||
const keepDir = keepWorkspace.workspaceDirectory;
|
||||
expect(existsSync(keepDir)).toBe(true);
|
||||
|
||||
// Last reference, deleteWorktreeFromDisk omitted (defaults false) → dir stays.
|
||||
const keepArchive = await ctx.client.archivePaseoWorktree({ worktreePath: keepDir });
|
||||
expect(keepArchive.success).toBe(true);
|
||||
await expect
|
||||
.poll(async () => (await activeWorkspaceIds()).has(keepWorkspace.id), {
|
||||
timeout: 10000,
|
||||
interval: 100,
|
||||
})
|
||||
.toBe(false);
|
||||
expect(existsSync(keepDir)).toBe(true);
|
||||
|
||||
const deleteResult = await ctx.client.createWorkspace({
|
||||
backing: "worktree",
|
||||
cwd: repoDir,
|
||||
branch: "delete-from-disk",
|
||||
baseBranch: "main",
|
||||
});
|
||||
const deleteWorkspace = deleteResult.workspace;
|
||||
if (!deleteWorkspace?.workspaceDirectory) {
|
||||
throw new Error(deleteResult.error ?? "Failed to create worktree workspace");
|
||||
}
|
||||
const deleteDir = deleteWorkspace.workspaceDirectory;
|
||||
expect(existsSync(deleteDir)).toBe(true);
|
||||
|
||||
// Last reference, deleteWorktreeFromDisk true → dir is removed from disk.
|
||||
const deleteArchive = await ctx.client.archivePaseoWorktree({
|
||||
worktreePath: deleteDir,
|
||||
deleteWorktreeFromDisk: true,
|
||||
});
|
||||
expect(deleteArchive.success).toBe(true);
|
||||
await expect
|
||||
.poll(async () => (await activeWorkspaceIds()).has(deleteWorkspace.id), {
|
||||
timeout: 10000,
|
||||
interval: 100,
|
||||
})
|
||||
.toBe(false);
|
||||
await expect.poll(() => existsSync(deleteDir), { timeout: 10000, interval: 100 }).toBe(false);
|
||||
}, 60000);
|
||||
|
||||
test("worktree archive targets the explicit workspaceId when a directory backs multiple workspaces", async () => {
|
||||
const repoDir = createGitRepo();
|
||||
|
||||
const worktreeResult = await ctx.client.createWorkspace({
|
||||
backing: "worktree",
|
||||
cwd: repoDir,
|
||||
branch: "targeted-worktree",
|
||||
baseBranch: "main",
|
||||
});
|
||||
const worktreeWorkspace = worktreeResult.workspace;
|
||||
if (!worktreeWorkspace?.workspaceDirectory) {
|
||||
throw new Error(worktreeResult.error ?? "Failed to create worktree workspace");
|
||||
}
|
||||
const worktreeDir = worktreeWorkspace.workspaceDirectory;
|
||||
|
||||
// A local workspace records the SAME directory as its cwd. Resolving the
|
||||
// archive target by cwd alone is ambiguous; the explicit workspaceId must win.
|
||||
const localWorkspaceId = await createLocalWorkspace(worktreeDir, "local-sibling");
|
||||
expect(localWorkspaceId).not.toBe(worktreeWorkspace.id);
|
||||
|
||||
const archive = await ctx.client.archivePaseoWorktree({
|
||||
worktreePath: worktreeDir,
|
||||
workspaceId: localWorkspaceId,
|
||||
});
|
||||
expect(archive.success).toBe(true);
|
||||
|
||||
// Exactly the targeted workspace is archived; the worktree-backed sibling stays.
|
||||
await expect
|
||||
.poll(async () => (await activeWorkspaceIds()).has(localWorkspaceId), {
|
||||
timeout: 10000,
|
||||
interval: 100,
|
||||
})
|
||||
.toBe(false);
|
||||
const remaining = await activeWorkspaceIds();
|
||||
expect(remaining.has(localWorkspaceId)).toBe(false);
|
||||
expect(remaining.has(worktreeWorkspace.id)).toBe(true);
|
||||
expect(existsSync(worktreeDir)).toBe(true);
|
||||
|
||||
await ctx.client.archivePaseoWorktree({
|
||||
worktreePath: worktreeDir,
|
||||
workspaceId: worktreeWorkspace.id,
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
test("deleteWorktreeFromDisk keeps the worktree when a sibling workspace still references it", async () => {
|
||||
const repoDir = createGitRepo();
|
||||
|
||||
const worktreeResult = await ctx.client.createWorkspace({
|
||||
backing: "worktree",
|
||||
cwd: repoDir,
|
||||
branch: "shared-worktree",
|
||||
baseBranch: "main",
|
||||
});
|
||||
const worktreeWorkspace = worktreeResult.workspace;
|
||||
if (!worktreeWorkspace?.workspaceDirectory) {
|
||||
throw new Error(worktreeResult.error ?? "Failed to create worktree workspace");
|
||||
}
|
||||
const worktreeDir = worktreeWorkspace.workspaceDirectory;
|
||||
expect(existsSync(worktreeDir)).toBe(true);
|
||||
|
||||
// A second workspace records the SAME worktree directory as its cwd.
|
||||
const siblingWorkspaceId = await createLocalWorkspace(worktreeDir, "sibling");
|
||||
expect(siblingWorkspaceId).not.toBe(worktreeWorkspace.id);
|
||||
|
||||
// Archive the worktree-backed workspace with deleteWorktreeFromDisk true.
|
||||
// It is NOT the last reference, so the directory must survive.
|
||||
const archive = await ctx.client.archivePaseoWorktree({
|
||||
worktreePath: worktreeDir,
|
||||
deleteWorktreeFromDisk: true,
|
||||
});
|
||||
expect(archive.success).toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(async () => (await activeWorkspaceIds()).has(worktreeWorkspace.id), {
|
||||
timeout: 10000,
|
||||
interval: 100,
|
||||
})
|
||||
.toBe(false);
|
||||
|
||||
const remaining = await activeWorkspaceIds();
|
||||
expect(remaining.has(worktreeWorkspace.id)).toBe(false);
|
||||
expect(remaining.has(siblingWorkspaceId)).toBe(true);
|
||||
expect(existsSync(worktreeDir)).toBe(true);
|
||||
}, 60000);
|
||||
@@ -1,13 +1,11 @@
|
||||
import type {
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js";
|
||||
|
||||
// Archiving the last workspace of a project leaves the project as a first-class
|
||||
// empty project — it persists until explicitly removed, so we never archive the
|
||||
// parent project here.
|
||||
export async function archivePersistedWorkspaceRecord(input: {
|
||||
workspaceId: string;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "archive">;
|
||||
projectRegistry: Pick<ProjectRegistry, "archive">;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "archive">;
|
||||
archivedAt?: string;
|
||||
}): Promise<PersistedWorkspaceRecord | null> {
|
||||
const existingWorkspace = await input.workspaceRegistry.get(input.workspaceId);
|
||||
@@ -21,12 +19,6 @@ export async function archivePersistedWorkspaceRecord(input: {
|
||||
|
||||
const archivedAt = input.archivedAt ?? new Date().toISOString();
|
||||
await input.workspaceRegistry.archive(input.workspaceId, archivedAt);
|
||||
const activeSiblings = (await input.workspaceRegistry.list()).filter(
|
||||
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
|
||||
);
|
||||
if (activeSiblings.length === 0) {
|
||||
await input.projectRegistry.archive(existingWorkspace.projectId, archivedAt);
|
||||
}
|
||||
|
||||
return existingWorkspace;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { DaemonClient } from "./test-utils/index.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
|
||||
// workspace.create has four reject branches before it ever touches the
|
||||
// registries; this pins each one's errorCode (or, for project-not-found, its
|
||||
// message) as seen by the daemon client, so the CLI/app contract on top of them
|
||||
// stays covered.
|
||||
test("workspace.create surfaces each early-reject error branch", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const missingDir = path.join(tmpdir(), `paseo-workspace-create-missing-${Date.now()}`);
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.82",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
// local backing without a cwd -> cwd_required
|
||||
const cwdRequired = await client.createWorkspace({ backing: "local" });
|
||||
expect(cwdRequired.workspace).toBeNull();
|
||||
expect(cwdRequired.errorCode).toBe("cwd_required");
|
||||
|
||||
// local backing pointed at a path that does not exist -> directory_not_found
|
||||
const directoryNotFound = await client.createWorkspace({
|
||||
backing: "local",
|
||||
cwd: missingDir,
|
||||
});
|
||||
expect(directoryNotFound.workspace).toBeNull();
|
||||
expect(directoryNotFound.errorCode).toBe("directory_not_found");
|
||||
expect(directoryNotFound.error).toContain(missingDir);
|
||||
|
||||
// worktree backing without a cwd or projectId -> source_required
|
||||
const sourceRequired = await client.createWorkspace({ backing: "worktree", branch: "feat" });
|
||||
expect(sourceRequired.workspace).toBeNull();
|
||||
expect(sourceRequired.errorCode).toBe("source_required");
|
||||
|
||||
// worktree backing with an unknown projectId -> project-not-found message
|
||||
// (surfaced via the generic catch, so it carries an error but no errorCode)
|
||||
const projectNotFound = await client.createWorkspace({
|
||||
backing: "worktree",
|
||||
projectId: "proj-does-not-exist",
|
||||
branch: "feat",
|
||||
});
|
||||
expect(projectNotFound.workspace).toBeNull();
|
||||
expect(projectNotFound.error).toContain("Project not found: proj-does-not-exist");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(missingDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 180000);
|
||||
@@ -42,10 +42,27 @@ class WorkspaceStatus {
|
||||
archivedAt: null,
|
||||
};
|
||||
|
||||
// Second workspace sharing the SAME cwd as `workspace`. Created later so the
|
||||
// deterministic-oldest fallback never attributes a stamped agent to it by cwd.
|
||||
private readonly sameCwdWorkspace: PersistedWorkspaceRecord = {
|
||||
workspaceId: "workspace-1-sibling",
|
||||
projectId: this.project.projectId,
|
||||
cwd: this.project.rootPath,
|
||||
kind: "local_checkout",
|
||||
displayName: "main-2",
|
||||
createdAt: "2026-03-02T12:00:00.000Z",
|
||||
updatedAt: "2026-03-02T12:00:00.000Z",
|
||||
archivedAt: null,
|
||||
};
|
||||
|
||||
private readonly workspaces = [this.workspace];
|
||||
|
||||
private readonly agents: AgentSnapshotPayload[] = [];
|
||||
private readonly terminals: Array<{ cwd: string; activity: TerminalActivity | null }> = [];
|
||||
private readonly terminals: Array<{
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
activity: TerminalActivity | null;
|
||||
}> = [];
|
||||
private readonly directory = new WorkspaceDirectory({
|
||||
logger: createTestLogger(),
|
||||
projectRegistry: { list: async () => [this.project] },
|
||||
@@ -77,6 +94,18 @@ class WorkspaceStatus {
|
||||
this.agents.push(createAgent({ ...input, cwd: this.workspace.cwd }));
|
||||
}
|
||||
|
||||
hasSiblingWorkspaceSameCwd(): void {
|
||||
this.workspaces.push(this.sameCwdWorkspace);
|
||||
}
|
||||
|
||||
// A root agent owned by a specific workspace, even though both same-cwd
|
||||
// workspaces share the directory. Attribution must follow workspaceId.
|
||||
hasStampedRootAgent(input: AgentState & { workspaceId: string }): void {
|
||||
this.agents.push(
|
||||
createAgent({ ...input, cwd: this.workspace.cwd, workspaceId: input.workspaceId }),
|
||||
);
|
||||
}
|
||||
|
||||
hasDelegatedAgent(input: AgentState): void {
|
||||
this.agents.push(
|
||||
createAgent({
|
||||
@@ -124,6 +153,16 @@ class WorkspaceStatus {
|
||||
});
|
||||
}
|
||||
|
||||
// A working terminal owned by a specific same-cwd workspace. Attribution must
|
||||
// follow workspaceId, not the deterministic-oldest cwd fallback.
|
||||
hasStampedWorkingTerminal(input: { workspaceId: string; changedAt: number }): void {
|
||||
this.terminals.push({
|
||||
cwd: this.workspace.cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
activity: { state: "working", changedAt: input.changedAt },
|
||||
});
|
||||
}
|
||||
|
||||
hasWorkingTerminalInSubdirectory(changedAt: number): void {
|
||||
this.terminals.push({
|
||||
cwd: `${this.workspace.cwd}/packages/app`,
|
||||
@@ -166,12 +205,15 @@ interface AgentState {
|
||||
attentionReason?: AgentSnapshotPayload["attentionReason"];
|
||||
}
|
||||
|
||||
function createAgent(input: AgentState & { cwd: string; labels?: Record<string, string> }) {
|
||||
function createAgent(
|
||||
input: AgentState & { cwd: string; labels?: Record<string, string>; workspaceId?: string },
|
||||
) {
|
||||
const pendingPermissionCount = input.pendingPermissionCount ?? 0;
|
||||
return {
|
||||
id: input.id,
|
||||
provider: "codex",
|
||||
cwd: input.cwd,
|
||||
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
@@ -229,6 +271,46 @@ describe("WorkspaceDirectory", () => {
|
||||
await expect(workspace.workspaceStatus()).resolves.toBe("running");
|
||||
});
|
||||
|
||||
test("two same-cwd workspaces keep independent status, attributed by workspaceId", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
workspace.hasSiblingWorkspaceSameCwd();
|
||||
// The running agent is stamped to the SIBLING. A cwd-only fallback would
|
||||
// attribute it to the older `workspace-1` and leave the sibling "done", so
|
||||
// this asserts the running status follows workspaceId, not directory.
|
||||
workspace.hasStampedRootAgent({
|
||||
id: "agent-a",
|
||||
status: "running",
|
||||
workspaceId: "workspace-1-sibling",
|
||||
});
|
||||
workspace.hasStampedRootAgent({
|
||||
id: "agent-b",
|
||||
status: "idle",
|
||||
workspaceId: "workspace-1",
|
||||
});
|
||||
|
||||
await expect(workspace.workspaceStatuses()).resolves.toEqual({
|
||||
"workspace-1": "done",
|
||||
"workspace-1-sibling": "running",
|
||||
});
|
||||
});
|
||||
|
||||
test("two same-cwd workspaces keep independent terminal status, attributed by workspaceId", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
const changedAt = new Date(NOW).getTime();
|
||||
|
||||
workspace.hasSiblingWorkspaceSameCwd();
|
||||
// The working terminal is owned by the SIBLING. A cwd-only resolver would
|
||||
// attribute it to the older `workspace-1`, so this asserts the running
|
||||
// status follows the terminal's workspaceId, not the directory.
|
||||
workspace.hasStampedWorkingTerminal({ workspaceId: "workspace-1-sibling", changedAt });
|
||||
|
||||
await expect(workspace.workspaceStatuses()).resolves.toEqual({
|
||||
"workspace-1": "done",
|
||||
"workspace-1-sibling": "running",
|
||||
});
|
||||
});
|
||||
|
||||
test("running delegated child contributes running to the parent workspace, not its worktree", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
@@ -313,3 +395,97 @@ describe("WorkspaceDirectory", () => {
|
||||
expect(descriptor.statusEnteredAt).toBe("2027-01-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceDirectory empty projects", () => {
|
||||
function makeDirectory(input: {
|
||||
projects: PersistedProjectRecord[];
|
||||
workspaces: PersistedWorkspaceRecord[];
|
||||
}): WorkspaceDirectory {
|
||||
return new WorkspaceDirectory({
|
||||
logger: createTestLogger(),
|
||||
projectRegistry: { list: async () => input.projects },
|
||||
workspaceRegistry: { list: async () => input.workspaces },
|
||||
listAgentPayloads: async () => [],
|
||||
listTerminalActivityContributions: async () => [],
|
||||
isProviderVisibleToClient: () => true,
|
||||
buildWorkspaceDescriptor: async ({ workspace }) => ({
|
||||
id: workspace.workspaceId,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: "project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/workspace/project",
|
||||
workspaceDirectory: workspace.cwd,
|
||||
projectKind: "non_git",
|
||||
workspaceKind: workspace.kind,
|
||||
name: workspace.displayName,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
gitRuntime: null,
|
||||
githubRuntime: null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function project(input: Partial<PersistedProjectRecord> & { projectId: string }) {
|
||||
return {
|
||||
rootPath: `/workspace/${input.projectId}`,
|
||||
kind: "non_git",
|
||||
displayName: input.projectId,
|
||||
customName: null,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
...input,
|
||||
} satisfies PersistedProjectRecord;
|
||||
}
|
||||
|
||||
test("surfaces a project with no active workspaces as an empty project", async () => {
|
||||
const directory = makeDirectory({
|
||||
projects: [project({ projectId: "empty", customName: "Renamed" })],
|
||||
workspaces: [],
|
||||
});
|
||||
|
||||
const result = await directory.listFetchEntries({
|
||||
type: "fetch_workspaces_request",
|
||||
requestId: "r1",
|
||||
});
|
||||
|
||||
expect(result.entries).toEqual([]);
|
||||
expect(result.emptyProjects).toEqual([
|
||||
{
|
||||
projectId: "empty",
|
||||
projectDisplayName: "Renamed",
|
||||
projectCustomName: "Renamed",
|
||||
projectRootPath: "/workspace/empty",
|
||||
projectKind: "non_git",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("excludes projects that still have an active workspace", async () => {
|
||||
const directory = makeDirectory({
|
||||
projects: [project({ projectId: "with-ws" }), project({ projectId: "empty" })],
|
||||
workspaces: [
|
||||
{
|
||||
workspaceId: "ws-1",
|
||||
projectId: "with-ws",
|
||||
cwd: "/workspace/with-ws",
|
||||
kind: "directory",
|
||||
displayName: "main",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await directory.listFetchEntries({
|
||||
type: "fetch_workspaces_request",
|
||||
requestId: "r1",
|
||||
});
|
||||
|
||||
expect(result.emptyProjects.map((p) => p.projectId)).toEqual(["empty"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
|
||||
import { SortablePager } from "./pagination/sortable-pager.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import { resolveActiveWorkspaceRecordForCwd } from "./workspace-registry-model.js";
|
||||
import { resolveProjectDisplayName } from "./workspace-registry.js";
|
||||
import {
|
||||
resolveActiveWorkspaceRecordForCwd,
|
||||
resolveWorkspaceIdForRecord,
|
||||
} from "./workspace-registry-model.js";
|
||||
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
||||
|
||||
type WorkspaceIdResolver = (cwd: string) => string | undefined;
|
||||
@@ -52,6 +56,7 @@ type FetchWorkspacesResponsePayload = Extract<
|
||||
>["payload"];
|
||||
type FetchWorkspacesResponseEntry = FetchWorkspacesResponsePayload["entries"][number];
|
||||
type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload["pageInfo"];
|
||||
type WorkspaceProjectDescriptor = FetchWorkspacesResponsePayload["emptyProjects"][number];
|
||||
|
||||
export type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter;
|
||||
|
||||
@@ -92,7 +97,7 @@ export interface WorkspaceDirectoryDeps {
|
||||
};
|
||||
listAgentPayloads(): Promise<AgentSnapshotPayload[]>;
|
||||
listTerminalActivityContributions(): Promise<
|
||||
Array<{ cwd: string; activity: TerminalActivity | null }>
|
||||
Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }>
|
||||
>;
|
||||
isProviderVisibleToClient(provider: string): boolean;
|
||||
buildWorkspaceDescriptor(input: {
|
||||
@@ -210,9 +215,7 @@ export class WorkspaceDirectory {
|
||||
);
|
||||
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
|
||||
const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
|
||||
const workspaceIdsByDirectory = new Map(
|
||||
activeRecords.map((workspace) => [resolve(workspace.cwd), workspace.workspaceId] as const),
|
||||
);
|
||||
const activeWorkspaceIds = new Set(activeRecords.map((workspace) => workspace.workspaceId));
|
||||
const resolveActiveWorkspaceIdForCwd: WorkspaceIdResolver = (cwd) =>
|
||||
resolveActiveWorkspaceRecordForCwd(cwd, activeRecords)?.workspaceId;
|
||||
|
||||
@@ -239,6 +242,62 @@ export class WorkspaceDirectory {
|
||||
const activeAgents = agents.filter(
|
||||
(agent) => !agent.archivedAt && this.deps.isProviderVisibleToClient(agent.provider),
|
||||
);
|
||||
this.applyAgentBucketContributions({
|
||||
activeAgents,
|
||||
activeRecords,
|
||||
activeWorkspaceIds,
|
||||
descriptorsByWorkspaceId,
|
||||
});
|
||||
|
||||
// Terminal activity contributions: working terminal → running bucket.
|
||||
const terminalEntriesByWorkspaceId = this.applyTerminalContributions(
|
||||
terminalContributions,
|
||||
activeRecords,
|
||||
resolveActiveWorkspaceIdForCwd,
|
||||
descriptorsByWorkspaceId,
|
||||
);
|
||||
|
||||
const contributingAgentsByWorkspaceId = groupAgentsByWorkspaceId(
|
||||
activeAgents,
|
||||
activeRecords,
|
||||
activeWorkspaceIds,
|
||||
);
|
||||
|
||||
// Resolve the workspace-level `statusEnteredAt` (see aggregate semantics
|
||||
// on `resolveStatusEnteredAt`).
|
||||
const nowIso = new Date().toISOString();
|
||||
for (const [workspaceId, descriptor] of descriptorsByWorkspaceId) {
|
||||
const contributingAgents = contributingAgentsByWorkspaceId.get(workspaceId) ?? [];
|
||||
const terminalEntries = terminalEntriesByWorkspaceId.get(workspaceId) ?? [];
|
||||
const result = this.resolveStatusEnteredAt({
|
||||
workspaceId,
|
||||
winningBucket: descriptor.status,
|
||||
contributingAgents,
|
||||
terminalEntries,
|
||||
previous: this.bucketHistoryByWorkspaceId.get(workspaceId) ?? null,
|
||||
nowIso,
|
||||
});
|
||||
descriptor.statusEnteredAt = result.statusEnteredAt;
|
||||
if (result.recordUpdate) {
|
||||
this.bucketHistoryByWorkspaceId.set(workspaceId, result.recordUpdate);
|
||||
} else if (result.recordDelete) {
|
||||
this.bucketHistoryByWorkspaceId.delete(workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
return descriptorsByWorkspaceId;
|
||||
}
|
||||
|
||||
// Aggregate each agent's state bucket into its owning workspace descriptor,
|
||||
// keeping the highest-priority bucket. Delegated agents contribute to their
|
||||
// delegation root's workspace; their own status is ignored unless running.
|
||||
private applyAgentBucketContributions(params: {
|
||||
activeAgents: AgentSnapshotPayload[];
|
||||
activeRecords: PersistedWorkspaceRecord[];
|
||||
activeWorkspaceIds: ReadonlySet<string>;
|
||||
descriptorsByWorkspaceId: Map<string, WorkspaceDescriptorPayload>;
|
||||
}): void {
|
||||
const { activeAgents, activeRecords, activeWorkspaceIds, descriptorsByWorkspaceId } = params;
|
||||
const activeAgentsById = new Map(activeAgents.map((agent) => [agent.id, agent] as const));
|
||||
|
||||
for (const agent of activeAgents) {
|
||||
@@ -263,8 +322,8 @@ export class WorkspaceDirectory {
|
||||
});
|
||||
}
|
||||
|
||||
const workspaceId = workspaceIdsByDirectory.get(resolve(workspaceAgent.cwd));
|
||||
if (workspaceId === undefined) {
|
||||
const workspaceId = resolveWorkspaceIdForRecord(workspaceAgent, activeRecords);
|
||||
if (workspaceId === null || !activeWorkspaceIds.has(workspaceId)) {
|
||||
continue;
|
||||
}
|
||||
const existing = descriptorsByWorkspaceId.get(workspaceId);
|
||||
@@ -278,48 +337,17 @@ export class WorkspaceDirectory {
|
||||
existing.status = bucket;
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal activity contributions: working terminal → running bucket.
|
||||
const terminalEntriesByWorkspaceId = this.applyTerminalContributions(
|
||||
terminalContributions,
|
||||
resolveActiveWorkspaceIdForCwd,
|
||||
descriptorsByWorkspaceId,
|
||||
);
|
||||
|
||||
// Resolve the workspace-level `statusEnteredAt` (see aggregate semantics
|
||||
// on `resolveStatusEnteredAt`).
|
||||
const nowIso = new Date().toISOString();
|
||||
for (const [workspaceId, descriptor] of descriptorsByWorkspaceId) {
|
||||
const contributingAgents = agents.filter(
|
||||
(agent) =>
|
||||
!agent.archivedAt &&
|
||||
this.deps.isProviderVisibleToClient(agent.provider) &&
|
||||
workspaceIdsByDirectory.get(resolve(agent.cwd)) === workspaceId,
|
||||
);
|
||||
const terminalEntries = terminalEntriesByWorkspaceId.get(workspaceId) ?? [];
|
||||
const result = this.resolveStatusEnteredAt({
|
||||
workspaceId,
|
||||
winningBucket: descriptor.status,
|
||||
contributingAgents,
|
||||
terminalEntries,
|
||||
previous: this.bucketHistoryByWorkspaceId.get(workspaceId) ?? null,
|
||||
nowIso,
|
||||
});
|
||||
descriptor.statusEnteredAt = result.statusEnteredAt;
|
||||
if (result.recordUpdate) {
|
||||
this.bucketHistoryByWorkspaceId.set(workspaceId, result.recordUpdate);
|
||||
} else if (result.recordDelete) {
|
||||
this.bucketHistoryByWorkspaceId.delete(workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
return descriptorsByWorkspaceId;
|
||||
}
|
||||
|
||||
// Apply working terminal contributions to descriptor statuses and build a map
|
||||
// of terminal timestamp entries per workspace for use in `resolveStatusEnteredAt`.
|
||||
private applyTerminalContributions(
|
||||
terminalContributions: Array<{ cwd: string; activity: TerminalActivity | null }>,
|
||||
terminalContributions: Array<{
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
activity: TerminalActivity | null;
|
||||
}>,
|
||||
activeRecords: PersistedWorkspaceRecord[],
|
||||
resolveWorkspaceIdForCwd: WorkspaceIdResolver,
|
||||
descriptorsByWorkspaceId: Map<string, WorkspaceDescriptorPayload>,
|
||||
): Map<string, Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }>> {
|
||||
@@ -327,7 +355,7 @@ export class WorkspaceDirectory {
|
||||
string,
|
||||
Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }>
|
||||
>();
|
||||
for (const { cwd, activity } of terminalContributions) {
|
||||
for (const { cwd, workspaceId: contributedWorkspaceId, activity } of terminalContributions) {
|
||||
if (!activity) {
|
||||
continue;
|
||||
}
|
||||
@@ -339,8 +367,10 @@ export class WorkspaceDirectory {
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
const workspaceId = resolveWorkspaceIdForCwd(cwd);
|
||||
if (workspaceId === undefined) {
|
||||
const workspaceId =
|
||||
resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords) ??
|
||||
resolveWorkspaceIdForCwd(cwd);
|
||||
if (workspaceId == null) {
|
||||
continue;
|
||||
}
|
||||
const existing = descriptorsByWorkspaceId.get(workspaceId);
|
||||
@@ -468,6 +498,32 @@ export class WorkspaceDirectory {
|
||||
return resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
|
||||
}
|
||||
|
||||
// Project parents that have no active workspaces. These persist as first-class
|
||||
// empty projects so the sidebar can render an empty project row with a
|
||||
// "+ New workspace" affordance.
|
||||
async listEmptyProjects(): Promise<WorkspaceProjectDescriptor[]> {
|
||||
const [persistedWorkspaces, persistedProjects] = await Promise.all([
|
||||
this.deps.workspaceRegistry.list(),
|
||||
this.deps.projectRegistry.list(),
|
||||
]);
|
||||
const projectIdsWithActiveWorkspaces = new Set(
|
||||
persistedWorkspaces
|
||||
.filter((workspace) => !workspace.archivedAt)
|
||||
.map((workspace) => workspace.projectId),
|
||||
);
|
||||
return persistedProjects
|
||||
.filter(
|
||||
(project) => !project.archivedAt && !projectIdsWithActiveWorkspaces.has(project.projectId),
|
||||
)
|
||||
.map((project) => ({
|
||||
projectId: project.projectId,
|
||||
projectDisplayName: resolveProjectDisplayName(project),
|
||||
projectCustomName: project.customName ?? null,
|
||||
projectRootPath: project.rootPath,
|
||||
projectKind: project.kind,
|
||||
}));
|
||||
}
|
||||
|
||||
async listDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
||||
return Array.from(
|
||||
(
|
||||
@@ -506,6 +562,7 @@ export class WorkspaceDirectory {
|
||||
|
||||
async listFetchEntries(request: FetchWorkspacesRequestMessage): Promise<{
|
||||
entries: FetchWorkspacesResponseEntry[];
|
||||
emptyProjects: WorkspaceProjectDescriptor[];
|
||||
pageInfo: FetchWorkspacesResponsePageInfo;
|
||||
}> {
|
||||
const filter = request.filter;
|
||||
@@ -532,6 +589,15 @@ export class WorkspaceDirectory {
|
||||
? this.pager.encode(pagedEntries[pagedEntries.length - 1], sort)
|
||||
: null;
|
||||
|
||||
// Empty project parents ride only on the first page so the sidebar can render
|
||||
// them without them being duplicated across pagination.
|
||||
const projectIdFilter = filter?.projectId?.trim();
|
||||
const emptyProjects = cursorToken
|
||||
? []
|
||||
: (await this.listEmptyProjects()).filter(
|
||||
(project) => !projectIdFilter || project.projectId === projectIdFilter,
|
||||
);
|
||||
|
||||
this.deps.logger.debug(
|
||||
{
|
||||
requestId: request.requestId,
|
||||
@@ -549,6 +615,7 @@ export class WorkspaceDirectory {
|
||||
|
||||
return {
|
||||
entries: pagedEntries,
|
||||
emptyProjects,
|
||||
pageInfo: {
|
||||
nextCursor,
|
||||
prevCursor: request.page?.cursor ?? null,
|
||||
@@ -558,6 +625,24 @@ export class WorkspaceDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
function groupAgentsByWorkspaceId(
|
||||
agents: AgentSnapshotPayload[],
|
||||
activeRecords: PersistedWorkspaceRecord[],
|
||||
activeWorkspaceIds: ReadonlySet<string>,
|
||||
): Map<string, AgentSnapshotPayload[]> {
|
||||
const byWorkspaceId = new Map<string, AgentSnapshotPayload[]>();
|
||||
for (const agent of agents) {
|
||||
const workspaceId = resolveWorkspaceIdForRecord(agent, activeRecords);
|
||||
if (workspaceId === null || !activeWorkspaceIds.has(workspaceId)) {
|
||||
continue;
|
||||
}
|
||||
const entries = byWorkspaceId.get(workspaceId) ?? [];
|
||||
entries.push(agent);
|
||||
byWorkspaceId.set(workspaceId, entries);
|
||||
}
|
||||
return byWorkspaceId;
|
||||
}
|
||||
|
||||
function resolveDelegationRootAgent(
|
||||
agent: AgentSnapshotPayload,
|
||||
activeAgentsById: ReadonlyMap<string, AgentSnapshotPayload>,
|
||||
|
||||
@@ -209,7 +209,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
expect(workspaces.get("w1")!.archivedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("archives orphaned projects after all workspaces are archived", async () => {
|
||||
test("keeps a project active after all its workspaces are archived", async () => {
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
@@ -245,8 +245,8 @@ describe("WorkspaceReconciliationService", () => {
|
||||
const result = await service.runOnce();
|
||||
|
||||
const projChange = result.changesApplied.find((c) => c.kind === "project_archived");
|
||||
expect(projChange).toBeDefined();
|
||||
expect(projects.get("p1")!.archivedAt).toBeTruthy();
|
||||
expect(projChange).toBeUndefined();
|
||||
expect(projects.get("p1")!.archivedAt).toBeFalsy();
|
||||
});
|
||||
|
||||
test("updates project kind when a directory becomes a git repo", async () => {
|
||||
@@ -456,7 +456,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
expect.objectContaining({
|
||||
kind: "project_archived",
|
||||
projectId: repoDir,
|
||||
reason: "no_active_workspaces",
|
||||
reason: "merged_duplicate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
@@ -753,7 +753,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
{
|
||||
message: "Workspace reconciliation applied changes",
|
||||
payload: expect.objectContaining({
|
||||
changeCount: 2,
|
||||
changeCount: 1,
|
||||
changes: expect.arrayContaining([
|
||||
{
|
||||
kind: "workspace_archived",
|
||||
@@ -761,17 +761,12 @@ describe("WorkspaceReconciliationService", () => {
|
||||
directory: "/tmp/does-not-exist-log-test",
|
||||
reason: "directory_missing",
|
||||
},
|
||||
{
|
||||
kind: "project_archived",
|
||||
projectId: "p1",
|
||||
directory: "/tmp/does-not-exist-log-test",
|
||||
reason: "no_active_workspaces",
|
||||
},
|
||||
]),
|
||||
durationMs: expect.any(Number),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(projects.get("p1")!.archivedAt).toBeFalsy();
|
||||
});
|
||||
|
||||
test("does not log reconciliation when no changes are applied", async () => {
|
||||
|
||||
@@ -159,29 +159,19 @@ export class WorkspaceReconciliationService {
|
||||
// 2. Merge duplicate active project records that point at the same repo root.
|
||||
await this.mergeDuplicateProjectsByRoot(activeProjects, workspacesByProject, changes);
|
||||
|
||||
// 3. Archive orphaned projects (all workspaces archived/removed)
|
||||
const orphanedProjects = activeProjects.filter((project) => {
|
||||
const siblings = workspacesByProject.get(project.projectId) ?? [];
|
||||
return siblings.length === 0;
|
||||
});
|
||||
await Promise.all(
|
||||
orphanedProjects.map(async (project) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.projectRegistry.archive(project.projectId, timestamp);
|
||||
changes.push({
|
||||
kind: "project_archived",
|
||||
projectId: project.projectId,
|
||||
directory: project.rootPath,
|
||||
reason: "no_active_workspaces",
|
||||
});
|
||||
}),
|
||||
// 3. Reconcile git metadata for active projects whose directories still exist.
|
||||
// A project with zero active workspaces is a first-class empty project — it
|
||||
// persists until explicitly removed and still reconciles its own metadata.
|
||||
// Skip projects archived earlier in this pass (e.g. merged duplicates) so we
|
||||
// don't resurrect them by upserting a stale, non-archived copy.
|
||||
const archivedProjectIds = new Set(
|
||||
changes
|
||||
.filter((change) => change.kind === "project_archived")
|
||||
.map((change) => change.projectId),
|
||||
);
|
||||
|
||||
// 4. Reconcile git metadata for active projects whose directories still exist
|
||||
const projectsToReconcile = activeProjects.filter((project) => {
|
||||
if (project.archivedAt) return false;
|
||||
const siblings = workspacesByProject.get(project.projectId) ?? [];
|
||||
if (siblings.length === 0) return false;
|
||||
if (archivedProjectIds.has(project.projectId)) return false;
|
||||
if (!existsSync(project.rootPath)) return false;
|
||||
return true;
|
||||
});
|
||||
@@ -263,6 +253,14 @@ export class WorkspaceReconciliationService {
|
||||
|
||||
for (const project of duplicateProjects) {
|
||||
workspacesByProject.set(project.projectId, []);
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.projectRegistry.archive(project.projectId, timestamp);
|
||||
changes.push({
|
||||
kind: "project_archived",
|
||||
projectId: project.projectId,
|
||||
directory: project.rootPath,
|
||||
reason: "merged_duplicate",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,18 +9,24 @@ import {
|
||||
deriveWorkspaceKind,
|
||||
detectStaleWorkspaces,
|
||||
generateWorkspaceId,
|
||||
resolveWorkspaceIdForRecord,
|
||||
} from "./workspace-registry-model.js";
|
||||
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
|
||||
function createWorkspaceRecord(cwd: string, workspaceId: string) {
|
||||
function createWorkspaceRecord(
|
||||
cwd: string,
|
||||
workspaceId: string,
|
||||
overrides?: { createdAt?: string; archivedAt?: string },
|
||||
) {
|
||||
return createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: workspaceId,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: basename(cwd) || cwd,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
createdAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z",
|
||||
archivedAt: overrides?.archivedAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,3 +232,65 @@ describe("git worktree grouping", () => {
|
||||
).toBe("worktree");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceIdForRecord", () => {
|
||||
test("resolves a stamped record to its workspaceId, ignoring cwd matches", () => {
|
||||
const workspaces = [
|
||||
createWorkspaceRecord("/tmp/repo", "ws-a"),
|
||||
createWorkspaceRecord("/tmp/repo", "ws-b"),
|
||||
];
|
||||
|
||||
const resolved = resolveWorkspaceIdForRecord(
|
||||
{ workspaceId: "ws-b", cwd: "/tmp/repo" },
|
||||
workspaces,
|
||||
);
|
||||
|
||||
expect(resolved).toBe("ws-b");
|
||||
});
|
||||
|
||||
test("falls back to the single cwd match for a legacy record without workspaceId", () => {
|
||||
const workspaces = [
|
||||
createWorkspaceRecord("/tmp/repo", "ws-only"),
|
||||
createWorkspaceRecord("/tmp/other", "ws-other"),
|
||||
];
|
||||
|
||||
const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces);
|
||||
|
||||
expect(resolved).toBe("ws-only");
|
||||
});
|
||||
|
||||
test("resolves a legacy record with multiple cwd matches to the deterministic oldest", () => {
|
||||
const workspaces = [
|
||||
createWorkspaceRecord("/tmp/repo", "ws-newer", { createdAt: "2026-03-02T00:00:00.000Z" }),
|
||||
createWorkspaceRecord("/tmp/repo", "ws-older", { createdAt: "2026-03-01T00:00:00.000Z" }),
|
||||
];
|
||||
|
||||
const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces);
|
||||
|
||||
expect(resolved).toBe("ws-older");
|
||||
});
|
||||
|
||||
test("returns null for a legacy record with no cwd match", () => {
|
||||
const workspaces = [createWorkspaceRecord("/tmp/other", "ws-other")];
|
||||
|
||||
const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces);
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
test("falls back to cwd when the stamped workspaceId points at an archived workspace", () => {
|
||||
const workspaces = [
|
||||
createWorkspaceRecord("/tmp/repo", "ws-archived", {
|
||||
archivedAt: "2026-03-05T00:00:00.000Z",
|
||||
}),
|
||||
createWorkspaceRecord("/tmp/repo", "ws-live"),
|
||||
];
|
||||
|
||||
const resolved = resolveWorkspaceIdForRecord(
|
||||
{ workspaceId: "ws-archived", cwd: "/tmp/repo" },
|
||||
workspaces,
|
||||
);
|
||||
|
||||
expect(resolved).toBe("ws-live");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,42 @@ export function generateWorkspaceId(): string {
|
||||
return `wks_${randomBytes(8).toString("hex")}`;
|
||||
}
|
||||
|
||||
// COMPAT(workspaceOwnership): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||
// Resolves the owning workspace for a record (agent/terminal) that may predate
|
||||
// workspaceId stamping. New records always carry workspaceId and hit the first
|
||||
// branch; legacy records fall back to cwd matching. When several active
|
||||
// workspaces share the same cwd, duplicate-cwd disambiguation is impossible from
|
||||
// cwd alone, so we pick the deterministic oldest one.
|
||||
export function resolveWorkspaceIdForRecord(
|
||||
record: { workspaceId?: string; cwd: string },
|
||||
activeWorkspaces: Iterable<PersistedWorkspaceRecord>,
|
||||
): string | null {
|
||||
const workspaces = Array.from(activeWorkspaces);
|
||||
if (record.workspaceId) {
|
||||
const exact = workspaces.find(
|
||||
(workspace) => !workspace.archivedAt && workspace.workspaceId === record.workspaceId,
|
||||
);
|
||||
if (exact) {
|
||||
return exact.workspaceId;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedCwd = resolve(record.cwd);
|
||||
const cwdMatches = workspaces.filter(
|
||||
(workspace) => !workspace.archivedAt && resolve(workspace.cwd) === resolvedCwd,
|
||||
);
|
||||
if (cwdMatches.length === 1) {
|
||||
return cwdMatches[0].workspaceId;
|
||||
}
|
||||
if (cwdMatches.length > 1) {
|
||||
return cwdMatches.reduce((oldest, candidate) =>
|
||||
candidate.createdAt < oldest.createdAt ? candidate : oldest,
|
||||
).workspaceId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveActiveWorkspaceRecordForCwd(
|
||||
cwd: string,
|
||||
workspaces: Iterable<PersistedWorkspaceRecord>,
|
||||
|
||||
@@ -10,8 +10,39 @@ import {
|
||||
createPersistedWorkspaceRecord,
|
||||
FileBackedProjectRegistry,
|
||||
FileBackedWorkspaceRegistry,
|
||||
resolveWorkspaceDisplayName,
|
||||
resolveWorkspaceName,
|
||||
} from "./workspace-registry.js";
|
||||
|
||||
describe("resolveWorkspaceName", () => {
|
||||
test("prefers the user-set title over the derived display name", () => {
|
||||
expect(
|
||||
resolveWorkspaceName({ title: "Payments work", derivedDisplayName: "feature/payments" }),
|
||||
).toBe("Payments work");
|
||||
});
|
||||
|
||||
test("falls back to the derived display name when there is no title", () => {
|
||||
expect(resolveWorkspaceName({ title: null, derivedDisplayName: "feature/payments" })).toBe(
|
||||
"feature/payments",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveWorkspaceDisplayName applies the same rule over the persisted record", () => {
|
||||
const record = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-1",
|
||||
projectId: "proj-1",
|
||||
cwd: "/tmp/repo",
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
title: "Renamed",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
expect(resolveWorkspaceDisplayName(record)).toBe("Renamed");
|
||||
expect(resolveWorkspaceDisplayName({ ...record, title: null })).toBe("main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace registries", () => {
|
||||
let tmpDir: string;
|
||||
let projectRegistry: FileBackedProjectRegistry;
|
||||
|
||||
@@ -29,6 +29,14 @@ const PersistedWorkspaceRecordSchema = z.object({
|
||||
cwd: z.string(),
|
||||
kind: z.enum(["local_checkout", "worktree", "directory"]),
|
||||
displayName: z.string(),
|
||||
// User-set title layered over the derived displayName. In Model B the title is
|
||||
// the workspace identity; branch/directory are backing metadata. Reconciliation
|
||||
// never touches this. Null means "use the derived displayName".
|
||||
title: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
archivedAt: z.string().nullable(),
|
||||
@@ -227,12 +235,28 @@ export function createPersistedWorkspaceRecord(input: {
|
||||
cwd: string;
|
||||
kind: PersistedWorkspaceKind;
|
||||
displayName: string;
|
||||
title?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string | null;
|
||||
}): PersistedWorkspaceRecord {
|
||||
return PersistedWorkspaceRecordSchema.parse({
|
||||
...input,
|
||||
title: input.title ?? null,
|
||||
archivedAt: input.archivedAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// The single workspace-name rule: the user-set title always wins; otherwise fall
|
||||
// back to the freshest available derived display name (a live branch snapshot when
|
||||
// the caller has one, the persisted displayName otherwise).
|
||||
export function resolveWorkspaceName(input: {
|
||||
title: string | null;
|
||||
derivedDisplayName: string;
|
||||
}): string {
|
||||
return input.title ?? input.derivedDisplayName;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceDisplayName(record: PersistedWorkspaceRecord): string {
|
||||
return resolveWorkspaceName({ title: record.title, derivedDisplayName: record.displayName });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { DaemonClient } from "./test-utils/index.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { getAskModeConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
|
||||
const WORKSPACE_A = "wks_same_cwd_a";
|
||||
const WORKSPACE_B = "wks_same_cwd_b";
|
||||
|
||||
// Seed two active workspaces that share one cwd, so we can prove Phase 1
|
||||
// attributes agents, terminals, and status by workspaceId rather than by
|
||||
// directory. Both registry files must exist on disk before the daemon starts:
|
||||
// bootstrapWorkspaceRegistries skips materialization when both files are
|
||||
// present, leaving these seeded records untouched.
|
||||
function seedSameCwdWorkspaces(): { paseoHomeRoot: string; cwd: string } {
|
||||
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-same-cwd-home-"));
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-same-cwd-dir-"));
|
||||
const projectsDir = path.join(paseoHomeRoot, ".paseo", "projects");
|
||||
mkdirSync(projectsDir, { recursive: true });
|
||||
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "prj_same_cwd",
|
||||
rootPath: cwd,
|
||||
kind: "non_git",
|
||||
displayName: path.basename(cwd),
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
const workspaceA = createPersistedWorkspaceRecord({
|
||||
workspaceId: WORKSPACE_A,
|
||||
projectId: project.projectId,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: "workspace-a",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
// Created later so the deterministic-oldest cwd fallback would never pick B:
|
||||
// any correct attribution to B must follow the stamped workspaceId.
|
||||
const workspaceB = createPersistedWorkspaceRecord({
|
||||
workspaceId: WORKSPACE_B,
|
||||
projectId: project.projectId,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: "workspace-b",
|
||||
createdAt: "2026-03-02T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify([project]));
|
||||
writeFileSync(
|
||||
path.join(projectsDir, "workspaces.json"),
|
||||
JSON.stringify([workspaceA, workspaceB]),
|
||||
);
|
||||
|
||||
return { paseoHomeRoot, cwd };
|
||||
}
|
||||
|
||||
async function statusByWorkspaceId(client: DaemonClient): Promise<Map<string, string>> {
|
||||
const workspaces = await client.fetchWorkspaces();
|
||||
return new Map(workspaces.entries.map((entry) => [entry.id, entry.status]));
|
||||
}
|
||||
|
||||
test("two workspaces sharing one cwd stay isolated by workspaceId", async () => {
|
||||
const { paseoHomeRoot, cwd } = seedSameCwdWorkspaces();
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot });
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.82",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
// Both seeded workspaces are visible and start with no contributing agents.
|
||||
expect(await statusByWorkspaceId(client)).toEqual(
|
||||
new Map([
|
||||
[WORKSPACE_A, "done"],
|
||||
[WORKSPACE_B, "done"],
|
||||
]),
|
||||
);
|
||||
|
||||
// 1. Agent created in workspace A carries workspaceId A and only moves A's
|
||||
// status. Ask mode + a write parks the agent on a pending permission,
|
||||
// which is the deterministic "needs_input" signal for A.
|
||||
const agentA = await client.createAgent({
|
||||
...getAskModeConfig("codex"),
|
||||
cwd,
|
||||
workspaceId: WORKSPACE_A,
|
||||
title: "Workspace A agent",
|
||||
});
|
||||
expect(agentA.workspaceId).toBe(WORKSPACE_A);
|
||||
|
||||
await client.sendMessage(
|
||||
agentA.id,
|
||||
'Use your shell tool to run: `printf "ok" > a.txt`. Request permission and wait.',
|
||||
);
|
||||
const parkedA = await client.waitForFinish(agentA.id, 60000);
|
||||
expect(parkedA.status).toBe("permission");
|
||||
|
||||
const fetchedA = await client.fetchAgent(agentA.id);
|
||||
expect(fetchedA?.agent.workspaceId).toBe(WORKSPACE_A);
|
||||
|
||||
expect(await statusByWorkspaceId(client)).toEqual(
|
||||
new Map([
|
||||
[WORKSPACE_A, "needs_input"],
|
||||
[WORKSPACE_B, "done"],
|
||||
]),
|
||||
);
|
||||
|
||||
// 2. Terminal created in A is delivered to A's directory subscription and to
|
||||
// A's list, but never to B's — exercises the workspaceId terminal filter.
|
||||
const terminalSnapshotsByWorkspace = new Map<string | undefined, Set<string>>();
|
||||
const unsubscribeTerminals = client.on("terminals_changed", (message) => {
|
||||
if (message.type !== "terminals_changed") {
|
||||
return;
|
||||
}
|
||||
for (const terminal of message.payload.terminals) {
|
||||
const seen = terminalSnapshotsByWorkspace.get(terminal.workspaceId) ?? new Set<string>();
|
||||
seen.add(terminal.id);
|
||||
terminalSnapshotsByWorkspace.set(terminal.workspaceId, seen);
|
||||
}
|
||||
});
|
||||
|
||||
client.subscribeTerminals({ cwd, workspaceId: WORKSPACE_A });
|
||||
client.subscribeTerminals({ cwd, workspaceId: WORKSPACE_B });
|
||||
|
||||
const createdTerminal = await client.createTerminal(cwd, "A terminal", undefined, {
|
||||
workspaceId: WORKSPACE_A,
|
||||
});
|
||||
expect(createdTerminal.terminal?.workspaceId).toBe(WORKSPACE_A);
|
||||
const terminalId = createdTerminal.terminal?.id;
|
||||
if (!terminalId) {
|
||||
throw new Error("Expected a created terminal id");
|
||||
}
|
||||
|
||||
// A's directory subscription receives a snapshot attributing the terminal
|
||||
// to A. Poll the observed snapshot state instead of sleeping for a fixed
|
||||
// window, so the assertion never races the daemon's snapshot push.
|
||||
await expect
|
||||
.poll(() => terminalSnapshotsByWorkspace.get(WORKSPACE_A)?.has(terminalId) ?? false)
|
||||
.toBe(true);
|
||||
unsubscribeTerminals();
|
||||
|
||||
// No snapshot ever attributed the terminal to B (the only other same-cwd
|
||||
// workspace).
|
||||
expect(terminalSnapshotsByWorkspace.get(WORKSPACE_B)?.has(terminalId) ?? false).toBe(false);
|
||||
|
||||
const listForA = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_A });
|
||||
expect(listForA.terminals.some((terminal) => terminal.id === terminalId)).toBe(true);
|
||||
|
||||
const listForB = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_B });
|
||||
expect(listForB.terminals.some((terminal) => terminal.id === terminalId)).toBe(false);
|
||||
|
||||
// 3. Agent created in workspace B carries workspaceId B and moves only B's
|
||||
// status. A keeps its own pending-permission state independently.
|
||||
const agentB = await client.createAgent({
|
||||
...getAskModeConfig("codex"),
|
||||
cwd,
|
||||
workspaceId: WORKSPACE_B,
|
||||
title: "Workspace B agent",
|
||||
});
|
||||
expect(agentB.workspaceId).toBe(WORKSPACE_B);
|
||||
|
||||
await client.sendMessage(
|
||||
agentB.id,
|
||||
'Use your shell tool to run: `printf "ok" > b.txt`. Request permission and wait.',
|
||||
);
|
||||
const parkedB = await client.waitForFinish(agentB.id, 60000);
|
||||
expect(parkedB.status).toBe("permission");
|
||||
|
||||
const fetchedB = await client.fetchAgent(agentB.id);
|
||||
expect(fetchedB?.agent.workspaceId).toBe(WORKSPACE_B);
|
||||
|
||||
expect(await statusByWorkspaceId(client)).toEqual(
|
||||
new Map([
|
||||
[WORKSPACE_A, "needs_input"],
|
||||
[WORKSPACE_B, "needs_input"],
|
||||
]),
|
||||
);
|
||||
|
||||
await client.killTerminal(terminalId);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 180000);
|
||||
@@ -157,6 +157,7 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-test",
|
||||
workspaceId: "ws-agent-test",
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
@@ -271,6 +272,7 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-carriage-return",
|
||||
workspaceId: "ws-carriage-return",
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
@@ -331,9 +333,11 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
|
||||
const registeredEnvs: Array<{ cwd: string; env: Record<string, string> }> = [];
|
||||
const createTerminalEnvs: Record<string, string>[] = [];
|
||||
const createTerminalWorkspaceIds: Array<string | undefined> = [];
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-shared-runtime-port",
|
||||
workspaceId: "ws-shared-runtime-port",
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: {
|
||||
@@ -342,6 +346,7 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
},
|
||||
async createTerminal(options) {
|
||||
createTerminalEnvs.push(options.env ?? {});
|
||||
createTerminalWorkspaceIds.push(options.workspaceId);
|
||||
return {
|
||||
id: "term-1",
|
||||
name: options.name ?? "Terminal",
|
||||
@@ -398,6 +403,7 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
|
||||
expect(createTerminalEnvs.length).toBeGreaterThan(0);
|
||||
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
|
||||
expect(createTerminalWorkspaceIds).toEqual(["ws-shared-runtime-port"]);
|
||||
|
||||
const terminalToolCall = persisted.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
|
||||
@@ -119,6 +119,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
await expect(
|
||||
runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-live-failure",
|
||||
workspaceId: "ws-live-failure",
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
@@ -169,6 +170,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-large-output",
|
||||
workspaceId: "ws-large-output",
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
@@ -238,6 +240,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-terminal-readiness",
|
||||
workspaceId: "ws-terminal-readiness",
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: {
|
||||
|
||||
@@ -40,6 +40,9 @@ export interface WorktreeBootstrapTerminalResult {
|
||||
|
||||
export interface RunAsyncWorktreeBootstrapOptions {
|
||||
agentId: string;
|
||||
// Workspace the bootstrapped terminals belong to. Stamping it lets
|
||||
// workspaceId-scoped archive tear these terminals down.
|
||||
workspaceId: string;
|
||||
worktree: WorktreeConfig;
|
||||
shouldBootstrap?: boolean;
|
||||
terminalManager: TerminalManager | null;
|
||||
@@ -540,6 +543,7 @@ async function runWorktreeTerminalBootstrap(
|
||||
cwd: options.worktree.worktreePath,
|
||||
name: spec.name,
|
||||
env: runtimeEnv,
|
||||
workspaceId: options.workspaceId,
|
||||
});
|
||||
await waitForTerminalBootstrapReadiness(terminal);
|
||||
terminal.send({
|
||||
|
||||
@@ -14,7 +14,10 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import pino, { type Logger } from "pino";
|
||||
|
||||
import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messages.js";
|
||||
import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js";
|
||||
import {
|
||||
archivePaseoWorktree,
|
||||
killTerminalsForWorkspace as killWorkspaceTerminals,
|
||||
} from "./paseo-worktree-archive-service.js";
|
||||
import {
|
||||
buildAgentSessionConfig,
|
||||
createPaseoWorktreeWorkflow,
|
||||
@@ -298,12 +301,17 @@ function createPaseoWorktreeForTest(options: {
|
||||
};
|
||||
}
|
||||
|
||||
function createManagedAgentForArchive(input: { id: string; cwd: string }): ManagedAgent {
|
||||
function createManagedAgentForArchive(input: {
|
||||
id: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
}): ManagedAgent {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: input.id,
|
||||
provider: "codex",
|
||||
cwd: input.cwd,
|
||||
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
@@ -489,6 +497,7 @@ function createAgentStorageStub(): Pick<AgentStorage, "list"> {
|
||||
function createWorkspaceArchivingDeps() {
|
||||
return {
|
||||
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-test"),
|
||||
listActiveWorkspaces: vi.fn(async () => []),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}),
|
||||
markWorkspaceArchiving: vi.fn(),
|
||||
clearWorkspaceArchiving: vi.fn(),
|
||||
@@ -1708,18 +1717,59 @@ describe("archivePaseoWorktree", () => {
|
||||
}
|
||||
});
|
||||
|
||||
function createIsPathWithinRoot() {
|
||||
return (rootPath: string, candidatePath: string) => {
|
||||
const normalizedRoot = path.resolve(rootPath);
|
||||
const normalizedCandidate = path.resolve(candidatePath);
|
||||
return (
|
||||
normalizedCandidate === normalizedRoot ||
|
||||
normalizedCandidate.startsWith(normalizedRoot + path.sep)
|
||||
);
|
||||
function createWorkspaceRecord(input: {
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
repoDir: string;
|
||||
displayName: string;
|
||||
}): PersistedWorkspaceRecord {
|
||||
return {
|
||||
workspaceId: input.workspaceId,
|
||||
projectId: input.repoDir,
|
||||
cwd: input.cwd,
|
||||
kind: "worktree",
|
||||
displayName: input.displayName,
|
||||
createdAt: "2026-04-30T00:00:00.000Z",
|
||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
test("runs agent close and terminal teardown concurrently and removes the worktree", async () => {
|
||||
function createTerminalSessionStub(input: {
|
||||
id: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
}): TerminalSession {
|
||||
return {
|
||||
id: input.id,
|
||||
cwd: input.cwd,
|
||||
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
||||
} as unknown as TerminalSession;
|
||||
}
|
||||
|
||||
// Minimal terminal manager that only supports the queries archive performs:
|
||||
// enumerate directories and fetch terminals filtered by owning workspaceId.
|
||||
function createArchiveTerminalManagerStub(
|
||||
sessions: TerminalSession[],
|
||||
killed: string[],
|
||||
): TerminalManager {
|
||||
return {
|
||||
listDirectories: () => Array.from(new Set(sessions.map((session) => session.cwd))),
|
||||
getTerminals: async (cwd: string, options?: { workspaceId?: string }) =>
|
||||
sessions.filter(
|
||||
(session) =>
|
||||
session.cwd === cwd &&
|
||||
(options?.workspaceId === undefined ||
|
||||
session.workspaceId === undefined ||
|
||||
session.workspaceId === options.workspaceId),
|
||||
),
|
||||
killTerminalAndWait: async (terminalId: string) => {
|
||||
killed.push(terminalId);
|
||||
},
|
||||
} as unknown as TerminalManager;
|
||||
}
|
||||
|
||||
test("runs agent close and terminal teardown concurrently, scoped to the workspace", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
@@ -1732,6 +1782,7 @@ describe("archivePaseoWorktree", () => {
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const workspaceId = "ws-archive-parallel";
|
||||
const teardownStartTimes: Record<string, number> = {};
|
||||
const teardownEndTimes: Record<string, number> = {};
|
||||
const archiveAgentSpy = vi.fn(async (agentId: string) => {
|
||||
@@ -1743,7 +1794,7 @@ describe("archivePaseoWorktree", () => {
|
||||
const archiveSnapshotSpy = vi.fn(async () => {
|
||||
throw new Error("not expected for live agents");
|
||||
});
|
||||
const killTerminalsUnderPath = vi.fn(async () => {
|
||||
const killTerminalsForWorkspace = vi.fn(async () => {
|
||||
teardownStartTimes.__terminals = Date.now();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
teardownEndTimes.__terminals = Date.now();
|
||||
@@ -1755,8 +1806,8 @@ describe("archivePaseoWorktree", () => {
|
||||
github: createGitHubServiceStub(),
|
||||
agentManager: {
|
||||
listAgents: () => [
|
||||
createManagedAgentForArchive({ id: "agent-1", cwd: created.worktreePath }),
|
||||
createManagedAgentForArchive({ id: "agent-2", cwd: created.worktreePath }),
|
||||
createManagedAgentForArchive({ id: "agent-1", cwd: created.worktreePath, workspaceId }),
|
||||
createManagedAgentForArchive({ id: "agent-2", cwd: created.worktreePath, workspaceId }),
|
||||
],
|
||||
archiveAgent: archiveAgentSpy,
|
||||
archiveSnapshot: archiveSnapshotSpy,
|
||||
@@ -1764,8 +1815,10 @@ describe("archivePaseoWorktree", () => {
|
||||
agentStorage: createAgentStorageStub(),
|
||||
archiveWorkspaceRecord: vi.fn(async () => {}),
|
||||
...createWorkspaceArchivingDeps(),
|
||||
isPathWithinRoot: createIsPathWithinRoot(),
|
||||
killTerminalsUnderPath,
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === created.worktreePath ? workspaceId : null,
|
||||
),
|
||||
killTerminalsForWorkspace,
|
||||
sessionLogger: createLogger(),
|
||||
},
|
||||
{
|
||||
@@ -1776,10 +1829,12 @@ describe("archivePaseoWorktree", () => {
|
||||
);
|
||||
|
||||
expect(archivedAgents).toEqual(expect.arrayContaining(["agent-1", "agent-2"]));
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
expect(archiveAgentSpy).toHaveBeenCalledTimes(2);
|
||||
expect(archiveSnapshotSpy).not.toHaveBeenCalled();
|
||||
expect(killTerminalsUnderPath).toHaveBeenCalledWith(created.worktreePath);
|
||||
expect(killTerminalsForWorkspace).toHaveBeenCalledWith(workspaceId);
|
||||
|
||||
// Archiving must never delete the worktree from disk.
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
|
||||
// All teardown work must overlap — sequential would take ~300ms, parallel ~100ms.
|
||||
const starts = Object.values(teardownStartTimes);
|
||||
@@ -1789,6 +1844,112 @@ describe("archivePaseoWorktree", () => {
|
||||
expect(maxEnd - minStart).toBeLessThan(220);
|
||||
});
|
||||
|
||||
test("tears down only the target workspace; a sibling sharing the cwd survives", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const created = await createLegacyWorktreeForTest({
|
||||
branchName: "archive-siblings",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "archive-siblings",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const sharedCwd = created.worktreePath;
|
||||
const workspaceA = "ws-A";
|
||||
const workspaceB = "ws-B";
|
||||
|
||||
const liveAgents = [
|
||||
createManagedAgentForArchive({ id: "agent-A", cwd: sharedCwd, workspaceId: workspaceA }),
|
||||
createManagedAgentForArchive({ id: "agent-B", cwd: sharedCwd, workspaceId: workspaceB }),
|
||||
];
|
||||
const storedRecords: StoredAgentRecord[] = [
|
||||
{
|
||||
id: "snapshot-A",
|
||||
provider: "codex",
|
||||
cwd: sharedCwd,
|
||||
workspaceId: workspaceA,
|
||||
createdAt: "2026-04-30T00:00:00.000Z",
|
||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||
labels: {},
|
||||
lastStatus: "closed",
|
||||
config: null,
|
||||
} as unknown as StoredAgentRecord,
|
||||
{
|
||||
id: "snapshot-B",
|
||||
provider: "codex",
|
||||
cwd: sharedCwd,
|
||||
workspaceId: workspaceB,
|
||||
createdAt: "2026-04-30T00:00:00.000Z",
|
||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||
labels: {},
|
||||
lastStatus: "closed",
|
||||
config: null,
|
||||
} as unknown as StoredAgentRecord,
|
||||
];
|
||||
|
||||
const killedTerminals: string[] = [];
|
||||
const terminalManager = createArchiveTerminalManagerStub(
|
||||
[
|
||||
createTerminalSessionStub({ id: "term-A", cwd: sharedCwd, workspaceId: workspaceA }),
|
||||
createTerminalSessionStub({ id: "term-B", cwd: sharedCwd, workspaceId: workspaceB }),
|
||||
],
|
||||
killedTerminals,
|
||||
);
|
||||
|
||||
const archivedAgentIds: string[] = [];
|
||||
const archivedSnapshotIds: string[] = [];
|
||||
const archivedWorkspaceRecords: string[] = [];
|
||||
|
||||
const archivedAgents = await archivePaseoWorktree(
|
||||
{
|
||||
paseoHome,
|
||||
github: createGitHubServiceStub(),
|
||||
agentManager: {
|
||||
listAgents: () => liveAgents,
|
||||
archiveAgent: async (agentId: string) => {
|
||||
archivedAgentIds.push(agentId);
|
||||
return { archivedAt: new Date().toISOString() };
|
||||
},
|
||||
archiveSnapshot: async (agentId: string) => {
|
||||
archivedSnapshotIds.push(agentId);
|
||||
return storedRecords.find((record) => record.id === agentId)!;
|
||||
},
|
||||
},
|
||||
agentStorage: { list: async () => storedRecords },
|
||||
archiveWorkspaceRecord: async (id: string) => {
|
||||
archivedWorkspaceRecords.push(id);
|
||||
},
|
||||
...createWorkspaceArchivingDeps(),
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === sharedCwd ? workspaceA : null,
|
||||
),
|
||||
killTerminalsForWorkspace: (workspaceId) =>
|
||||
killWorkspaceTerminals({ terminalManager, sessionLogger: createLogger() }, workspaceId),
|
||||
sessionLogger: createLogger(),
|
||||
},
|
||||
{
|
||||
targetPath: sharedCwd,
|
||||
repoRoot: repoDir,
|
||||
requestId: "req-archive-siblings",
|
||||
},
|
||||
);
|
||||
|
||||
// Only workspace A's agents, snapshots, terminals, and record are torn down.
|
||||
expect(archivedAgents).toEqual(["agent-A", "snapshot-A"]);
|
||||
expect(archivedAgentIds).toEqual(["agent-A"]);
|
||||
expect(archivedSnapshotIds).toEqual(["snapshot-A"]);
|
||||
expect(archivedWorkspaceRecords).toEqual([workspaceA]);
|
||||
expect(killedTerminals).toEqual(["term-A"]);
|
||||
|
||||
// Sibling workspace B is untouched, and the shared directory survives.
|
||||
expect(archivedAgentIds).not.toContain("agent-B");
|
||||
expect(killedTerminals).not.toContain("term-B");
|
||||
expect(existsSync(sharedCwd)).toBe(true);
|
||||
});
|
||||
|
||||
test("emits archiving upserts during worktree archive request until final remove", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
@@ -1807,17 +1968,14 @@ describe("archivePaseoWorktree", () => {
|
||||
const liveAgent = createManagedAgentForArchive({
|
||||
id: "agent-1",
|
||||
cwd: created.worktreePath,
|
||||
});
|
||||
const workspaceRecord: PersistedWorkspaceRecord = {
|
||||
workspaceId,
|
||||
projectId: repoDir,
|
||||
});
|
||||
const workspaceRecord = createWorkspaceRecord({
|
||||
workspaceId,
|
||||
cwd: created.worktreePath,
|
||||
kind: "worktree",
|
||||
repoDir,
|
||||
displayName: "archive-marked-during-close",
|
||||
createdAt: "2026-04-30T00:00:00.000Z",
|
||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
};
|
||||
});
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const events: string[] = [];
|
||||
const archivedWorkspaceIds = new Set<string>();
|
||||
@@ -1874,6 +2032,7 @@ describe("archivePaseoWorktree", () => {
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === created.worktreePath ? workspaceId : null,
|
||||
),
|
||||
listActiveWorkspaces: vi.fn(async () => []),
|
||||
archiveWorkspaceRecord: vi.fn(async (archivedWorkspaceId: string) => {
|
||||
archivedWorkspaceIds.add(archivedWorkspaceId);
|
||||
}),
|
||||
@@ -1891,8 +2050,7 @@ describe("archivePaseoWorktree", () => {
|
||||
archivingByWorkspaceId.delete(clearedWorkspaceId);
|
||||
}
|
||||
},
|
||||
isPathWithinRoot: createIsPathWithinRoot(),
|
||||
killTerminalsUnderPath: vi.fn(async () => {}),
|
||||
killTerminalsForWorkspace: vi.fn(async () => {}),
|
||||
sessionLogger: createLogger(),
|
||||
},
|
||||
{
|
||||
@@ -1945,141 +2103,27 @@ describe("archivePaseoWorktree", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("archives the workspace record even when the teardown script fails", async () => {
|
||||
const teardownLogPath = isPlatform("win32")
|
||||
? 'Set-Content -Path (Join-Path $env:PASEO_SOURCE_CHECKOUT_PATH "teardown-start.log") -Value "started"'
|
||||
: 'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-start.log"';
|
||||
const failingTeardownCommand = isPlatform("win32")
|
||||
? 'Write-Error "boom"; exit 9'
|
||||
: "echo boom 1>&2; exit 9";
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
teardown: [teardownLogPath, failingTeardownCommand],
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const created = await createLegacyWorktreeForTest({
|
||||
branchName: "archive-delete-fails",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "archive-delete-fails",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const workspaceId = "ws-archive-delete-fails";
|
||||
const archivingByWorkspaceId = new Map<string, string>();
|
||||
const archivedWorkspaceIds = new Set<string>();
|
||||
const emittedUpdates: Array<
|
||||
| {
|
||||
kind: "upsert";
|
||||
workspaceId: string;
|
||||
archivingAt: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "remove";
|
||||
workspaceId: string;
|
||||
}
|
||||
> = [];
|
||||
const archiveWorkspaceRecord = vi.fn(async (archivedWorkspaceId: string) => {
|
||||
archivedWorkspaceIds.add(archivedWorkspaceId);
|
||||
});
|
||||
|
||||
await expect(
|
||||
archivePaseoWorktree(
|
||||
{
|
||||
paseoHome,
|
||||
github: createGitHubServiceStub(),
|
||||
workspaceGitService: { getSnapshot: vi.fn(async () => null) },
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })),
|
||||
archiveSnapshot: vi.fn(async () => {
|
||||
throw new Error("not expected for empty agent list");
|
||||
}),
|
||||
},
|
||||
agentStorage: createAgentStorageStub(),
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === created.worktreePath ? workspaceId : null,
|
||||
),
|
||||
archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async (workspaceIds: Iterable<string>) => {
|
||||
for (const emittedWorkspaceId of workspaceIds) {
|
||||
if (archivedWorkspaceIds.has(emittedWorkspaceId)) {
|
||||
emittedUpdates.push({
|
||||
kind: "remove",
|
||||
workspaceId: emittedWorkspaceId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
emittedUpdates.push({
|
||||
kind: "upsert",
|
||||
workspaceId: emittedWorkspaceId,
|
||||
archivingAt: archivingByWorkspaceId.get(emittedWorkspaceId) ?? null,
|
||||
});
|
||||
}
|
||||
}),
|
||||
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => {
|
||||
for (const markedWorkspaceId of workspaceIds) {
|
||||
archivingByWorkspaceId.set(markedWorkspaceId, archivingAt);
|
||||
}
|
||||
},
|
||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => {
|
||||
for (const clearedWorkspaceId of workspaceIds) {
|
||||
archivingByWorkspaceId.delete(clearedWorkspaceId);
|
||||
}
|
||||
},
|
||||
isPathWithinRoot: createIsPathWithinRoot(),
|
||||
killTerminalsUnderPath: vi.fn(async () => {}),
|
||||
sessionLogger: createLogger(),
|
||||
},
|
||||
{
|
||||
targetPath: created.worktreePath,
|
||||
repoRoot: repoDir,
|
||||
requestId: "req-archive-delete-fails",
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("Worktree teardown command failed");
|
||||
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
expect(existsSync(path.join(repoDir, "teardown-start.log"))).toBe(true);
|
||||
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId);
|
||||
expect(emittedUpdates[0]).toEqual({
|
||||
kind: "upsert",
|
||||
workspaceId,
|
||||
archivingAt: expect.any(String),
|
||||
});
|
||||
expect(emittedUpdates.at(-1)).toEqual({
|
||||
kind: "remove",
|
||||
workspaceId,
|
||||
});
|
||||
});
|
||||
|
||||
test("proceeds to FS delete even when terminal teardown rejects", async () => {
|
||||
test("archives the workspace record but leaves the worktree on disk", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const created = await createLegacyWorktreeForTest({
|
||||
branchName: "archive-terminal-throws",
|
||||
branchName: "archive-keeps-disk",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "archive-terminal-throws",
|
||||
worktreeSlug: "archive-keeps-disk",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const killTerminalsUnderPath = vi.fn(async () => {
|
||||
throw new Error("simulated terminal teardown failure");
|
||||
});
|
||||
const workspaceId = "ws-archive-keeps-disk";
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await archivePaseoWorktree(
|
||||
{
|
||||
paseoHome,
|
||||
github: createGitHubServiceStub(),
|
||||
workspaceGitService: { getSnapshot: vi.fn(async () => null) },
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })),
|
||||
@@ -2088,24 +2132,26 @@ describe("archivePaseoWorktree", () => {
|
||||
}),
|
||||
},
|
||||
agentStorage: createAgentStorageStub(),
|
||||
archiveWorkspaceRecord: vi.fn(async () => {}),
|
||||
archiveWorkspaceRecord,
|
||||
...createWorkspaceArchivingDeps(),
|
||||
isPathWithinRoot: createIsPathWithinRoot(),
|
||||
killTerminalsUnderPath,
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === created.worktreePath ? workspaceId : null,
|
||||
),
|
||||
killTerminalsForWorkspace: vi.fn(async () => {}),
|
||||
sessionLogger: createLogger(),
|
||||
},
|
||||
{
|
||||
targetPath: created.worktreePath,
|
||||
repoRoot: repoDir,
|
||||
requestId: "req-archive-terminal-throws",
|
||||
requestId: "req-archive-keeps-disk",
|
||||
},
|
||||
);
|
||||
|
||||
expect(killTerminalsUnderPath).toHaveBeenCalledTimes(1);
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId);
|
||||
});
|
||||
|
||||
test("forces a workspace git snapshot refresh after archive deletes a worktree", async () => {
|
||||
test("forces a workspace git snapshot refresh after archive", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
@@ -2137,8 +2183,10 @@ describe("archivePaseoWorktree", () => {
|
||||
agentStorage: createAgentStorageStub(),
|
||||
archiveWorkspaceRecord: vi.fn(async () => {}),
|
||||
...createWorkspaceArchivingDeps(),
|
||||
isPathWithinRoot: createIsPathWithinRoot(),
|
||||
killTerminalsUnderPath: vi.fn(async () => {}),
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === created.worktreePath ? "ws-archive-refresh" : null,
|
||||
),
|
||||
killTerminalsForWorkspace: vi.fn(async () => {}),
|
||||
sessionLogger: createLogger(),
|
||||
},
|
||||
{
|
||||
@@ -2154,32 +2202,26 @@ describe("archivePaseoWorktree", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("succeeds when git has forgotten about the worktree (no repoRoot)", async () => {
|
||||
test("deletes the worktree from disk when asked and it is the last reference", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const created = await createLegacyWorktreeForTest({
|
||||
branchName: "archive-orphan",
|
||||
branchName: "archive-delete-last-ref",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "archive-orphan",
|
||||
worktreeSlug: "archive-delete-last-ref",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const workspaceId = "ws-delete-last-ref";
|
||||
|
||||
// Simulate a prior failed archive that stripped git's admin dir.
|
||||
rmSync(path.join(repoDir, ".git", "worktrees", "archive-orphan"), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
await handlePaseoWorktreeArchiveRequest(
|
||||
await archivePaseoWorktree(
|
||||
{
|
||||
paseoHome,
|
||||
github: createGitHubServiceStub(),
|
||||
workspaceGitService: { getSnapshot: vi.fn(async () => null) },
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })),
|
||||
@@ -2189,27 +2231,127 @@ describe("archivePaseoWorktree", () => {
|
||||
},
|
||||
agentStorage: createAgentStorageStub(),
|
||||
archiveWorkspaceRecord: vi.fn(async () => {}),
|
||||
emit: (msg) => emitted.push(msg),
|
||||
...createWorkspaceArchivingDeps(),
|
||||
isPathWithinRoot: createIsPathWithinRoot(),
|
||||
killTerminalsUnderPath: vi.fn(async () => {}),
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === created.worktreePath ? workspaceId : null,
|
||||
),
|
||||
// No other active workspace references the worktree dir.
|
||||
listActiveWorkspaces: vi.fn(async () => []),
|
||||
killTerminalsForWorkspace: vi.fn(async () => {}),
|
||||
sessionLogger: createLogger(),
|
||||
},
|
||||
{
|
||||
type: "paseo_worktree_archive_request",
|
||||
requestId: "req-archive-orphan",
|
||||
worktreePath: created.worktreePath,
|
||||
targetPath: created.worktreePath,
|
||||
repoRoot: repoDir,
|
||||
worktreesBaseRoot: path.join(paseoHome, "worktrees"),
|
||||
deleteWorktreeFromDisk: true,
|
||||
requestId: "req-delete-last-ref",
|
||||
},
|
||||
);
|
||||
|
||||
const response = emitted.find(
|
||||
(
|
||||
message,
|
||||
): message is Extract<SessionOutboundMessage, { type: "paseo_worktree_archive_response" }> =>
|
||||
message.type === "paseo_worktree_archive_response",
|
||||
);
|
||||
expect(response?.payload.success).toBe(true);
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps the worktree on disk when a sibling workspace still references it", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const created = await createLegacyWorktreeForTest({
|
||||
branchName: "archive-delete-sibling",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "archive-delete-sibling",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const sharedCwd = created.worktreePath;
|
||||
const workspaceA = "ws-delete-A";
|
||||
const workspaceB = "ws-delete-B";
|
||||
|
||||
await archivePaseoWorktree(
|
||||
{
|
||||
paseoHome,
|
||||
github: createGitHubServiceStub(),
|
||||
workspaceGitService: { getSnapshot: vi.fn(async () => null) },
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })),
|
||||
archiveSnapshot: vi.fn(async () => {
|
||||
throw new Error("not expected for empty agent list");
|
||||
}),
|
||||
},
|
||||
agentStorage: createAgentStorageStub(),
|
||||
archiveWorkspaceRecord: vi.fn(async () => {}),
|
||||
...createWorkspaceArchivingDeps(),
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === sharedCwd ? workspaceA : null,
|
||||
),
|
||||
// Sibling workspace B still references the same worktree directory.
|
||||
listActiveWorkspaces: vi.fn(async () => [{ workspaceId: workspaceB, cwd: sharedCwd }]),
|
||||
killTerminalsForWorkspace: vi.fn(async () => {}),
|
||||
sessionLogger: createLogger(),
|
||||
},
|
||||
{
|
||||
targetPath: sharedCwd,
|
||||
repoRoot: repoDir,
|
||||
worktreesBaseRoot: path.join(paseoHome, "worktrees"),
|
||||
deleteWorktreeFromDisk: true,
|
||||
requestId: "req-delete-sibling",
|
||||
},
|
||||
);
|
||||
|
||||
expect(existsSync(sharedCwd)).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps the worktree on disk when deleteWorktreeFromDisk is not requested", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const created = await createLegacyWorktreeForTest({
|
||||
branchName: "archive-no-delete-flag",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "archive-no-delete-flag",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const workspaceId = "ws-no-delete-flag";
|
||||
const listActiveWorkspaces = vi.fn(async () => []);
|
||||
|
||||
await archivePaseoWorktree(
|
||||
{
|
||||
paseoHome,
|
||||
github: createGitHubServiceStub(),
|
||||
workspaceGitService: { getSnapshot: vi.fn(async () => null) },
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })),
|
||||
archiveSnapshot: vi.fn(async () => {
|
||||
throw new Error("not expected for empty agent list");
|
||||
}),
|
||||
},
|
||||
agentStorage: createAgentStorageStub(),
|
||||
archiveWorkspaceRecord: vi.fn(async () => {}),
|
||||
...createWorkspaceArchivingDeps(),
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === created.worktreePath ? workspaceId : null,
|
||||
),
|
||||
listActiveWorkspaces,
|
||||
killTerminalsForWorkspace: vi.fn(async () => {}),
|
||||
sessionLogger: createLogger(),
|
||||
},
|
||||
{
|
||||
targetPath: created.worktreePath,
|
||||
repoRoot: repoDir,
|
||||
worktreesBaseRoot: path.join(paseoHome, "worktrees"),
|
||||
requestId: "req-no-delete-flag",
|
||||
},
|
||||
);
|
||||
|
||||
// Default (false): the directory survives. The explicit workspaceId is
|
||||
// resolved by path, so no last-reference deletion runs.
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,10 +198,12 @@ export async function buildAgentSessionConfig(
|
||||
): Promise<{
|
||||
sessionConfig: AgentSessionConfig;
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
createdWorkspaceId?: string;
|
||||
}> {
|
||||
let cwd = expandTilde(config.cwd);
|
||||
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
|
||||
let setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
||||
let createdWorkspaceId: string | undefined;
|
||||
|
||||
if (!normalized) {
|
||||
return {
|
||||
@@ -243,6 +245,7 @@ export async function buildAgentSessionConfig(
|
||||
);
|
||||
cwd = createdWorktree.worktree.worktreePath;
|
||||
setupContinuation = createdWorktree.setupContinuation;
|
||||
createdWorkspaceId = createdWorktree.workspace.workspaceId;
|
||||
} else if (normalized.createNewBranch) {
|
||||
const baseBranch =
|
||||
normalized.baseBranch ??
|
||||
@@ -268,6 +271,7 @@ export async function buildAgentSessionConfig(
|
||||
cwd,
|
||||
},
|
||||
setupContinuation,
|
||||
createdWorkspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -449,6 +453,8 @@ export async function handlePaseoWorktreeArchiveRequest(
|
||||
worktreePath: msg.worktreePath,
|
||||
repoRoot: msg.repoRoot,
|
||||
branchName: msg.branchName,
|
||||
workspaceId: msg.workspaceId,
|
||||
deleteWorktreeFromDisk: msg.deleteWorktreeFromDisk,
|
||||
});
|
||||
if (!result.ok) {
|
||||
dependencies.emit({
|
||||
@@ -622,6 +628,7 @@ export async function createPaseoWorktreeWorkflow(
|
||||
startAfterAgentCreate: ({ agentId }) => {
|
||||
void runAsyncWorktreeBootstrap({
|
||||
agentId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
worktree: createdWorktree.worktree,
|
||||
shouldBootstrap: createdWorktree.created,
|
||||
terminalManager: setupContinuation.terminalManager,
|
||||
|
||||
@@ -100,6 +100,8 @@ export interface ArchivePaseoWorktreeCommandInput {
|
||||
worktreePath?: string;
|
||||
worktreeSlug?: string;
|
||||
branchName?: string;
|
||||
workspaceId?: string;
|
||||
deleteWorktreeFromDisk?: boolean;
|
||||
}
|
||||
|
||||
export type ArchivePaseoWorktreeCommandResult =
|
||||
@@ -139,6 +141,8 @@ export async function archivePaseoWorktreeCommand(
|
||||
repoRoot,
|
||||
worktreesRoot: ownership.worktreeRoot,
|
||||
worktreesBaseRoot: dependencies.worktreesRoot,
|
||||
workspaceId: input.workspaceId,
|
||||
deleteWorktreeFromDisk: input.deleteWorktreeFromDisk,
|
||||
requestId: input.requestId,
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface TerminalListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
title?: string;
|
||||
activity: TerminalActivity | null;
|
||||
}
|
||||
@@ -37,10 +38,11 @@ export interface TerminalActivityTransitionEvent {
|
||||
export type TerminalActivityListener = (event: TerminalActivityTransitionEvent) => void;
|
||||
|
||||
export interface TerminalManager {
|
||||
getTerminals(cwd: string): Promise<TerminalSession[]>;
|
||||
getTerminals(cwd: string, options?: { workspaceId?: string }): Promise<TerminalSession[]>;
|
||||
createTerminal(options: {
|
||||
id?: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
env?: Record<string, string>;
|
||||
@@ -184,6 +186,7 @@ export function createTerminalManager(
|
||||
id: input.session.id,
|
||||
name: input.session.name,
|
||||
cwd: input.session.cwd,
|
||||
...(input.session.workspaceId ? { workspaceId: input.session.workspaceId } : {}),
|
||||
title: input.session.getTitle(),
|
||||
activity: input.session.getActivity(),
|
||||
};
|
||||
@@ -235,7 +238,10 @@ export function createTerminalManager(
|
||||
}
|
||||
|
||||
return {
|
||||
async getTerminals(cwd: string): Promise<TerminalSession[]> {
|
||||
async getTerminals(
|
||||
cwd: string,
|
||||
options?: { workspaceId?: string },
|
||||
): Promise<TerminalSession[]> {
|
||||
assertAbsolutePath(cwd);
|
||||
|
||||
// Terminals are bucketed by exact cwd, but an agent can open a terminal in
|
||||
@@ -247,12 +253,24 @@ export function createTerminalManager(
|
||||
sessions.push(...bucketSessions);
|
||||
}
|
||||
}
|
||||
|
||||
// When the query carries a workspaceId, two workspaces sharing a cwd must
|
||||
// not see each other's terminals. Exclude sessions owned by a different
|
||||
// workspace; keep sessions without an owner (COMPAT: created by clients
|
||||
// that predate terminal workspace ownership).
|
||||
if (options?.workspaceId !== undefined) {
|
||||
return sessions.filter(
|
||||
(session) =>
|
||||
session.workspaceId === undefined || session.workspaceId === options.workspaceId,
|
||||
);
|
||||
}
|
||||
return sessions;
|
||||
},
|
||||
|
||||
async createTerminal(options: {
|
||||
id?: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
env?: Record<string, string>;
|
||||
@@ -286,6 +304,7 @@ export function createTerminalManager(
|
||||
await createTerminal({
|
||||
id: terminalId,
|
||||
cwd: options.cwd,
|
||||
...(options.workspaceId ? { workspaceId: options.workspaceId } : {}),
|
||||
name: options.name ?? defaultName,
|
||||
...(options.title ? { title: options.title } : {}),
|
||||
...(options.command ? { command: options.command } : {}),
|
||||
|
||||
@@ -415,6 +415,76 @@ describe("terminal-session-controller subdirectory aggregation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal-session-controller workspace-scoped subscriptions", () => {
|
||||
test("two workspaces sharing a cwd subscribe and unsubscribe independently", async () => {
|
||||
const cwd = "/work/shared";
|
||||
const terminalA: TerminalSession = {
|
||||
...listSession({ id: "a", name: "A", cwd }),
|
||||
workspaceId: "ws-a",
|
||||
};
|
||||
const terminalB: TerminalSession = {
|
||||
...listSession({ id: "b", name: "B", cwd }),
|
||||
workspaceId: "ws-b",
|
||||
};
|
||||
|
||||
let changedListener: ((event: TerminalsChangedEvent) => void) | null = null;
|
||||
const terminalManager: TerminalManager = {
|
||||
getTerminals: vi.fn(async (_cwd: string, options?: { workspaceId?: string }) =>
|
||||
options?.workspaceId === "ws-b" ? [terminalB] : [terminalA],
|
||||
),
|
||||
createTerminal: vi.fn(),
|
||||
registerCwdEnv: vi.fn(),
|
||||
validateTerminalActivityToken: vi.fn(() => "unknown"),
|
||||
getTerminal: vi.fn(),
|
||||
getTerminalState: vi.fn(),
|
||||
setTerminalTitle: vi.fn(),
|
||||
setTerminalActivity: vi.fn(),
|
||||
killTerminal: vi.fn(),
|
||||
killTerminalAndWait: vi.fn(),
|
||||
captureTerminal: vi.fn(),
|
||||
listDirectories: vi.fn(() => [cwd]),
|
||||
killAll: vi.fn(),
|
||||
subscribeTerminalsChanged: vi.fn((listener) => {
|
||||
changedListener = listener;
|
||||
return vi.fn();
|
||||
}),
|
||||
subscribeTerminalActivity: vi.fn(() => vi.fn()),
|
||||
};
|
||||
|
||||
const outboundMessages: SessionOutboundMessage[] = [];
|
||||
const controller = new TerminalSessionController({
|
||||
terminalManager,
|
||||
emit: (message) => outboundMessages.push(message),
|
||||
emitBinary: vi.fn(),
|
||||
hasBinaryChannel: () => true,
|
||||
isPathWithinRoot: isSameOrDescendantPath,
|
||||
sessionLogger: createLogger(),
|
||||
});
|
||||
controller.start();
|
||||
|
||||
controller.dispatch({ type: "subscribe_terminals_request", cwd, workspaceId: "ws-a" });
|
||||
controller.dispatch({ type: "subscribe_terminals_request", cwd, workspaceId: "ws-b" });
|
||||
await flushMicrotasks();
|
||||
outboundMessages.length = 0;
|
||||
|
||||
// Tearing down workspace B must not drop workspace A's live subscription.
|
||||
controller.dispatch({ type: "unsubscribe_terminals_request", cwd, workspaceId: "ws-b" });
|
||||
|
||||
changedListener?.({ cwd, terminals: [{ id: "a", name: "A", cwd, workspaceId: "ws-a" }] });
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(outboundMessages).toEqual([
|
||||
{
|
||||
type: "terminals_changed",
|
||||
payload: {
|
||||
cwd,
|
||||
terminals: [{ id: "a", name: "A", workspaceId: "ws-a", activity: null }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal-session-controller backpressure snapshot fallback", () => {
|
||||
async function setup(getClientBufferedAmount: () => number | null): Promise<{
|
||||
pushOutput: (data: string) => void;
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
UnsubscribeTerminalRequest,
|
||||
UnsubscribeTerminalsRequest,
|
||||
} from "../server/messages.js";
|
||||
import { killTerminalsUnderPath as killWorktreeTerminalsUnderPath } from "../server/paseo-worktree-archive-service.js";
|
||||
import { killTerminalsForWorkspace as killWorkspaceTerminals } from "../server/paseo-worktree-archive-service.js";
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalResizePayload,
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import type { TerminalSession } from "./terminal.js";
|
||||
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
|
||||
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
||||
import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key";
|
||||
|
||||
const MAX_TERMINAL_STREAM_SLOTS = 256;
|
||||
|
||||
@@ -123,7 +124,14 @@ export class TerminalSessionController {
|
||||
private readonly clientSupportsWrapReflow: () => boolean;
|
||||
private readonly getClientBufferedAmount: () => number | null;
|
||||
|
||||
private readonly subscribedDirectories = new Set<string>();
|
||||
// A subscription is scoped to a (cwd, workspaceId) pair, keyed by
|
||||
// terminalSubscriptionKey: two workspaces sharing a cwd subscribe and unsub
|
||||
// independently, and each only receives its own workspace's terminals. The
|
||||
// workspaceId is absent for old clients, which key to the cwd alone.
|
||||
private readonly subscribedDirectories = new Map<
|
||||
string,
|
||||
{ cwd: string; workspaceId: string | undefined }
|
||||
>();
|
||||
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
||||
private readonly exitSubscriptions = new Map<string, () => void>();
|
||||
private readonly activeStreams = new Map<number, ActiveTerminalStream>();
|
||||
@@ -238,17 +246,14 @@ export class TerminalSessionController {
|
||||
return { terminalId, success: true };
|
||||
}
|
||||
|
||||
async killTerminalsUnderPath(rootPath: string): Promise<void> {
|
||||
return killWorktreeTerminalsUnderPath(
|
||||
async killTerminalsForWorkspace(workspaceId: string): Promise<void> {
|
||||
return killWorkspaceTerminals(
|
||||
{
|
||||
isPathWithinRoot: (pathRoot, candidatePath) =>
|
||||
this.isPathWithinRoot(pathRoot, candidatePath),
|
||||
killTrackedTerminal: (terminalId, options) => this.killTracked(terminalId, options),
|
||||
detachTerminalStream: (terminalId, options) => void this.detachStream(terminalId, options),
|
||||
sessionLogger: this.sessionLogger,
|
||||
terminalManager: this.terminalManager,
|
||||
},
|
||||
rootPath,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -293,6 +298,7 @@ export class TerminalSessionController {
|
||||
terminals: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceId?: string;
|
||||
title?: string;
|
||||
activity: TerminalActivity | null;
|
||||
}>;
|
||||
@@ -307,10 +313,11 @@ export class TerminalSessionController {
|
||||
}
|
||||
|
||||
private toTerminalInfo(
|
||||
terminal: Pick<TerminalSession, "id" | "name" | "getTitle" | "getActivity">,
|
||||
terminal: Pick<TerminalSession, "id" | "name" | "workspaceId" | "getTitle" | "getActivity">,
|
||||
): {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceId?: string;
|
||||
title?: string;
|
||||
activity: TerminalActivity | null;
|
||||
} {
|
||||
@@ -319,6 +326,7 @@ export class TerminalSessionController {
|
||||
return {
|
||||
id: terminal.id,
|
||||
name: terminal.name,
|
||||
...(terminal.workspaceId ? { workspaceId: terminal.workspaceId } : {}),
|
||||
...(title ? { title } : {}),
|
||||
activity,
|
||||
};
|
||||
@@ -330,41 +338,52 @@ export class TerminalSessionController {
|
||||
// or above the terminal's cwd, keyed by that root, carrying the full
|
||||
// aggregated list — so the client's cache replacement doesn't drop the
|
||||
// terminals that live directly at the root.
|
||||
const matchingRoots = Array.from(this.subscribedDirectories).filter((root) =>
|
||||
this.isPathWithinRoot(root, event.cwd),
|
||||
const matchingSubscriptions = Array.from(this.subscribedDirectories.values()).filter(
|
||||
(subscription) => this.isPathWithinRoot(subscription.cwd, event.cwd),
|
||||
);
|
||||
for (const root of matchingRoots) {
|
||||
await this.emitTerminalsSnapshotForRoot(root);
|
||||
for (const subscription of matchingSubscriptions) {
|
||||
await this.emitTerminalsSnapshotForSubscription(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void {
|
||||
this.subscribedDirectories.add(msg.cwd);
|
||||
void this.emitTerminalsSnapshotForRoot(msg.cwd);
|
||||
const subscription = { cwd: msg.cwd, workspaceId: msg.workspaceId };
|
||||
this.subscribedDirectories.set(terminalSubscriptionKey(msg.cwd, msg.workspaceId), subscription);
|
||||
void this.emitTerminalsSnapshotForSubscription(subscription);
|
||||
}
|
||||
|
||||
private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void {
|
||||
this.subscribedDirectories.delete(msg.cwd);
|
||||
this.subscribedDirectories.delete(terminalSubscriptionKey(msg.cwd, msg.workspaceId));
|
||||
}
|
||||
|
||||
private async emitTerminalsSnapshotForRoot(cwd: string): Promise<void> {
|
||||
if (!this.terminalManager || !this.subscribedDirectories.has(cwd)) {
|
||||
private async emitTerminalsSnapshotForSubscription(subscription: {
|
||||
cwd: string;
|
||||
workspaceId: string | undefined;
|
||||
}): Promise<void> {
|
||||
const key = terminalSubscriptionKey(subscription.cwd, subscription.workspaceId);
|
||||
if (!this.terminalManager || !this.subscribedDirectories.has(key)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const terminals = await this.getTerminalsForWorkspaceRoot(cwd);
|
||||
const terminals = await this.getTerminalsForWorkspaceRoot(
|
||||
subscription.cwd,
|
||||
subscription.workspaceId,
|
||||
);
|
||||
for (const terminal of terminals) {
|
||||
this.ensureExitSubscription(terminal);
|
||||
}
|
||||
if (!this.subscribedDirectories.has(cwd)) {
|
||||
if (!this.subscribedDirectories.has(key)) {
|
||||
return;
|
||||
}
|
||||
this.emitTerminalsChangedSnapshot({
|
||||
cwd,
|
||||
cwd: subscription.cwd,
|
||||
terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)),
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.warn({ err: error, cwd }, "Failed to emit initial terminal snapshot");
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, cwd: subscription.cwd },
|
||||
"Failed to emit initial terminal snapshot",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +403,7 @@ export class TerminalSessionController {
|
||||
try {
|
||||
const terminals =
|
||||
typeof msg.cwd === "string"
|
||||
? await this.getTerminalsForWorkspaceRoot(msg.cwd)
|
||||
? await this.getTerminalsForWorkspaceRoot(msg.cwd, msg.workspaceId)
|
||||
: await this.getAllTerminalSessions();
|
||||
for (const terminal of terminals) {
|
||||
this.ensureExitSubscription(terminal);
|
||||
@@ -422,12 +441,15 @@ export class TerminalSessionController {
|
||||
return terminalsByDirectory.flat();
|
||||
}
|
||||
|
||||
private async getTerminalsForWorkspaceRoot(cwd: string): Promise<TerminalSession[]> {
|
||||
private async getTerminalsForWorkspaceRoot(
|
||||
cwd: string,
|
||||
workspaceId?: string,
|
||||
): Promise<TerminalSession[]> {
|
||||
if (!this.terminalManager) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const terminals = await this.terminalManager.getTerminals(cwd);
|
||||
const terminals = await this.terminalManager.getTerminals(cwd, { workspaceId });
|
||||
const workspaceRoots = await this.listTerminalWorkspaceRoots();
|
||||
if (workspaceRoots.length === 0) {
|
||||
return terminals;
|
||||
@@ -500,6 +522,7 @@ export class TerminalSessionController {
|
||||
|
||||
const session = await this.terminalManager.createTerminal({
|
||||
cwd: msg.cwd,
|
||||
...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}),
|
||||
name: msg.name,
|
||||
command: msg.command,
|
||||
args: msg.args,
|
||||
@@ -512,6 +535,7 @@ export class TerminalSessionController {
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
cwd: session.cwd,
|
||||
...(session.workspaceId ? { workspaceId: session.workspaceId } : {}),
|
||||
...(session.getTitle() ? { title: session.getTitle() } : {}),
|
||||
activity: session.getActivity(),
|
||||
},
|
||||
|
||||
@@ -66,6 +66,7 @@ function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo {
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
cwd: session.cwd,
|
||||
...(session.workspaceId ? { workspaceId: session.workspaceId } : {}),
|
||||
...(session.getTitle() ? { title: session.getTitle() } : {}),
|
||||
activity: session.getActivity(),
|
||||
};
|
||||
@@ -241,7 +242,9 @@ async function handleCreateTerminalRequest(message: TerminalCreateRequest): Prom
|
||||
async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
|
||||
switch (message.type) {
|
||||
case "getTerminals": {
|
||||
const terminals = await manager.getTerminals(message.cwd);
|
||||
const terminals = await manager.getTerminals(message.cwd, {
|
||||
workspaceId: message.workspaceId,
|
||||
});
|
||||
sendToParent({
|
||||
type: "response",
|
||||
requestId: message.requestId,
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface WorkerTerminalInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
title?: string;
|
||||
activity: TerminalActivity | null;
|
||||
}
|
||||
@@ -20,6 +21,7 @@ export interface WorkerTerminalInfo {
|
||||
export interface WorkerCreateTerminalOptions {
|
||||
id?: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
env?: Record<string, string>;
|
||||
@@ -39,6 +41,7 @@ export type TerminalWorkerRequest =
|
||||
type: "getTerminals";
|
||||
requestId: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
| {
|
||||
type: "createTerminal";
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface TerminalSession {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
send(msg: ClientMessage): void;
|
||||
subscribe(listener: (msg: ServerMessage) => void, options?: TerminalSubscribeOptions): () => void;
|
||||
onExit(listener: (info: TerminalExitInfo) => void): () => void;
|
||||
@@ -117,6 +118,7 @@ function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | nu
|
||||
export interface CreateTerminalOptions {
|
||||
id?: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
shell?: string;
|
||||
env?: Record<string, string>;
|
||||
activityEnv?: Record<string, string>;
|
||||
@@ -792,6 +794,7 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] {
|
||||
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
|
||||
const {
|
||||
cwd,
|
||||
workspaceId,
|
||||
shell,
|
||||
env = {},
|
||||
activityEnv = {},
|
||||
@@ -852,7 +855,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
env: buildTerminalEnvironment({ shell: spawnCommand, env: { ...env, ...activityEnv } }),
|
||||
env: buildTerminalEnvironment({
|
||||
shell: spawnCommand,
|
||||
env: {
|
||||
...env,
|
||||
...activityEnv,
|
||||
...(workspaceId ? { PASEO_WORKSPACE_ID: workspaceId } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
function emitTitleChange(nextTitle: string | undefined): void {
|
||||
@@ -1433,6 +1443,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
id,
|
||||
name,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
send,
|
||||
subscribe,
|
||||
onExit,
|
||||
|
||||
@@ -115,6 +115,7 @@ function cloneTerminalInfo(info: WorkerTerminalInfo): WorkerTerminalInfo {
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
cwd: info.cwd,
|
||||
...(info.workspaceId ? { workspaceId: info.workspaceId } : {}),
|
||||
...(info.title ? { title: info.title } : {}),
|
||||
activity: info.activity,
|
||||
};
|
||||
@@ -177,6 +178,7 @@ export function createWorkerTerminalManager(
|
||||
id: record.info.id,
|
||||
name: record.info.name,
|
||||
cwd: record.info.cwd,
|
||||
...(record.info.workspaceId ? { workspaceId: record.info.workspaceId } : {}),
|
||||
...(record.info.title ? { title: record.info.title } : {}),
|
||||
activity: record.activity,
|
||||
});
|
||||
@@ -219,6 +221,9 @@ export function createWorkerTerminalManager(
|
||||
get cwd() {
|
||||
return record.info.cwd;
|
||||
},
|
||||
get workspaceId() {
|
||||
return record.info.workspaceId;
|
||||
},
|
||||
send(message: ClientMessage): void {
|
||||
if (message.type === "resize") {
|
||||
record.state = {
|
||||
@@ -498,6 +503,7 @@ export function createWorkerTerminalManager(
|
||||
id: terminal.id,
|
||||
name: terminal.name,
|
||||
cwd: terminal.cwd,
|
||||
...(terminal.workspaceId ? { workspaceId: terminal.workspaceId } : {}),
|
||||
...(terminal.title ? { title: terminal.title } : {}),
|
||||
activity: terminal.activity,
|
||||
})),
|
||||
@@ -625,8 +631,15 @@ export function createWorkerTerminalManager(
|
||||
}
|
||||
|
||||
return {
|
||||
async getTerminals(cwd: string): Promise<TerminalSession[]> {
|
||||
const result = (await sendRequest({ type: "getTerminals", cwd })) as WorkerTerminalInfo[];
|
||||
async getTerminals(
|
||||
cwd: string,
|
||||
options?: { workspaceId?: string },
|
||||
): Promise<TerminalSession[]> {
|
||||
const result = (await sendRequest({
|
||||
type: "getTerminals",
|
||||
cwd,
|
||||
...(options?.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
|
||||
})) as WorkerTerminalInfo[];
|
||||
return toSessions(result);
|
||||
},
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface TreeKillTarget {
|
||||
once?(event: "exit", listener: () => void): unknown;
|
||||
}
|
||||
|
||||
interface TerminateWithTreeKillOptions {
|
||||
export interface TerminateWithTreeKillOptions {
|
||||
gracefulSignal?: NodeJS.Signals;
|
||||
forceSignal?: NodeJS.Signals;
|
||||
gracefulTimeoutMs: number;
|
||||
@@ -22,6 +22,13 @@ export type TerminateWithTreeKillResult =
|
||||
| "killed"
|
||||
| "kill-timeout";
|
||||
|
||||
// Injection seam: production wires terminateWithTreeKill; tests wire a fake that
|
||||
// records which children were terminated as observable state.
|
||||
export type ProcessTerminator = (
|
||||
child: TreeKillTarget,
|
||||
options: TerminateWithTreeKillOptions,
|
||||
) => Promise<TerminateWithTreeKillResult>;
|
||||
|
||||
export async function terminateWithTreeKill(
|
||||
child: TreeKillTarget,
|
||||
options: TerminateWithTreeKillOptions,
|
||||
|
||||
Reference in New Issue
Block a user