Fix same-directory workspace ownership
This commit is contained in:
@@ -58,7 +58,7 @@ The asymmetry is intentional: a subagent's home is the parent's track, not the t
|
|||||||
|
|
||||||
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
|
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
|
||||||
|
|
||||||
Workspace status is an aggregate activity signal. Root agents contribute their normal state bucket to their own workspace. Running subagents contribute `running` to their root parent's workspace, not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
|
Workspace status is an aggregate activity signal. Root agents contribute their normal state bucket to every active workspace with the same `cwd` as their owning workspace; tab and agent visibility still stays scoped to the agent's `workspaceId`. Running subagents contribute `running` to workspaces with the same `cwd` as their root parent's owning workspace, not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
|
||||||
|
|
||||||
## The subagents track
|
## The subagents track
|
||||||
|
|
||||||
|
|||||||
+32
-23
@@ -40,29 +40,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
|
|||||||
|
|
||||||
Each agent is stored as a separate JSON file, grouped by project directory.
|
Each agent is stored as a separate JSON file, grouped by project directory.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `id` | `string` | UUID, primary key |
|
| `id` | `string` | UUID, primary key |
|
||||||
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
||||||
| `cwd` | `string` | Working directory the agent operates in |
|
| `cwd` | `string` | Working directory the agent operates in |
|
||||||
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
| `workspaceId` | `string?` | Owning workspace id. Legacy cwd-only records are migrated to a stable existing workspace owner; runtime workspace membership should not infer ownership from cwd when same-CWD workspaces exist. |
|
||||||
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
||||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
||||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||||
| `title` | `string?` | User-visible title |
|
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
| `title` | `string?` | User-visible title |
|
||||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||||
| `lastModeId` | `string?` | Last active mode ID |
|
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
| `lastModeId` | `string?` | Last active mode ID |
|
||||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||||
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||||
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
||||||
| `lastError` | `string?` (nullable) | Last error message, if any |
|
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
||||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
| `lastError` | `string?` (nullable) | Last error message, if any |
|
||||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||||
|
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||||
|
|
||||||
### Nested: SerializableConfig
|
### Nested: SerializableConfig
|
||||||
|
|
||||||
@@ -126,6 +127,14 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Runtime-only Terminal Sessions
|
||||||
|
|
||||||
|
Terminals are live daemon state, not persisted JSON records. A terminal may carry a `workspaceId` while it is running; workspace-scoped terminal lists include only terminals with the matching `workspaceId`. Legacy live terminals without an owner remain visible to unscoped terminal reads but are not migrated or inferred into same-`cwd` workspace lists.
|
||||||
|
|
||||||
|
Terminal activity still contributes to the aggregate workspace status bucket. When an owned terminal contributes activity, every active workspace with the owning workspace's `cwd` receives the same status bucket; terminal visibility itself stays `workspaceId`-scoped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2. Daemon Configuration
|
## 2. Daemon Configuration
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/config.json`
|
**Path:** `$PASEO_HOME/config.json`
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
|||||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, status, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
|
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ TerminalSession
|
|||||||
|
|
||||||
`TerminalActivityTracker` is the single stateful object per session. It holds `{ state, changedAt }`, starts at unknown (`null`), and fires `onChange` only when the state actually changes.
|
`TerminalActivityTracker` is the single stateful object per session. It holds `{ state, changedAt }`, starts at unknown (`null`), and fires `onChange` only when the state actually changes.
|
||||||
|
|
||||||
Terminal directory snapshots (`terminalsChanged`) and workspace contribution changes are separate concerns. A title-only change produces a terminal list snapshot but never touches workspace descriptors. A transition that changes the derived workspace bucket (e.g. idle → working, working → idle, attention cleared) emits both a terminal list snapshot and a server-internal `TerminalWorkspaceContributionChanged` event, which Session consumes to invalidate only the affected workspace.
|
Terminal directory snapshots (`terminalsChanged`) and workspace contribution changes are separate concerns. A title-only change produces a terminal list snapshot but never touches workspace descriptors. A transition that changes the derived workspace bucket (e.g. idle -> working, working -> idle, attention cleared) emits both a terminal list snapshot and a server-internal `TerminalWorkspaceContributionChanged` event, which Session consumes to invalidate every active workspace sharing the owning workspace's `cwd`.
|
||||||
|
|
||||||
### Transitions carry their own history
|
### Transitions carry their own history
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { test, expect } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
import {
|
import { expectComposerEditable, expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||||
expectComposerEditable,
|
|
||||||
expectComposerVisible,
|
|
||||||
fillComposerDraft,
|
|
||||||
} from "./helpers/composer";
|
|
||||||
import { clickNewChat, gotoWorkspace } from "./helpers/launcher";
|
import { clickNewChat, gotoWorkspace } from "./helpers/launcher";
|
||||||
import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace";
|
import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace";
|
||||||
|
import { captureWsSessionFrames } from "./helpers/rename";
|
||||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||||
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
|
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
|
||||||
|
|
||||||
type NewWorkspaceDaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
type NewWorkspaceDaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||||
|
|
||||||
|
interface CreateAgentFrame extends Record<string, unknown> {
|
||||||
|
initialPrompt: string | null;
|
||||||
|
workspaceId: string | null;
|
||||||
|
provider: string | null;
|
||||||
|
cwd: string | null;
|
||||||
|
modeId: string | null;
|
||||||
|
model: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFrameForPrompt(frames: CreateAgentFrame[], prompt: string): CreateAgentFrame | null {
|
||||||
|
return frames.find((frame) => frame.initialPrompt === prompt) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("Workspace model regressions", () => {
|
test.describe("Workspace model regressions", () => {
|
||||||
let client: NewWorkspaceDaemonClient;
|
let client: NewWorkspaceDaemonClient;
|
||||||
|
|
||||||
@@ -64,6 +74,21 @@ test.describe("Workspace model regressions", () => {
|
|||||||
const seeded: SeededWorkspace = await seedWorkspace({
|
const seeded: SeededWorkspace = await seedWorkspace({
|
||||||
repoPrefix: "workspace-new-agent-model-",
|
repoPrefix: "workspace-new-agent-model-",
|
||||||
});
|
});
|
||||||
|
const createFrames = captureWsSessionFrames<CreateAgentFrame>(
|
||||||
|
page,
|
||||||
|
"create_agent_request",
|
||||||
|
(inner) => {
|
||||||
|
const config = (inner.config ?? {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
|
||||||
|
workspaceId: typeof inner.workspaceId === "string" ? inner.workspaceId : null,
|
||||||
|
provider: typeof config.provider === "string" ? config.provider : null,
|
||||||
|
cwd: typeof config.cwd === "string" ? config.cwd : null,
|
||||||
|
modeId: typeof config.modeId === "string" ? config.modeId : null,
|
||||||
|
model: typeof config.model === "string" ? config.model : null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const secondWorkspace = await seeded.client.createWorkspace({
|
const secondWorkspace = await seeded.client.createWorkspace({
|
||||||
@@ -78,9 +103,9 @@ test.describe("Workspace model regressions", () => {
|
|||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"@paseo:create-agent-preferences",
|
"@paseo:create-agent-preferences",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
provider: "codex",
|
provider: "mock",
|
||||||
providerPreferences: {
|
providerPreferences: {
|
||||||
codex: { mode: "full-access" },
|
mock: { mode: "load-test" },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -90,10 +115,23 @@ test.describe("Workspace model regressions", () => {
|
|||||||
|
|
||||||
await expectComposerVisible(page);
|
await expectComposerVisible(page);
|
||||||
await expectComposerEditable(page);
|
await expectComposerEditable(page);
|
||||||
await fillComposerDraft(page, "d");
|
const prompt = `Create agent default model ${Date.now()}`;
|
||||||
|
await submitMessage(page, prompt);
|
||||||
await expect(page.getByText("No model is available for the selected provider")).toHaveCount(
|
await expect(page.getByText("No model is available for the selected provider")).toHaveCount(
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
await expect
|
||||||
|
.poll(() => createFrameForPrompt(createFrames, prompt), {
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
|
.toEqual({
|
||||||
|
initialPrompt: prompt,
|
||||||
|
workspaceId: secondWorkspace.workspace.id,
|
||||||
|
provider: "mock",
|
||||||
|
cwd: seeded.repoPath,
|
||||||
|
modeId: "load-test",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await seeded.cleanup();
|
await seeded.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
modeId: "auto",
|
modeId: "auto",
|
||||||
|
model: "gpt-5.4",
|
||||||
|
thinkingOptionId: "high",
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -52,6 +52,22 @@ describe("resolveEffectiveComposerModelId", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe("");
|
).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the provider default model when no model is selected", () => {
|
||||||
|
expect(
|
||||||
|
resolveEffectiveComposerModelId({
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "",
|
||||||
|
modeId: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
availableModels: [
|
||||||
|
{ provider: "codex", id: "gpt-5.4-mini", label: "gpt-5.4-mini" },
|
||||||
|
{ provider: "codex", id: "gpt-5.4", label: "gpt-5.4", isDefault: true },
|
||||||
|
],
|
||||||
|
modeOptions: [],
|
||||||
|
}),
|
||||||
|
).toBe("gpt-5.4");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveEffectiveComposerThinkingOptionId", () => {
|
describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||||
|
|||||||
@@ -229,7 +229,15 @@ export function filterAndRankModelRows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveEffectiveComposerModelId(selection: ProviderSelectionState): string {
|
export function resolveEffectiveComposerModelId(selection: ProviderSelectionState): string {
|
||||||
return selection.modelId.trim();
|
const selectedModelId = selection.modelId.trim();
|
||||||
|
if (selectedModelId) {
|
||||||
|
return selectedModelId;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
selection.availableModels.find((model) => model.isDefault)?.id ??
|
||||||
|
selection.availableModels[0]?.id ??
|
||||||
|
""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveEffectiveComposerThinkingOptionId(
|
export function resolveEffectiveComposerThinkingOptionId(
|
||||||
|
|||||||
@@ -252,6 +252,10 @@ import {
|
|||||||
type CreatePaseoWorktreeInput,
|
type CreatePaseoWorktreeInput,
|
||||||
type CreatePaseoWorktreeResult,
|
type CreatePaseoWorktreeResult,
|
||||||
} from "./paseo-worktree-service.js";
|
} from "./paseo-worktree-service.js";
|
||||||
|
import {
|
||||||
|
migrateLegacyAgentWorkspaceOwnership,
|
||||||
|
resolveLegacyWorkspaceOwnerForCwd,
|
||||||
|
} from "./workspace-registry-bootstrap.js";
|
||||||
import {
|
import {
|
||||||
generateBranchNameFromFirstAgentContext,
|
generateBranchNameFromFirstAgentContext,
|
||||||
type GeneratedWorkspaceName,
|
type GeneratedWorkspaceName,
|
||||||
@@ -3282,6 +3286,10 @@ export class Session {
|
|||||||
const createAgentConfig: AgentSessionConfig = createdWorktree
|
const createAgentConfig: AgentSessionConfig = createdWorktree
|
||||||
? { ...config, cwd: createdWorktree.worktree.worktreePath }
|
? { ...config, cwd: createdWorktree.worktree.worktreePath }
|
||||||
: config;
|
: config;
|
||||||
|
const workspaceId =
|
||||||
|
createdWorktree?.workspace.workspaceId ??
|
||||||
|
msg.workspaceId ??
|
||||||
|
(await this.resolveLegacyWorkspaceOwnerForAgentCreate(createAgentConfig.cwd));
|
||||||
|
|
||||||
const { snapshot, liveSnapshot } = await createAgentCommand(
|
const { snapshot, liveSnapshot } = await createAgentCommand(
|
||||||
{
|
{
|
||||||
@@ -3297,7 +3305,7 @@ export class Session {
|
|||||||
{
|
{
|
||||||
kind: "session",
|
kind: "session",
|
||||||
config: createAgentConfig,
|
config: createAgentConfig,
|
||||||
workspaceId: createdWorktree ? createdWorktree.workspace.workspaceId : msg.workspaceId,
|
workspaceId,
|
||||||
worktreeName,
|
worktreeName,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
clientMessageId,
|
clientMessageId,
|
||||||
@@ -3321,7 +3329,13 @@ export class Session {
|
|||||||
agentId: snapshot.id,
|
agentId: snapshot.id,
|
||||||
createdWorktree,
|
createdWorktree,
|
||||||
});
|
});
|
||||||
|
if (!createdWorktree && trimmedPrompt) {
|
||||||
|
await this.scheduleAutoNameLocalWorkspaceTitleForFirstAgent({
|
||||||
|
workspaceId,
|
||||||
|
cwd: createAgentConfig.cwd,
|
||||||
|
firstAgentContext,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (requestId) {
|
if (requestId) {
|
||||||
const agentPayload = await this.buildAgentPayload(liveSnapshot);
|
const agentPayload = await this.buildAgentPayload(liveSnapshot);
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -3801,6 +3815,26 @@ export class Session {
|
|||||||
await this.emitWorkspaceUpdateForCwd(input.cwd);
|
await this.emitWorkspaceUpdateForCwd(input.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async scheduleAutoNameLocalWorkspaceTitleForFirstAgent(input: {
|
||||||
|
workspaceId?: string;
|
||||||
|
cwd: string;
|
||||||
|
firstAgentContext: FirstAgentContext;
|
||||||
|
}): Promise<void> {
|
||||||
|
const workspaceId = input.workspaceId ?? (await this.resolveWorkspaceIdForCwd(input.cwd));
|
||||||
|
if (!workspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.scheduleWorkspaceNaming(
|
||||||
|
() =>
|
||||||
|
this.maybeAutoNameDirectoryWorkspaceTitle({
|
||||||
|
workspaceId,
|
||||||
|
cwd: input.cwd,
|
||||||
|
firstAgentContext: input.firstAgentContext,
|
||||||
|
}),
|
||||||
|
{ cwd: input.cwd, message: "Failed to auto-name local workspace title" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private emitProviderDisabledResponse(
|
private emitProviderDisabledResponse(
|
||||||
kind: "models" | "modes",
|
kind: "models" | "modes",
|
||||||
provider: AgentProvider,
|
provider: AgentProvider,
|
||||||
@@ -7296,12 +7330,22 @@ export class Session {
|
|||||||
{ workspaceId: event.workspaceId, cwd: event.cwd },
|
{ workspaceId: event.workspaceId, cwd: event.cwd },
|
||||||
workspaces,
|
workspaces,
|
||||||
);
|
);
|
||||||
if (workspaceId) {
|
const owningWorkspace = workspaceId
|
||||||
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], {
|
? workspaces.find(
|
||||||
skipReconcile: true,
|
(workspace) => workspace.workspaceId === workspaceId && !workspace.archivedAt,
|
||||||
});
|
)
|
||||||
|
: null;
|
||||||
|
if (!owningWorkspace) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd(
|
||||||
|
owningWorkspace.cwd,
|
||||||
|
workspaces,
|
||||||
|
);
|
||||||
|
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, {
|
||||||
|
skipReconcile: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
await this.emitWorkspaceUpdateForCwd(event.cwd, { skipReconcile: true });
|
await this.emitWorkspaceUpdateForCwd(event.cwd, { skipReconcile: true });
|
||||||
}
|
}
|
||||||
@@ -7614,6 +7658,13 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await migrateLegacyAgentWorkspaceOwnership({
|
||||||
|
agentStorage: this.agentStorage,
|
||||||
|
workspaceRegistry: this.workspaceRegistry,
|
||||||
|
logger: this.sessionLogger,
|
||||||
|
cwds: [cwd],
|
||||||
|
});
|
||||||
|
|
||||||
const workspace = await createLocalCheckoutWorkspace(
|
const workspace = await createLocalCheckoutWorkspace(
|
||||||
{ cwd, title: request.title ?? null },
|
{ cwd, title: request.title ?? null },
|
||||||
{
|
{
|
||||||
@@ -7656,6 +7707,12 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveLegacyWorkspaceOwnerForAgentCreate(
|
||||||
|
cwd: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
return resolveLegacyWorkspaceOwnerForCwd(cwd, await this.workspaceRegistry.list()) ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Schedules a background workspace-naming write off the request path and
|
// Schedules a background workspace-naming write off the request path and
|
||||||
// records the resulting promise so tests can await completion deterministically
|
// records the resulting promise so tests can await completion deterministically
|
||||||
// (no wall-clock sleeps). The setTimeout(0) keeps the LLM call off the hot path.
|
// (no wall-clock sleeps). The setTimeout(0) keeps the LLM call off the hot path.
|
||||||
|
|||||||
@@ -4716,7 +4716,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("same-cwd workspace descriptors scope agent status by workspace id", async () => {
|
test("same-cwd workspace descriptors share agent status by cwd", async () => {
|
||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const project = createPersistedProjectRecord({
|
const project = createPersistedProjectRecord({
|
||||||
projectId: "proj-same-cwd-status",
|
projectId: "proj-same-cwd-status",
|
||||||
@@ -4758,7 +4758,7 @@ test("same-cwd workspace descriptors scope agent status by workspace id", async
|
|||||||
];
|
];
|
||||||
const runningDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
|
const runningDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
|
||||||
expect(runningDescriptors.get(workspaceA.workspaceId)?.status).toBe("running");
|
expect(runningDescriptors.get(workspaceA.workspaceId)?.status).toBe("running");
|
||||||
expect(runningDescriptors.get(workspaceB.workspaceId)?.status).toBe("done");
|
expect(runningDescriptors.get(workspaceB.workspaceId)?.status).toBe("running");
|
||||||
|
|
||||||
session.listAgentPayloads = async () => [
|
session.listAgentPayloads = async () => [
|
||||||
makeAgent({
|
makeAgent({
|
||||||
@@ -4773,7 +4773,7 @@ test("same-cwd workspace descriptors scope agent status by workspace id", async
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
const attentionDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
|
const attentionDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
|
||||||
expect(attentionDescriptors.get(workspaceA.workspaceId)?.status).toBe("done");
|
expect(attentionDescriptors.get(workspaceA.workspaceId)?.status).toBe("attention");
|
||||||
expect(attentionDescriptors.get(workspaceB.workspaceId)?.status).toBe("attention");
|
expect(attentionDescriptors.get(workspaceB.workspaceId)?.status).toBe("attention");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5770,7 +5770,7 @@ test("terminal activity contribution change updates the correct workspace", asyn
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("same-cwd workspace attribution targets the terminal's workspace", async () => {
|
test("same-cwd terminal activity updates every workspace status bucket for that cwd", async () => {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-same-cwd-"));
|
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-same-cwd-"));
|
||||||
const workspaceA = createPersistedWorkspaceRecord({
|
const workspaceA = createPersistedWorkspaceRecord({
|
||||||
@@ -5816,21 +5816,22 @@ test("same-cwd workspace attribution targets the terminal's workspace", async ()
|
|||||||
message.payload.kind === "upsert" &&
|
message.payload.kind === "upsert" &&
|
||||||
message.payload.workspace.id === workspaceB.workspaceId &&
|
message.payload.workspace.id === workspaceB.workspaceId &&
|
||||||
message.payload.workspace.status === "running",
|
message.payload.workspace.status === "running",
|
||||||
"same-cwd terminal activity targets the stamped workspace",
|
"same-cwd terminal activity updates the stamped workspace",
|
||||||
|
);
|
||||||
|
await waitForWorkspaceUpdate(
|
||||||
|
emitted,
|
||||||
|
(message) =>
|
||||||
|
message.payload.kind === "upsert" &&
|
||||||
|
message.payload.workspace.id === workspaceA.workspaceId &&
|
||||||
|
message.payload.workspace.status === "running",
|
||||||
|
"same-cwd terminal activity updates the sibling workspace status bucket",
|
||||||
);
|
);
|
||||||
|
|
||||||
const updates = filterByType(emitted, "workspace_update");
|
const updates = filterByType(emitted, "workspace_update");
|
||||||
const upserts = updates.filter((update) => update.payload.kind === "upsert");
|
const upserts = updates.filter((update) => update.payload.kind === "upsert");
|
||||||
const targetIds = new Set(upserts.map((update) => update.payload.workspace.id));
|
const targetIds = new Set(upserts.map((update) => update.payload.workspace.id));
|
||||||
expect(targetIds.has(workspaceB.workspaceId)).toBe(true);
|
expect(targetIds.has(workspaceB.workspaceId)).toBe(true);
|
||||||
expect(targetIds.has(workspaceA.workspaceId)).toBe(false);
|
expect(targetIds.has(workspaceA.workspaceId)).toBe(true);
|
||||||
expect(upserts[upserts.length - 1]?.payload).toMatchObject({
|
|
||||||
kind: "upsert",
|
|
||||||
workspace: {
|
|
||||||
id: workspaceB.workspaceId,
|
|
||||||
status: "running",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("nested worktree attribution targets the deepest active workspace", async () => {
|
test("nested worktree attribution targets the deepest active workspace", async () => {
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ class WorkspaceStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A root agent owned by a specific workspace, even though both same-cwd
|
// A root agent owned by a specific workspace, even though both same-cwd
|
||||||
// workspaces share the directory. Attribution must follow workspaceId.
|
// workspaces share the directory. Ownership follows workspaceId; aggregate
|
||||||
|
// status intentionally fans out to every workspace for that cwd.
|
||||||
hasStampedRootAgent(input: AgentState & { workspaceId: string }): void {
|
hasStampedRootAgent(input: AgentState & { workspaceId: string }): void {
|
||||||
this.agents.push(
|
this.agents.push(
|
||||||
createAgent({ ...input, cwd: this.workspace.cwd, workspaceId: input.workspaceId }),
|
createAgent({ ...input, cwd: this.workspace.cwd, workspaceId: input.workspaceId }),
|
||||||
@@ -153,8 +154,9 @@ class WorkspaceStatus {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// A working terminal owned by a specific same-cwd workspace. Attribution must
|
// A working terminal owned by a specific same-cwd workspace. Ownership follows
|
||||||
// follow workspaceId, not the deterministic-oldest cwd fallback.
|
// workspaceId; aggregate status intentionally fans out to every workspace for
|
||||||
|
// that cwd.
|
||||||
hasStampedWorkingTerminal(input: { workspaceId: string; changedAt: number }): void {
|
hasStampedWorkingTerminal(input: { workspaceId: string; changedAt: number }): void {
|
||||||
this.terminals.push({
|
this.terminals.push({
|
||||||
cwd: this.workspace.cwd,
|
cwd: this.workspace.cwd,
|
||||||
@@ -278,13 +280,10 @@ describe("WorkspaceDirectory", () => {
|
|||||||
await expect(workspace.workspaceStatus()).resolves.toBe("running");
|
await expect(workspace.workspaceStatus()).resolves.toBe("running");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("two same-cwd workspaces keep independent status, attributed by workspaceId", async () => {
|
test("same-cwd workspaces share agent status buckets", async () => {
|
||||||
const workspace = new WorkspaceStatus();
|
const workspace = new WorkspaceStatus();
|
||||||
|
|
||||||
workspace.hasSiblingWorkspaceSameCwd();
|
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({
|
workspace.hasStampedRootAgent({
|
||||||
id: "agent-a",
|
id: "agent-a",
|
||||||
status: "running",
|
status: "running",
|
||||||
@@ -297,23 +296,49 @@ describe("WorkspaceDirectory", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(workspace.workspaceStatuses()).resolves.toEqual({
|
await expect(workspace.workspaceStatuses()).resolves.toEqual({
|
||||||
"workspace-1": "done",
|
"workspace-1": "running",
|
||||||
"workspace-1-sibling": "running",
|
"workspace-1-sibling": "running",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("two same-cwd workspaces keep independent terminal status, attributed by workspaceId", async () => {
|
test("same-cwd workspaces share agent attention buckets", async () => {
|
||||||
|
const workspace = new WorkspaceStatus();
|
||||||
|
|
||||||
|
workspace.hasSiblingWorkspaceSameCwd();
|
||||||
|
workspace.hasStampedRootAgent({
|
||||||
|
id: "agent-a",
|
||||||
|
status: "idle",
|
||||||
|
pendingPermissionCount: 1,
|
||||||
|
workspaceId: "workspace-1-sibling",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(workspace.workspaceStatuses()).resolves.toEqual({
|
||||||
|
"workspace-1": "needs_input",
|
||||||
|
"workspace-1-sibling": "needs_input",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same-cwd workspaces share legacy unstamped agent status buckets", async () => {
|
||||||
|
const workspace = new WorkspaceStatus();
|
||||||
|
|
||||||
|
workspace.hasSiblingWorkspaceSameCwd();
|
||||||
|
workspace.hasRootAgent({ id: "legacy-agent", status: "running" });
|
||||||
|
|
||||||
|
await expect(workspace.workspaceStatuses()).resolves.toEqual({
|
||||||
|
"workspace-1": "running",
|
||||||
|
"workspace-1-sibling": "running",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same-cwd workspaces share terminal status buckets", async () => {
|
||||||
const workspace = new WorkspaceStatus();
|
const workspace = new WorkspaceStatus();
|
||||||
const changedAt = new Date(NOW).getTime();
|
const changedAt = new Date(NOW).getTime();
|
||||||
|
|
||||||
workspace.hasSiblingWorkspaceSameCwd();
|
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 });
|
workspace.hasStampedWorkingTerminal({ workspaceId: "workspace-1-sibling", changedAt });
|
||||||
|
|
||||||
await expect(workspace.workspaceStatuses()).resolves.toEqual({
|
await expect(workspace.workspaceStatuses()).resolves.toEqual({
|
||||||
"workspace-1": "done",
|
"workspace-1": "running",
|
||||||
"workspace-1-sibling": "running",
|
"workspace-1-sibling": "running",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -338,19 +338,21 @@ export class WorkspaceDirectory {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceId = resolveWorkspaceIdForRecord(workspaceAgent, activeRecords);
|
const workspaceIds = resolveWorkspaceIdsForStatusContribution(workspaceAgent, activeRecords);
|
||||||
if (workspaceId === null || !activeWorkspaceIds.has(workspaceId)) {
|
for (const workspaceId of workspaceIds) {
|
||||||
continue;
|
if (!activeWorkspaceIds.has(workspaceId)) {
|
||||||
}
|
continue;
|
||||||
const existing = descriptorsByWorkspaceId.get(workspaceId);
|
}
|
||||||
if (!existing) {
|
const existing = descriptorsByWorkspaceId.get(workspaceId);
|
||||||
continue;
|
if (!existing) {
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
|
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
|
||||||
) {
|
) {
|
||||||
existing.status = bucket;
|
existing.status = bucket;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,24 +379,29 @@ export class WorkspaceDirectory {
|
|||||||
}
|
}
|
||||||
const bucket = deriveTerminalActivityStatusBucket(activity);
|
const bucket = deriveTerminalActivityStatusBucket(activity);
|
||||||
if (!bucket) continue;
|
if (!bucket) continue;
|
||||||
const workspaceId =
|
const resolvedWorkspaceId = contributedWorkspaceId
|
||||||
resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords) ??
|
? resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords)
|
||||||
resolveWorkspaceIdForCwd(cwd);
|
: resolveWorkspaceIdForCwd(cwd);
|
||||||
if (workspaceId == null) {
|
const workspaceIds = resolvedWorkspaceId
|
||||||
continue;
|
? resolveWorkspaceIdsForStatusContribution(
|
||||||
|
{ workspaceId: resolvedWorkspaceId, cwd },
|
||||||
|
activeRecords,
|
||||||
|
)
|
||||||
|
: resolveWorkspaceIdsForStatusContribution({ cwd }, activeRecords);
|
||||||
|
for (const workspaceId of workspaceIds) {
|
||||||
|
const existing = descriptorsByWorkspaceId.get(workspaceId);
|
||||||
|
if (!existing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
|
||||||
|
) {
|
||||||
|
existing.status = bucket;
|
||||||
|
}
|
||||||
|
const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? [];
|
||||||
|
entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() });
|
||||||
|
terminalEntriesByWorkspaceId.set(workspaceId, entries);
|
||||||
}
|
}
|
||||||
const existing = descriptorsByWorkspaceId.get(workspaceId);
|
|
||||||
if (!existing) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
|
|
||||||
) {
|
|
||||||
existing.status = bucket;
|
|
||||||
}
|
|
||||||
const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? [];
|
|
||||||
entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() });
|
|
||||||
terminalEntriesByWorkspaceId.set(workspaceId, entries);
|
|
||||||
}
|
}
|
||||||
return terminalEntriesByWorkspaceId;
|
return terminalEntriesByWorkspaceId;
|
||||||
}
|
}
|
||||||
@@ -649,17 +656,44 @@ function groupAgentsByWorkspaceId(
|
|||||||
): Map<string, AgentSnapshotPayload[]> {
|
): Map<string, AgentSnapshotPayload[]> {
|
||||||
const byWorkspaceId = new Map<string, AgentSnapshotPayload[]>();
|
const byWorkspaceId = new Map<string, AgentSnapshotPayload[]>();
|
||||||
for (const agent of agents) {
|
for (const agent of agents) {
|
||||||
const workspaceId = resolveWorkspaceIdForRecord(agent, activeRecords);
|
const workspaceIds = resolveWorkspaceIdsForStatusContribution(agent, activeRecords);
|
||||||
if (workspaceId === null || !activeWorkspaceIds.has(workspaceId)) {
|
for (const workspaceId of workspaceIds) {
|
||||||
continue;
|
if (!activeWorkspaceIds.has(workspaceId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entries = byWorkspaceId.get(workspaceId) ?? [];
|
||||||
|
entries.push(agent);
|
||||||
|
byWorkspaceId.set(workspaceId, entries);
|
||||||
}
|
}
|
||||||
const entries = byWorkspaceId.get(workspaceId) ?? [];
|
|
||||||
entries.push(agent);
|
|
||||||
byWorkspaceId.set(workspaceId, entries);
|
|
||||||
}
|
}
|
||||||
return byWorkspaceId;
|
return byWorkspaceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveWorkspaceIdsForStatusContribution(
|
||||||
|
record: { workspaceId?: string; cwd: string },
|
||||||
|
activeWorkspaces: PersistedWorkspaceRecord[],
|
||||||
|
): string[] {
|
||||||
|
const owningWorkspaceId = resolveWorkspaceIdForRecord(record, activeWorkspaces);
|
||||||
|
if (!owningWorkspaceId) {
|
||||||
|
const recordCwd = resolve(record.cwd);
|
||||||
|
return activeWorkspaces
|
||||||
|
.filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === recordCwd)
|
||||||
|
.map((workspace) => workspace.workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const owningWorkspace = activeWorkspaces.find(
|
||||||
|
(workspace) => workspace.workspaceId === owningWorkspaceId && !workspace.archivedAt,
|
||||||
|
);
|
||||||
|
if (!owningWorkspace) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const owningCwd = resolve(owningWorkspace.cwd);
|
||||||
|
return activeWorkspaces
|
||||||
|
.filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === owningCwd)
|
||||||
|
.map((workspace) => workspace.workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveDelegationRootAgent(
|
function resolveDelegationRootAgent(
|
||||||
agent: AgentSnapshotPayload,
|
agent: AgentSnapshotPayload,
|
||||||
activeAgentsById: ReadonlyMap<string, AgentSnapshotPayload>,
|
activeAgentsById: ReadonlyMap<string, AgentSnapshotPayload>,
|
||||||
|
|||||||
@@ -175,6 +175,146 @@ describe("bootstrapWorkspaceRegistries", () => {
|
|||||||
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("ws-existing");
|
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("ws-existing");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("migrates cwd-only agents to the oldest existing same-cwd workspace", async () => {
|
||||||
|
await projectRegistry.initialize();
|
||||||
|
await workspaceRegistry.initialize();
|
||||||
|
await projectRegistry.upsert({
|
||||||
|
projectId: NON_GIT_PROJECT,
|
||||||
|
rootPath: NON_GIT_PROJECT,
|
||||||
|
kind: "non_git",
|
||||||
|
displayName: "non-git-project",
|
||||||
|
createdAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
await workspaceRegistry.upsert({
|
||||||
|
workspaceId: "ws-newer",
|
||||||
|
projectId: NON_GIT_PROJECT,
|
||||||
|
cwd: NON_GIT_PROJECT,
|
||||||
|
kind: "directory",
|
||||||
|
displayName: "newer",
|
||||||
|
createdAt: "2026-03-02T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
await workspaceRegistry.upsert({
|
||||||
|
workspaceId: "ws-older",
|
||||||
|
projectId: NON_GIT_PROJECT,
|
||||||
|
cwd: NON_GIT_PROJECT,
|
||||||
|
kind: "directory",
|
||||||
|
displayName: "older",
|
||||||
|
createdAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await agentStorage.initialize();
|
||||||
|
await agentStorage.upsert({
|
||||||
|
id: "legacy-agent",
|
||||||
|
provider: "codex",
|
||||||
|
cwd: NON_GIT_PROJECT,
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
lastActivityAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
title: null,
|
||||||
|
labels: {},
|
||||||
|
lastStatus: "idle",
|
||||||
|
lastModeId: null,
|
||||||
|
config: null,
|
||||||
|
runtimeInfo: { provider: "codex", sessionId: null },
|
||||||
|
persistence: null,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bootstrapWorkspaceRegistries({
|
||||||
|
paseoHome,
|
||||||
|
agentStorage,
|
||||||
|
projectRegistry,
|
||||||
|
workspaceRegistry,
|
||||||
|
workspaceGitService,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBe("ws-older");
|
||||||
|
expect(await workspaceRegistry.list()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("migrated legacy agents stay owned by the deterministic workspace when a same-cwd workspace is added later", async () => {
|
||||||
|
await projectRegistry.initialize();
|
||||||
|
await workspaceRegistry.initialize();
|
||||||
|
await projectRegistry.upsert({
|
||||||
|
projectId: NON_GIT_PROJECT,
|
||||||
|
rootPath: NON_GIT_PROJECT,
|
||||||
|
kind: "non_git",
|
||||||
|
displayName: "non-git-project",
|
||||||
|
createdAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
await workspaceRegistry.upsert({
|
||||||
|
workspaceId: "ws-original-owner",
|
||||||
|
projectId: NON_GIT_PROJECT,
|
||||||
|
cwd: NON_GIT_PROJECT,
|
||||||
|
kind: "directory",
|
||||||
|
displayName: "original",
|
||||||
|
createdAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await agentStorage.initialize();
|
||||||
|
await agentStorage.upsert({
|
||||||
|
id: "legacy-agent",
|
||||||
|
provider: "codex",
|
||||||
|
cwd: NON_GIT_PROJECT,
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
lastActivityAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
title: null,
|
||||||
|
labels: {},
|
||||||
|
lastStatus: "idle",
|
||||||
|
lastModeId: null,
|
||||||
|
config: null,
|
||||||
|
runtimeInfo: { provider: "codex", sessionId: null },
|
||||||
|
persistence: null,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bootstrapWorkspaceRegistries({
|
||||||
|
paseoHome,
|
||||||
|
agentStorage,
|
||||||
|
projectRegistry,
|
||||||
|
workspaceRegistry,
|
||||||
|
workspaceGitService,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
await workspaceRegistry.upsert({
|
||||||
|
workspaceId: "ws-created-later",
|
||||||
|
projectId: NON_GIT_PROJECT,
|
||||||
|
cwd: NON_GIT_PROJECT,
|
||||||
|
kind: "directory",
|
||||||
|
displayName: "created later",
|
||||||
|
createdAt: "2026-03-04T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-04T00:00:00.000Z",
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
await bootstrapWorkspaceRegistries({
|
||||||
|
paseoHome,
|
||||||
|
agentStorage,
|
||||||
|
projectRegistry,
|
||||||
|
workspaceRegistry,
|
||||||
|
workspaceGitService,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBe("ws-original-owner");
|
||||||
|
expect(await workspaceRegistry.get("ws-created-later")).toMatchObject({
|
||||||
|
cwd: NON_GIT_PROJECT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("preserves existing workspace IDs when only the projects file is missing", async () => {
|
test("preserves existing workspace IDs when only the projects file is missing", async () => {
|
||||||
await workspaceRegistry.initialize();
|
await workspaceRegistry.initialize();
|
||||||
await workspaceRegistry.upsert({
|
await workspaceRegistry.upsert({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
|
|||||||
import {
|
import {
|
||||||
createPersistedProjectRecord,
|
createPersistedProjectRecord,
|
||||||
createPersistedWorkspaceRecord,
|
createPersistedWorkspaceRecord,
|
||||||
|
type PersistedWorkspaceRecord,
|
||||||
type ProjectRegistry,
|
type ProjectRegistry,
|
||||||
type WorkspaceRegistry,
|
type WorkspaceRegistry,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
@@ -44,6 +45,71 @@ function resolveAgentUpdatedAt(record: StoredAgentRecord): string {
|
|||||||
return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString();
|
return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveLegacyWorkspaceOwnerForCwd(
|
||||||
|
cwd: string,
|
||||||
|
workspaces: Iterable<PersistedWorkspaceRecord>,
|
||||||
|
): string | null {
|
||||||
|
const normalizedCwd = path.resolve(cwd);
|
||||||
|
const activeWorkspaces = Array.from(workspaces).filter((workspace) => !workspace.archivedAt);
|
||||||
|
const exactMatches = activeWorkspaces.filter(
|
||||||
|
(workspace) => path.resolve(workspace.cwd) === normalizedCwd,
|
||||||
|
);
|
||||||
|
if (exactMatches.length > 0) {
|
||||||
|
return oldestWorkspace(exactMatches).workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixMatches = activeWorkspaces.filter((workspace) => {
|
||||||
|
const workspaceCwd = path.resolve(workspace.cwd);
|
||||||
|
return normalizedCwd === workspaceCwd || normalizedCwd.startsWith(`${workspaceCwd}${path.sep}`);
|
||||||
|
});
|
||||||
|
if (prefixMatches.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const longestPrefixLength = Math.max(
|
||||||
|
...prefixMatches.map((workspace) => path.resolve(workspace.cwd).length),
|
||||||
|
);
|
||||||
|
return oldestWorkspace(
|
||||||
|
prefixMatches.filter((workspace) => path.resolve(workspace.cwd).length === longestPrefixLength),
|
||||||
|
).workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateLegacyAgentWorkspaceOwnership(options: {
|
||||||
|
agentStorage: AgentStorage;
|
||||||
|
workspaceRegistry: WorkspaceRegistry;
|
||||||
|
logger: Logger;
|
||||||
|
cwds?: Iterable<string>;
|
||||||
|
}): Promise<number> {
|
||||||
|
const workspaceRecords = await options.workspaceRegistry.list();
|
||||||
|
const cwdFilter = options.cwds
|
||||||
|
? new Set(Array.from(options.cwds, (cwd) => path.resolve(cwd)))
|
||||||
|
: null;
|
||||||
|
const records = await options.agentStorage.list();
|
||||||
|
let migrated = 0;
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.workspaceId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (cwdFilter && !cwdFilter.has(path.resolve(record.cwd))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceId = resolveLegacyWorkspaceOwnerForCwd(record.cwd, workspaceRecords);
|
||||||
|
if (!workspaceId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await options.agentStorage.upsert({ ...record, workspaceId });
|
||||||
|
migrated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrated > 0) {
|
||||||
|
options.logger.info({ migrated }, "Migrated legacy agent workspace ownership");
|
||||||
|
}
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
export async function bootstrapWorkspaceRegistries(options: {
|
export async function bootstrapWorkspaceRegistries(options: {
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
agentStorage: AgentStorage;
|
agentStorage: AgentStorage;
|
||||||
@@ -60,6 +126,7 @@ export async function bootstrapWorkspaceRegistries(options: {
|
|||||||
await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]);
|
await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]);
|
||||||
|
|
||||||
if (projectsExists && workspacesExists) {
|
if (projectsExists && workspacesExists) {
|
||||||
|
await migrateLegacyAgentWorkspaceOwnership(options);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +235,8 @@ export async function bootstrapWorkspaceRegistries(options: {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await migrateLegacyAgentWorkspaceOwnership(options);
|
||||||
|
|
||||||
options.logger.info(
|
options.logger.info(
|
||||||
{
|
{
|
||||||
projectsFile: path.join(options.paseoHome, "projects", "projects.json"),
|
projectsFile: path.join(options.paseoHome, "projects", "projects.json"),
|
||||||
@@ -178,3 +247,9 @@ export async function bootstrapWorkspaceRegistries(options: {
|
|||||||
"Workspace registries bootstrapped from existing agent storage",
|
"Workspace registries bootstrapped from existing agent storage",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function oldestWorkspace(workspaces: PersistedWorkspaceRecord[]): PersistedWorkspaceRecord {
|
||||||
|
return workspaces.reduce((oldest, candidate) =>
|
||||||
|
candidate.createdAt < oldest.createdAt ? candidate : oldest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ describe("resolveWorkspaceIdForRecord", () => {
|
|||||||
expect(resolved).toBe("ws-only");
|
expect(resolved).toBe("ws-only");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolves a legacy record with multiple cwd matches to the deterministic oldest", () => {
|
test("does not resolve an unstamped legacy record with multiple cwd matches", () => {
|
||||||
const workspaces = [
|
const workspaces = [
|
||||||
createWorkspaceRecord("/tmp/repo", "ws-newer", { createdAt: "2026-03-02T00:00:00.000Z" }),
|
createWorkspaceRecord("/tmp/repo", "ws-newer", { createdAt: "2026-03-02T00:00:00.000Z" }),
|
||||||
createWorkspaceRecord("/tmp/repo", "ws-older", { createdAt: "2026-03-01T00:00:00.000Z" }),
|
createWorkspaceRecord("/tmp/repo", "ws-older", { createdAt: "2026-03-01T00:00:00.000Z" }),
|
||||||
@@ -267,7 +267,7 @@ describe("resolveWorkspaceIdForRecord", () => {
|
|||||||
|
|
||||||
const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces);
|
const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces);
|
||||||
|
|
||||||
expect(resolved).toBe("ws-older");
|
expect(resolved).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null for a legacy record with no cwd match", () => {
|
test("returns null for a legacy record with no cwd match", () => {
|
||||||
@@ -278,7 +278,7 @@ describe("resolveWorkspaceIdForRecord", () => {
|
|||||||
expect(resolved).toBeNull();
|
expect(resolved).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("falls back to cwd when the stamped workspaceId points at an archived workspace", () => {
|
test("does not move a stamped record to another workspace when its owner is archived", () => {
|
||||||
const workspaces = [
|
const workspaces = [
|
||||||
createWorkspaceRecord("/tmp/repo", "ws-archived", {
|
createWorkspaceRecord("/tmp/repo", "ws-archived", {
|
||||||
archivedAt: "2026-03-05T00:00:00.000Z",
|
archivedAt: "2026-03-05T00:00:00.000Z",
|
||||||
@@ -291,6 +291,6 @@ describe("resolveWorkspaceIdForRecord", () => {
|
|||||||
workspaces,
|
workspaces,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved).toBe("ws-live");
|
expect(resolved).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ export function generateWorkspaceId(): string {
|
|||||||
return `wks_${randomBytes(8).toString("hex")}`;
|
return `wks_${randomBytes(8).toString("hex")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// COMPAT(workspaceOwnership): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
// COMPAT(workspaceOwnership): added in v0.1.97, drop after 2026-12-15 once floor >= v0.1.97.
|
||||||
// Resolves the owning workspace for a record (agent/terminal) that may predate
|
// Resolves the owning workspace for a record (agent/terminal) that may predate
|
||||||
// workspaceId stamping. New records always carry workspaceId and hit the first
|
// workspaceId stamping. New records always carry workspaceId and hit the first
|
||||||
// branch; legacy records fall back to cwd matching. When several active
|
// branch; legacy records fall back only when cwd has a single active owner.
|
||||||
// workspaces share the same cwd, duplicate-cwd disambiguation is impossible from
|
// Duplicate-cwd records must be stamped by migration before runtime projection;
|
||||||
// cwd alone, so we pick the deterministic oldest one.
|
// otherwise cwd-only membership leaks into every same-directory workspace.
|
||||||
export function resolveWorkspaceIdForRecord(
|
export function resolveWorkspaceIdForRecord(
|
||||||
record: { workspaceId?: string; cwd: string },
|
record: { workspaceId?: string; cwd: string },
|
||||||
activeWorkspaces: Iterable<PersistedWorkspaceRecord>,
|
activeWorkspaces: Iterable<PersistedWorkspaceRecord>,
|
||||||
@@ -52,6 +52,7 @@ export function resolveWorkspaceIdForRecord(
|
|||||||
if (exact) {
|
if (exact) {
|
||||||
return exact.workspaceId;
|
return exact.workspaceId;
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedCwd = resolve(record.cwd);
|
const resolvedCwd = resolve(record.cwd);
|
||||||
@@ -61,11 +62,6 @@ export function resolveWorkspaceIdForRecord(
|
|||||||
if (cwdMatches.length === 1) {
|
if (cwdMatches.length === 1) {
|
||||||
return cwdMatches[0].workspaceId;
|
return cwdMatches[0].workspaceId;
|
||||||
}
|
}
|
||||||
if (cwdMatches.length > 1) {
|
|
||||||
return cwdMatches.reduce((oldest, candidate) =>
|
|
||||||
candidate.createdAt < oldest.createdAt ? candidate : oldest,
|
|
||||||
).workspaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import {
|
|||||||
const WORKSPACE_A = "wks_same_cwd_a";
|
const WORKSPACE_A = "wks_same_cwd_a";
|
||||||
const WORKSPACE_B = "wks_same_cwd_b";
|
const WORKSPACE_B = "wks_same_cwd_b";
|
||||||
|
|
||||||
// Seed two active workspaces that share one cwd, so we can prove Phase 1
|
// Seed two active workspaces that share one cwd, so we can prove ownership
|
||||||
// attributes agents, terminals, and status by workspaceId rather than by
|
// stays workspaceId-scoped while aggregate status is cwd-scoped. Both
|
||||||
// directory. Both registry files must exist on disk before the daemon starts:
|
// registry files must exist on disk before the daemon starts:
|
||||||
// bootstrapWorkspaceRegistries skips materialization when both files are
|
// bootstrapWorkspaceRegistries skips materialization when both files are
|
||||||
// present, leaving these seeded records untouched.
|
// present, leaving these seeded records untouched.
|
||||||
function seedSameCwdWorkspaces(): { paseoHomeRoot: string; cwd: string } {
|
function seedSameCwdWorkspaces(): { paseoHomeRoot: string; cwd: string } {
|
||||||
@@ -68,7 +68,7 @@ async function statusByWorkspaceId(client: DaemonClient): Promise<Map<string, st
|
|||||||
return new Map(workspaces.entries.map((entry) => [entry.id, entry.status]));
|
return new Map(workspaces.entries.map((entry) => [entry.id, entry.status]));
|
||||||
}
|
}
|
||||||
|
|
||||||
test("two workspaces sharing one cwd stay isolated by workspaceId", async () => {
|
test("two workspaces sharing one cwd share agent status without leaking ownership", async () => {
|
||||||
const { paseoHomeRoot, cwd } = seedSameCwdWorkspaces();
|
const { paseoHomeRoot, cwd } = seedSameCwdWorkspaces();
|
||||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot });
|
const daemon = await createTestPaseoDaemon({ paseoHomeRoot });
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
@@ -87,9 +87,10 @@ test("two workspaces sharing one cwd stay isolated by workspaceId", async () =>
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. Agent created in workspace A carries workspaceId A and only moves A's
|
// 1. Agent created in workspace A carries workspaceId A. Ask mode + a
|
||||||
// status. Ask mode + a write parks the agent on a pending permission,
|
// write parks the agent on a pending permission, which contributes the
|
||||||
// which is the deterministic "needs_input" signal for A.
|
// deterministic "needs_input" aggregate signal to all same-cwd
|
||||||
|
// workspaces.
|
||||||
const agentA = await client.createAgent({
|
const agentA = await client.createAgent({
|
||||||
...getAskModeConfig("codex"),
|
...getAskModeConfig("codex"),
|
||||||
cwd,
|
cwd,
|
||||||
@@ -111,7 +112,7 @@ test("two workspaces sharing one cwd stay isolated by workspaceId", async () =>
|
|||||||
expect(await statusByWorkspaceId(client)).toEqual(
|
expect(await statusByWorkspaceId(client)).toEqual(
|
||||||
new Map([
|
new Map([
|
||||||
[WORKSPACE_A, "needs_input"],
|
[WORKSPACE_A, "needs_input"],
|
||||||
[WORKSPACE_B, "done"],
|
[WORKSPACE_B, "needs_input"],
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -159,8 +160,8 @@ test("two workspaces sharing one cwd stay isolated by workspaceId", async () =>
|
|||||||
const listForB = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_B });
|
const listForB = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_B });
|
||||||
expect(listForB.terminals.some((terminal) => terminal.id === terminalId)).toBe(false);
|
expect(listForB.terminals.some((terminal) => terminal.id === terminalId)).toBe(false);
|
||||||
|
|
||||||
// 3. Agent created in workspace B carries workspaceId B and moves only B's
|
// 3. Agent created in workspace B carries workspaceId B while the aggregate
|
||||||
// status. A keeps its own pending-permission state independently.
|
// needs_input status stays shared across both same-cwd workspace rows.
|
||||||
const agentB = await client.createAgent({
|
const agentB = await client.createAgent({
|
||||||
...getAskModeConfig("codex"),
|
...getAskModeConfig("codex"),
|
||||||
cwd,
|
cwd,
|
||||||
|
|||||||
@@ -110,6 +110,20 @@ it("lists subdirectory terminals when querying the workspace root", async () =>
|
|||||||
expect(rootTerminals.map((terminal) => terminal.id)).toEqual([created.id]);
|
expect(rootTerminals.map((terminal) => terminal.id)).toEqual([created.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes only stamped terminals in workspace-scoped queries", async () => {
|
||||||
|
manager = createTerminalManager();
|
||||||
|
const cwd = realpathSync(tmpdir());
|
||||||
|
const legacy = await manager.createTerminal({ cwd });
|
||||||
|
const owned = await manager.createTerminal({ cwd, workspaceId: "ws-owned" });
|
||||||
|
const sibling = await manager.createTerminal({ cwd, workspaceId: "ws-sibling" });
|
||||||
|
|
||||||
|
const scoped = await manager.getTerminals(cwd, { workspaceId: "ws-owned" });
|
||||||
|
const unscoped = await manager.getTerminals(cwd);
|
||||||
|
|
||||||
|
expect(scoped.map((terminal) => terminal.id)).toEqual([owned.id]);
|
||||||
|
expect(unscoped.map((terminal) => terminal.id)).toEqual([legacy.id, owned.id, sibling.id]);
|
||||||
|
});
|
||||||
|
|
||||||
it("creates additional terminal with auto-incrementing name", async () => {
|
it("creates additional terminal with auto-incrementing name", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const cwd = realpathSync(tmpdir());
|
const cwd = realpathSync(tmpdir());
|
||||||
|
|||||||
@@ -296,14 +296,10 @@ export function createTerminalManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// When the query carries a workspaceId, two workspaces sharing a cwd must
|
// When the query carries a workspaceId, two workspaces sharing a cwd must
|
||||||
// not see each other's terminals. Exclude sessions owned by a different
|
// not see each other's terminals. A missing owner is not workspace
|
||||||
// workspace; keep sessions without an owner (COMPAT: created by clients
|
// membership; unscoped callers can still list those legacy terminals.
|
||||||
// that predate terminal workspace ownership).
|
|
||||||
if (options?.workspaceId !== undefined) {
|
if (options?.workspaceId !== undefined) {
|
||||||
return sessions.filter(
|
return sessions.filter((session) => session.workspaceId === options.workspaceId);
|
||||||
(session) =>
|
|
||||||
session.workspaceId === undefined || session.workspaceId === options.workspaceId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return sessions;
|
return sessions;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -566,6 +566,57 @@ it("lists terminals locally without waiting on the worker", async () => {
|
|||||||
expect(worker.sentMessages.some((message) => message.type === "getTerminals")).toBe(false);
|
expect(worker.sentMessages.some((message) => message.type === "getTerminals")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes only stamped terminals in workspace-scoped local reads", async () => {
|
||||||
|
const worker = new FakeTerminalWorker();
|
||||||
|
manager = createWorkerTerminalManager({
|
||||||
|
requestTimeoutMs: 5,
|
||||||
|
forkWorker: () => worker,
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.emitWorkerMessage({
|
||||||
|
type: "terminalCreated",
|
||||||
|
terminal: {
|
||||||
|
id: "terminal-legacy",
|
||||||
|
name: "Legacy",
|
||||||
|
cwd: "/workspace",
|
||||||
|
activity: null,
|
||||||
|
},
|
||||||
|
state: createTerminalState(),
|
||||||
|
});
|
||||||
|
worker.emitWorkerMessage({
|
||||||
|
type: "terminalCreated",
|
||||||
|
terminal: {
|
||||||
|
id: "terminal-owned",
|
||||||
|
name: "Owned",
|
||||||
|
cwd: "/workspace",
|
||||||
|
workspaceId: "ws-owned",
|
||||||
|
activity: null,
|
||||||
|
},
|
||||||
|
state: createTerminalState(),
|
||||||
|
});
|
||||||
|
worker.emitWorkerMessage({
|
||||||
|
type: "terminalCreated",
|
||||||
|
terminal: {
|
||||||
|
id: "terminal-sibling",
|
||||||
|
name: "Sibling",
|
||||||
|
cwd: "/workspace",
|
||||||
|
workspaceId: "ws-sibling",
|
||||||
|
activity: null,
|
||||||
|
},
|
||||||
|
state: createTerminalState(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const scoped = await manager.getTerminals("/workspace", { workspaceId: "ws-owned" });
|
||||||
|
const unscoped = await manager.getTerminals("/workspace");
|
||||||
|
|
||||||
|
expect(scoped.map((terminal) => terminal.id)).toEqual(["terminal-owned"]);
|
||||||
|
expect(unscoped.map((terminal) => terminal.id)).toEqual([
|
||||||
|
"terminal-legacy",
|
||||||
|
"terminal-owned",
|
||||||
|
"terminal-sibling",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects non-absolute cwd in getTerminals", async () => {
|
it("rejects non-absolute cwd in getTerminals", async () => {
|
||||||
const worker = new FakeTerminalWorker();
|
const worker = new FakeTerminalWorker();
|
||||||
manager = createWorkerTerminalManager({
|
manager = createWorkerTerminalManager({
|
||||||
|
|||||||
@@ -653,14 +653,10 @@ export function createWorkerTerminalManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// When the query carries a workspaceId, two workspaces sharing a cwd must
|
// When the query carries a workspaceId, two workspaces sharing a cwd must
|
||||||
// not see each other's terminals. Exclude sessions owned by a different
|
// not see each other's terminals. A missing owner is not workspace
|
||||||
// workspace; keep sessions without an owner (COMPAT: created by clients
|
// membership; unscoped callers can still list those legacy terminals.
|
||||||
// that predate terminal workspace ownership).
|
|
||||||
if (options?.workspaceId !== undefined) {
|
if (options?.workspaceId !== undefined) {
|
||||||
return sessions.filter(
|
return sessions.filter((session) => session.workspaceId === options.workspaceId);
|
||||||
(session) =>
|
|
||||||
session.workspaceId === undefined || session.workspaceId === options.workspaceId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return sessions;
|
return sessions;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user