mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d369c9d36b | ||
|
|
12890f8ab0 | ||
|
|
7c4aa058ff | ||
|
|
702bc942b7 | ||
|
|
b1b5010bd2 | ||
|
|
888b54bb44 | ||
|
|
63c3bb8c42 | ||
|
|
569b34263d | ||
|
|
71cc853f7b | ||
|
|
f2dfa83e83 | ||
|
|
fb4ca20dbd | ||
|
|
4e2aa97a74 | ||
|
|
5ab9b8dcd4 | ||
|
|
4284209025 | ||
|
|
6ae87ec7e2 | ||
|
|
630e8301ad | ||
|
|
74bb3e76e4 |
@@ -46,6 +46,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
|||||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||||
|
| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
|
||||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ Paseo is a client-server system for monitoring and controlling local AI coding a
|
|||||||
|
|
||||||
Your code never leaves your machine. Paseo is local-first.
|
Your code never leaves your machine. Paseo is local-first.
|
||||||
|
|
||||||
|
Daemon-owned outbound connections and routed API scope ownership are described in
|
||||||
|
[Connected hosts](connected-hosts.md).
|
||||||
|
|
||||||
## System overview
|
## System overview
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Connected hosts
|
||||||
|
|
||||||
|
A saved host belongs to the daemon that stores it. The app's saved connections remain a separate list.
|
||||||
|
Connect A to B through `paseo --host A host` or `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable
|
||||||
|
`registrationId` created before B is reachable.
|
||||||
|
|
||||||
|
## Ownership and authority
|
||||||
|
|
||||||
|
Credentials live in A's `hosts.json`, written atomically with mode `0600` on POSIX systems. Discovery returns a safe
|
||||||
|
target description. Replacement inputs never come back through a read API. Keep this file private
|
||||||
|
when collecting diagnostics or backing up daemon state.
|
||||||
|
|
||||||
|
The authenticated producer owns its source key. Repeating an unchanged add returns the saved entry,
|
||||||
|
including a user's disabled state. Changed input requires an explicit update. Display labels and
|
||||||
|
access can be changed by users with `tunnel.manage`; connection and metadata changes require the
|
||||||
|
producer or `access.manage`. Removing a producer-managed entry allows that producer to add it again.
|
||||||
|
Disabling retains the opt-out.
|
||||||
|
|
||||||
|
Each enabled registration owns one physical daemon link, shared by status monitoring and every
|
||||||
|
routed API scope. Each scope owns an isolated virtual session on B. A derives its identity from the
|
||||||
|
admitted origin session, producer, and logical SDK scope. B admits virtual sessions under the actual
|
||||||
|
physical link principal, then intersects forwarded caller grants with that link's current authority.
|
||||||
|
Changing or resuming a parent's grants closes its existing channels; reconnect re-admits demand with
|
||||||
|
current authority. A routed API cannot select a second hop, and saving a host grants no reverse access.
|
||||||
|
|
||||||
|
A producer that registers a host through a routed API is qualified on B as
|
||||||
|
`route:<B principal>:<A serverId>:<A producer>`, with each component percent-encoded. This namespace belongs to the authenticated link
|
||||||
|
principal. An ordinary direct B consumer does not become that producer by supplying a source key.
|
||||||
|
The optional `server_info.sourceId` reports this producer identity separately from the authenticated admission principal.
|
||||||
|
Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature.
|
||||||
|
|
||||||
|
A managed child has a qualified `{serverId, agentId}` address on every host. The parent's daemon owns membership and lifecycle intent; a destination's parent pointer is metadata. Workspace comparisons include the server ID, so matching workspace strings on different hosts do not share lifecycle ownership. Preserve the archive policy in [agent lifecycle](agent-lifecycle.md). The first child snapshot carries the owner's ledger projection even while creation is pending, because tab placement runs before creation finishes. Explicit clearing survives list and disk-cache round trips; a parent pointer or label cannot recreate membership on a capable daemon.
|
||||||
|
|
||||||
|
Retained app conversations bind their stores, queries, initialization and layout to origin authority as well as destination. Two routes to B therefore keep separate cached evidence and command capabilities. Provider catalog pushes belong to that same destination binding, including facts shown in the parent's track. Route restore remembers an origin ID, then reacquires current authority; persisted namespaces grant no access.
|
||||||
|
|
||||||
|
Durable relationships retain a stable authority reference and creation ceiling, never live credentials or execution capabilities. Recovery must resolve current authority through its owner. Derived runtime sessions cannot authorize their own recovery. Execution-scoped admissions and non-owner durable references without a supported authority resolver fail explicitly; do not recover them through the daemon owner's MCP runtime.
|
||||||
|
|
||||||
|
Before accepting new writes, the daemon adopts eligible pre-feature local parent labels into authoritative membership. The upgrade records its bounded candidates before qualifying agent metadata, then resumes those candidates after interruption. It never rescans later label edits. Existing membership, including forgotten entries, takes precedence; explicit null or qualified parent pointers do not establish legacy provenance. Adopted entries preserve the old daemon-owned local lifecycle, not an authenticated creator grant, and contain no creation recipe to replay. Recovery still resolves current owner authority within the retained self-only ceiling.
|
||||||
|
|
||||||
|
Provider-owned subagents are descriptors and timelines in `ProviderSubagentStore`, not `AgentStorage` records. They retain their provider observation/cancellation path. Internal managed agents and execution-owned records are separate stored/runtime concepts and are excluded from adoption on both sides of the relationship.
|
||||||
|
|
||||||
|
## Connections and recovery
|
||||||
|
|
||||||
|
The shared Node connector owns the target grammar and each connection attempt, including a fresh
|
||||||
|
SSH process. SSH reaches an already running daemon. The shared client connection lifecycle owns
|
||||||
|
physical reconnect and keepalive; the registry retains configuration, identity acceptance, and recovery
|
||||||
|
status. Unreachable registrations remain saved. Identity is pinned when discovered. Self and duplicate
|
||||||
|
registrations remain inspectable; a changed destination requires explicit identity replacement.
|
||||||
|
|
||||||
|
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
||||||
|
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
||||||
|
operations; requesting this feature gives update guidance.
|
||||||
|
|
||||||
|
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
||||||
|
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
||||||
|
through the usual owned-subscription contract. The ordinary client queues new requests while connecting
|
||||||
|
within its existing deadline and rejects requests while disconnected. Connection loss clears old
|
||||||
|
waiters and queued sends. Submitted requests with no response fail with `DaemonConnectionError`
|
||||||
|
(`DAEMON_CONNECTION_LOST`), optional `outcome: "unknown"` and known `serverId`. Inspect the destination
|
||||||
|
before retrying a mutation.
|
||||||
|
Ordinary non-recoverable mutations are not automatically replayed. Existing acknowledged/idempotent
|
||||||
|
creation recovery retains its request identity and resumes through the ordinary creation lifecycle. Multiplexing changes only the transport: ordinary permission
|
||||||
|
RPC codes pass through unchanged, and connection/recovery behavior matches direct clients.
|
||||||
|
|
||||||
|
Creation cancellation fences a reserved creation identity before admission. An admitted receipt can precede resource readiness and cannot be reported as canceled. Observe its current receipt and later changes through the ordinary SDK instead of replaying an uncertain prompt. Parent archive retains a pending obligation until cancellation or destination lifecycle confirmation; explicit forget removes local membership without claiming the destination changed.
|
||||||
|
|
||||||
|
Run journals retain submission and provider-turn identity. Restarted nonterminal evidence is interrupted, and subsequent evidence must match that turn. Idle and reconnect do not prove completion. A storage failure retains observed evidence with pending durability; a later read retries persistence. Notification recovery must preserve checkpoint identity and this durability distinction.
|
||||||
|
|
||||||
|
Text session envelopes carry a channel identifier. Binary routing adds an opcode, a bounded UTF-8
|
||||||
|
channel identifier, and the original terminal or file-transfer bytes. It never converts those bytes to
|
||||||
|
base64 JSON. The existing direct binary opcodes and 100 MiB message limit remain unchanged; routed
|
||||||
|
payloads reserve space for the routing header inside that limit. Relay encryption retains its own
|
||||||
|
existing frame limit.
|
||||||
|
|
||||||
|
Host links cap saved registrations, simultaneous channels, handshake lifetime, frames, physical
|
||||||
|
output, and aggregate queued bytes. Capacity checks use the actual escaped outer JSON or binary
|
||||||
|
frame size. Awaitable output completes when the physical send completes. An overflowing channel
|
||||||
|
closes without closing siblings or the saved link. Routed sessions release resources on disconnect;
|
||||||
|
subscription demand is rebuilt by its owning client on reconnect.
|
||||||
|
|
||||||
|
See the [SDK reference](../public-docs/sdk/reference.md#clienthosts),
|
||||||
|
[CLI reference](../public-docs/cli.md#connected-hosts), and
|
||||||
|
[plugin reference](../public-docs/plugins/reference.md#connected-hosts).
|
||||||
@@ -31,6 +31,10 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
|
|||||||
|
|
||||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||||
|
|
||||||
|
Connected-host management uses `tunnel.manage`; source-owned connection and metadata replacement
|
||||||
|
requires the producer or `access.manage`. Routed operations retain both caller and link authority,
|
||||||
|
see [Connected hosts](connected-hosts.md#ownership-and-authority).
|
||||||
|
|
||||||
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
|
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
|
||||||
|
|
||||||
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
|
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
|
||||||
|
|||||||
Generated
+1
@@ -38178,6 +38178,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.12.0",
|
"@getpaseo/protocol": "0.12.0",
|
||||||
"@getpaseo/relay": "0.12.0",
|
"@getpaseo/relay": "0.12.0",
|
||||||
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -854,7 +854,8 @@ async function revealCompactChanges(page: Page) {
|
|||||||
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
|
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
}
|
}
|
||||||
async function expectReviewEditorBody(page: Page, body: string) {
|
async function expectReviewEditorBody(page: Page, body: string) {
|
||||||
await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue(
|
const editor = page.getByRole("textbox", { name: "Review comment", exact: true });
|
||||||
body,
|
// A viewport resize starts the presentation handoff; exactly one editor must remain accessible.
|
||||||
);
|
await expect(editor).toHaveCount(1);
|
||||||
|
await expect(editor).toHaveValue(body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { metroTest as test, expect } from "../support/fixtures";
|
||||||
|
import { installDeveloperToastDismissal } from "../support/developer-ui.mjs";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
async function showTransientDeveloperToast(page: Page, shadow: boolean) {
|
||||||
|
await page.evaluate((useShadow) => {
|
||||||
|
const toast = document.createElement("div");
|
||||||
|
toast.id = "error-toast";
|
||||||
|
document.body.append(toast);
|
||||||
|
const root = useShadow ? toast.attachShadow({ mode: "open" }) : toast;
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.style.cssText = "width:32px;height:32px";
|
||||||
|
button.addEventListener("click", () => button.remove());
|
||||||
|
root.append(button);
|
||||||
|
}, shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTransientDeveloperToast(page: Page) {
|
||||||
|
await page.setContent("<button>Continue</button>");
|
||||||
|
await showTransientDeveloperToast(page, false);
|
||||||
|
await installDeveloperToastDismissal(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateWithDeveloperToastDismissal(page: Page) {
|
||||||
|
await installDeveloperToastDismissal(page);
|
||||||
|
await page.goto("data:text/html,<button>Continue</button>");
|
||||||
|
await showTransientDeveloperToast(page, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectLayoutWhileToastDisappears(page: Page) {
|
||||||
|
const action = page.getByRole("button", { name: "Continue", exact: true });
|
||||||
|
const boxes = await Promise.all(Array.from({ length: 4 }, () => action.boundingBox()));
|
||||||
|
for (const box of boxes) expect(box).not.toBeNull();
|
||||||
|
await expect(page.locator("#error-toast").getByRole("button")).toHaveCount(0);
|
||||||
|
await action.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a disappearing developer toast does not stall concurrent layout checks", async ({ page }) => {
|
||||||
|
test.setTimeout(10000);
|
||||||
|
await openTransientDeveloperToast(page);
|
||||||
|
await inspectLayoutWhileToastDisappears(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("developer toast dismissal survives navigation and observes Expo shadow-root updates", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(10000);
|
||||||
|
await navigateWithDeveloperToastDismissal(page);
|
||||||
|
await inspectLayoutWhileToastDisappears(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const root = document.getElementById("error-toast")!.shadowRoot!;
|
||||||
|
const replacement = document.createElement("button");
|
||||||
|
replacement.style.cssText = "width:32px;height:32px";
|
||||||
|
replacement.addEventListener("click", () => replacement.remove());
|
||||||
|
root.append(replacement);
|
||||||
|
});
|
||||||
|
await inspectLayoutWhileToastDisappears(page);
|
||||||
|
});
|
||||||
@@ -1,3 +1,16 @@
|
|||||||
|
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||||
|
import { submitMessage } from "../support/helpers/composer";
|
||||||
|
import { openSessions, clickSessionRow } from "../support/helpers/archive-tab";
|
||||||
|
import {
|
||||||
|
seedFleetSubagents,
|
||||||
|
delegateFleetThroughMcp,
|
||||||
|
observeFleetRouteOwnership,
|
||||||
|
readFleetCompletion,
|
||||||
|
connectFleetDirectHost,
|
||||||
|
openFleetParentTrack,
|
||||||
|
readFleetAuthorityState,
|
||||||
|
expectFleetRouteUnavailable,
|
||||||
|
} from "../support/helpers/subagents";
|
||||||
import { writeFile } from "node:fs/promises";
|
import { writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
@@ -6,6 +19,8 @@ import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-
|
|||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
|
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
|
||||||
|
|
||||||
|
test.use({ e2eInjectPaseoTools: true });
|
||||||
|
|
||||||
const SETTINGS_KEY = "@paseo:app-settings";
|
const SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
async function openParentInRightPane(page: Page, parentId: string, workspaceId: string) {
|
async function openParentInRightPane(page: Page, parentId: string, workspaceId: string) {
|
||||||
@@ -147,3 +162,167 @@ test("a preferred chat file link still opens beside its right-pane agent", async
|
|||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a fleet child retains its origin beside direct access through collision, catalog push, reload and revocation", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
const fleet = await seedFleetSubagents();
|
||||||
|
try {
|
||||||
|
const a = fleet.parent.serverId;
|
||||||
|
const b = fleet.destination.serverId;
|
||||||
|
await connectFleetDirectHost(page, fleet);
|
||||||
|
await openFleetParentTrack(page);
|
||||||
|
const rows = page.getByTestId(`subagents-track-row-${fleet.remote.id}`);
|
||||||
|
await expect(rows).toHaveCount(2);
|
||||||
|
const remote = rows.filter({ hasText: "Fleet child on B" });
|
||||||
|
await expect(remote).toBeVisible();
|
||||||
|
await expect(remote.getByTestId(`subagents-track-row-facts-${fleet.remote.id}`)).toHaveText(
|
||||||
|
"Ten second stream · Low · Load test",
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.status)
|
||||||
|
.toBe("online");
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.catalogVersion)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
const beforePush = await readFleetAuthorityState(page, a, b);
|
||||||
|
await fleet.direct.providers.refresh({ cwd: fleet.workspace.repoPath });
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.catalogVersion)
|
||||||
|
.toBeGreaterThan(beforePush.routed.catalogVersion);
|
||||||
|
await remote.click();
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
(url) => url.pathname.includes(`/h/${b}/workspace/`) && url.searchParams.get("origin") === a,
|
||||||
|
);
|
||||||
|
await submitMessage(page, "ROUTED_FLEET_PROMPT");
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await fleet.remote.refresh())?.agent.lastUserMessageAt)
|
||||||
|
.not.toBeNull();
|
||||||
|
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
|
||||||
|
const routedUrl = page.url();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
|
||||||
|
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||||
|
await fleet.remote.waitForFinish();
|
||||||
|
// Reopen direct B through ordinary history, then retain it beside the routed conversation.
|
||||||
|
await openSessions(page);
|
||||||
|
await clickSessionRow(page, "Fleet child on B");
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
(url) => url.pathname.includes(`/h/${b}/workspace/`) && !url.searchParams.has("origin"),
|
||||||
|
);
|
||||||
|
await openFleetParentTrack(page);
|
||||||
|
await page
|
||||||
|
.getByTestId(`subagents-track-row-${fleet.remote.id}`)
|
||||||
|
.filter({ hasText: "Fleet child on B" })
|
||||||
|
.click();
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.status)
|
||||||
|
.toBe("online");
|
||||||
|
await fleet.disable();
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.status)
|
||||||
|
.not.toBe("online");
|
||||||
|
const withdrawn = await readFleetAuthorityState(page, a, b);
|
||||||
|
expect(withdrawn.direct.status).toBe("online");
|
||||||
|
await fleet.direct.providers.refresh({ cwd: fleet.workspace.repoPath });
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.catalogVersion)
|
||||||
|
.toBeGreaterThan(withdrawn.direct.catalogVersion);
|
||||||
|
expect((await readFleetAuthorityState(page, a, b)).routed.catalogVersion).toBe(
|
||||||
|
withdrawn.routed.catalogVersion,
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
|
||||||
|
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
|
||||||
|
// Direct B remains a separate usable authority; its newer evidence cannot refill A's withdrawn scope.
|
||||||
|
await page.goto(buildHostAgentDetailRoute(b, fleet.remote.id, fleet.workspace.workspaceId));
|
||||||
|
await submitMessage(page, "DIRECT_ONLY_AFTER_REVOCATION");
|
||||||
|
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toBeVisible();
|
||||||
|
await page.goto(routedUrl);
|
||||||
|
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
|
||||||
|
await expectFleetRouteUnavailable(page, a, b);
|
||||||
|
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toHaveCount(0);
|
||||||
|
const beforeRecovery = (await fleet.direct.agents.ref(fleet.remote.id).refresh())?.agent
|
||||||
|
.lastUserMessageAt;
|
||||||
|
await fleet.enable();
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.status)
|
||||||
|
.toBe("online");
|
||||||
|
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
|
||||||
|
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toBeVisible();
|
||||||
|
await submitMessage(page, "AFTER_REENABLE_THROUGH_A");
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await fleet.direct.agents.ref(fleet.remote.id).refresh())?.agent.lastUserMessageAt,
|
||||||
|
)
|
||||||
|
.not.toBe(beforeRecovery);
|
||||||
|
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await fleet.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an agent delegates through MCP to self and B, opens both ordinary conversations and receives correlated completion", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
const counts = observeFleetRouteOwnership(page);
|
||||||
|
const fleet = await delegateFleetThroughMcp();
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: fleet.workspace.workspaceId,
|
||||||
|
agentId: fleet.parent.id,
|
||||||
|
});
|
||||||
|
console.log("Fleet ordinary parent ownership", await counts());
|
||||||
|
await openSubagentsTrack(page);
|
||||||
|
await expect(page.getByTestId(`subagents-track-row-${fleet.local.id}`)).toContainText(
|
||||||
|
"MCP child on A",
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.id}`)).toContainText(
|
||||||
|
"MCP child on B",
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.id}`)).toContainText(
|
||||||
|
"Fleet B",
|
||||||
|
);
|
||||||
|
await page.getByTestId(`subagents-track-row-${fleet.local.id}`).click();
|
||||||
|
await expect(page).toHaveURL((url) =>
|
||||||
|
url.pathname.includes(`/h/${fleet.local.serverId}/workspace/`),
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||||
|
await fleet.expectCompletion(fleet.local);
|
||||||
|
await submitMessage(page, "UI_MCP_FLEET_LOCAL");
|
||||||
|
await expect(page.getByText("UI_MCP_FLEET_LOCAL", { exact: true })).toBeVisible();
|
||||||
|
expect(
|
||||||
|
(await fleet.direct.agents.ref(fleet.local.id).refresh())?.agent.lastUserMessageAt,
|
||||||
|
).toBeNull();
|
||||||
|
await page.getByTestId(`workspace-tab-agent_${fleet.parent.id}`).click();
|
||||||
|
await openSubagentsTrack(page);
|
||||||
|
await page.getByTestId(`subagents-track-row-${fleet.remote.id}`).click();
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
(url) =>
|
||||||
|
url.pathname.includes(`/h/${fleet.remote.serverId}/workspace/`) &&
|
||||||
|
url.searchParams.get("origin") === fleet.parent.serverId,
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||||
|
console.log("Fleet destination conversation ownership", await counts());
|
||||||
|
await fleet.expectCompletion(fleet.remote);
|
||||||
|
await submitMessage(page, "UI_MCP_FLEET_REMOTE");
|
||||||
|
await expect(page.getByText("UI_MCP_FLEET_REMOTE", { exact: true })).toBeVisible();
|
||||||
|
expect(
|
||||||
|
(await fleet.origin.agents.ref(fleet.remote.id).refresh())?.agent.lastUserMessageAt,
|
||||||
|
).toBeNull();
|
||||||
|
await fleet.expectCompletion(fleet.local);
|
||||||
|
await fleet.expectCompletion(fleet.remote);
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: fleet.workspace.workspaceId,
|
||||||
|
agentId: fleet.parent.id,
|
||||||
|
});
|
||||||
|
await readFleetCompletion(page, fleet.local);
|
||||||
|
await readFleetCompletion(page, fleet.remote);
|
||||||
|
} finally {
|
||||||
|
await fleet.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
/** @param {import('@playwright/test').Page} page */
|
/** @param {import('@playwright/test').Page} page */
|
||||||
export async function installDeveloperToastDismissal(page) {
|
export async function installDeveloperToastDismissal(page) {
|
||||||
// Developer chrome must not cover app actions or move the test's pointer/focus.
|
// Keep developer chrome out of app actions without pointer/focus changes.
|
||||||
// Console errors remain in the runner's logs.
|
// Locator handlers can deadlock when concurrent checks dismiss the same toast.
|
||||||
const dismiss = page.locator("#error-toast").getByRole("button", { name: "", exact: true });
|
const install = () => {
|
||||||
await page.addLocatorHandler(dismiss, () => dismiss.dispatchEvent("click"), {
|
let observedRoot;
|
||||||
// A new logged error can replace the toast before the old one disappears.
|
const options = { childList: true, subtree: true };
|
||||||
noWaitAfter: true,
|
const dismiss = () => {
|
||||||
});
|
const toast = document.getElementById("error-toast");
|
||||||
|
const root = toast?.shadowRoot ?? toast;
|
||||||
|
if (root && root !== observedRoot) {
|
||||||
|
observer.disconnect();
|
||||||
|
observer.observe(document, options);
|
||||||
|
observer.observe(root, options);
|
||||||
|
observedRoot = root;
|
||||||
|
}
|
||||||
|
// Expo's toast root contains dismiss buttons; its overlay has a different ID.
|
||||||
|
for (const button of root?.querySelectorAll("button") ?? [])
|
||||||
|
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
};
|
||||||
|
const observer = new MutationObserver(dismiss);
|
||||||
|
observer.observe(document, options);
|
||||||
|
dismiss();
|
||||||
|
};
|
||||||
|
await page.addInitScript(install);
|
||||||
|
await page.evaluate(install);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { inspectSessionWireRequest } from "./session-wire";
|
||||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||||
import { daemonWsRoutePattern } from "./daemon-port";
|
import { daemonWsRoutePattern } from "./daemon-port";
|
||||||
|
|
||||||
@@ -16,11 +17,7 @@ interface SendAgentMessageRequest {
|
|||||||
function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null {
|
function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null {
|
||||||
if (typeof message !== "string") return null;
|
if (typeof message !== "string") return null;
|
||||||
try {
|
try {
|
||||||
const envelope = JSON.parse(message) as {
|
const request = inspectSessionWireRequest(message)?.request;
|
||||||
type?: unknown;
|
|
||||||
message?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
const request = envelope.type === "session" ? envelope.message : null;
|
|
||||||
if (
|
if (
|
||||||
request?.type !== "send_agent_message_request" ||
|
request?.type !== "send_agent_message_request" ||
|
||||||
typeof request.requestId !== "string" ||
|
typeof request.requestId !== "string" ||
|
||||||
|
|||||||
@@ -74,3 +74,12 @@ export async function loadProtocolSchemas(): Promise<typeof import("@getpaseo/pr
|
|||||||
).href;
|
).href;
|
||||||
return import(moduleUrl);
|
return import(moduleUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadPaseoApiFactory(): Promise<
|
||||||
|
(typeof import("@getpaseo/client"))["createPaseoApi"]
|
||||||
|
> {
|
||||||
|
const moduleUrl = pathToFileURL(
|
||||||
|
path.resolve(__dirname, "../../../../../packages/client/dist/index.js"),
|
||||||
|
).href;
|
||||||
|
return (await import(moduleUrl)).createPaseoApi;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { inspectSessionWireRequest } from "./session-wire";
|
||||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||||
import { daemonWsRoutePattern } from "./daemon-port";
|
import { daemonWsRoutePattern } from "./daemon-port";
|
||||||
|
|
||||||
@@ -71,12 +72,11 @@ function readSessionMessage(message: string | Buffer): ClientRequest | null {
|
|||||||
|
|
||||||
function readClientRequest(message: string | Buffer): ClientRequest | null {
|
function readClientRequest(message: string | Buffer): ClientRequest | null {
|
||||||
if (typeof message !== "string") return null;
|
if (typeof message !== "string") return null;
|
||||||
|
const session = inspectSessionWireRequest(message);
|
||||||
|
if (session) return session.request;
|
||||||
try {
|
try {
|
||||||
const envelope = JSON.parse(message) as {
|
const envelope = JSON.parse(message) as ClientRequest;
|
||||||
type?: unknown;
|
return envelope.type === "session" ? null : envelope;
|
||||||
message?: ClientRequest;
|
|
||||||
};
|
|
||||||
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/** Inspect an ordinary session request while preserving any owning transport envelopes. */
|
||||||
|
export function inspectSessionWireRequest(message: string | Buffer): {
|
||||||
|
request: Record<string, unknown>;
|
||||||
|
reply(response: unknown): string;
|
||||||
|
} | null {
|
||||||
|
const routes: string[] = [];
|
||||||
|
let raw = typeof message === "string" ? message : message.toString("utf8");
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const envelope = JSON.parse(raw);
|
||||||
|
if (envelope?.type !== "session" || !envelope.message || typeof envelope.message !== "object")
|
||||||
|
return null;
|
||||||
|
const request: Record<string, unknown> = envelope.message;
|
||||||
|
if (request.type !== "hosts.route.frame.request") {
|
||||||
|
return {
|
||||||
|
request,
|
||||||
|
reply(response) {
|
||||||
|
let data = JSON.stringify({ type: "session", message: response });
|
||||||
|
for (const subscriptionId of routes.toReversed()) {
|
||||||
|
data = JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "hosts.route.frame",
|
||||||
|
payload: { subscriptionId, data, binary: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof request.routeId !== "string" ||
|
||||||
|
typeof request.data !== "string" ||
|
||||||
|
request.binary !== false
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
routes.push(request.routeId);
|
||||||
|
raw = request.data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,15 @@
|
|||||||
|
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
|
import { addConnectedHostAndReload } from "./hosts";
|
||||||
|
import { buildSeededHost } from "./daemon-registry";
|
||||||
|
import { openAgentRoute } from "./mock-agent";
|
||||||
|
import { openSessions, clickSessionRow } from "./archive-tab";
|
||||||
|
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import { connectDaemonClient, loadPaseoApiFactory } from "./daemon-client-loader";
|
||||||
|
import { startIsolatedHostDaemon } from "./isolated-host-daemon";
|
||||||
|
import { seedWorkspace } from "./seed-client";
|
||||||
|
import { inspectSessionWireRequest } from "./session-wire";
|
||||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { daemonWsRoutePattern } from "./daemon-port";
|
import { daemonWsRoutePattern } from "./daemon-port";
|
||||||
@@ -251,22 +263,19 @@ export async function rejectNextManagedSubagentArchiveRequest(
|
|||||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
const server = ws.connectToServer();
|
const server = ws.connectToServer();
|
||||||
ws.onMessage((message) => {
|
ws.onMessage((message) => {
|
||||||
const requestId = archiveRequestId(message, subagentId);
|
const intercepted = archiveRequest(message, subagentId);
|
||||||
if (requestId && !rejected) {
|
if (intercepted && !rejected) {
|
||||||
rejected = true;
|
rejected = true;
|
||||||
// A daemon-rejected archive RPC: the request never reaches the daemon, and its normal
|
// A daemon-rejected archive RPC: the request never reaches the daemon, and its normal
|
||||||
// correlated error travels back through the real browser client and mutation rollback.
|
// correlated error travels back through the real browser client and mutation rollback.
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
intercepted.reply({
|
||||||
type: "session",
|
type: "rpc_error",
|
||||||
message: {
|
payload: {
|
||||||
type: "rpc_error",
|
requestId: intercepted.request.requestId,
|
||||||
payload: {
|
requestType: "archive_agent_request",
|
||||||
requestId,
|
error: "Archive rejected for test",
|
||||||
requestType: "archive_agent_request",
|
code: "archive_rejected",
|
||||||
error: "Archive rejected for test",
|
|
||||||
code: "archive_rejected",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -282,28 +291,18 @@ export async function rejectNextManagedSubagentArchiveRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean {
|
function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean {
|
||||||
return archiveRequestId(message, subagentId) !== null;
|
return archiveRequest(message, subagentId) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function archiveRequestId(message: WebSocketMessage, subagentId: string): string | null {
|
function archiveRequest(message: WebSocketMessage, subagentId: string) {
|
||||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
const inspected = inspectSessionWireRequest(message);
|
||||||
try {
|
if (
|
||||||
const envelope = JSON.parse(rawMessage) as {
|
inspected?.request.type === "archive_agent_request" &&
|
||||||
type?: unknown;
|
inspected.request.agentId === subagentId &&
|
||||||
message?: { type?: unknown; agentId?: unknown; requestId?: unknown };
|
typeof inspected.request.requestId === "string"
|
||||||
};
|
)
|
||||||
if (
|
return inspected;
|
||||||
envelope.type === "session" &&
|
return null;
|
||||||
envelope.message?.type === "archive_agent_request" &&
|
|
||||||
envelope.message.agentId === subagentId &&
|
|
||||||
typeof envelope.message.requestId === "string"
|
|
||||||
) {
|
|
||||||
return envelope.message.requestId;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
|
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
|
||||||
@@ -320,3 +319,354 @@ export async function detachSubagentFromTrack(page: Page, childId: string): Prom
|
|||||||
await expect(detachButton).toBeVisible({ timeout: 30_000 });
|
await expect(detachButton).toBeVisible({ timeout: 30_000 });
|
||||||
await detachButton.click();
|
await detachButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Real equal-ID children in equal-ID workspaces, with both route authorities independently live. */
|
||||||
|
export async function seedFleetSubagents() {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "fleet-track-" });
|
||||||
|
const destination = await startIsolatedHostDaemon(`fleet_${randomUUID()}`);
|
||||||
|
const clients: DaemonClient[] = [];
|
||||||
|
const apis: ReturnType<Awaited<ReturnType<typeof loadPaseoApiFactory>>>[] = [];
|
||||||
|
const close = async () => {
|
||||||
|
await Promise.allSettled(apis.map((api) => api.dispose()));
|
||||||
|
await Promise.allSettled(clients.map((client) => client.close()));
|
||||||
|
await destination.close();
|
||||||
|
await workspace.cleanup();
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const createApi = await loadPaseoApiFactory();
|
||||||
|
const aClient = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "fleet-origin" });
|
||||||
|
clients.push(aClient);
|
||||||
|
const bClient = await connectDaemonClient<DaemonClient>({
|
||||||
|
clientIdPrefix: "fleet-destination",
|
||||||
|
port: destination.port,
|
||||||
|
});
|
||||||
|
clients.push(bClient);
|
||||||
|
const origin = createApi(aClient);
|
||||||
|
const direct = createApi(bClient);
|
||||||
|
apis.push(origin, direct);
|
||||||
|
const registered = await origin.hosts.add({ connection: `127.0.0.1:${destination.port}` });
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await origin.hosts.list()).hosts.find(
|
||||||
|
(host) => host.registrationId === registered.registrationId,
|
||||||
|
)?.status,
|
||||||
|
)
|
||||||
|
.toBe("online");
|
||||||
|
const destinationWorkspace = await direct.workspaces.create({
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
source: { kind: "directory", path: workspace.repoPath },
|
||||||
|
});
|
||||||
|
expect(destinationWorkspace.id).toBe(workspace.workspaceId);
|
||||||
|
const config = { provider: "mock/ten-second-stream", modeId: "load-test" };
|
||||||
|
const parent = await origin.workspaces
|
||||||
|
.ref(workspace.workspaceId)
|
||||||
|
.agents.create({ title: "Fleet parent", config });
|
||||||
|
const agentId = randomUUID();
|
||||||
|
const local = await parent.subagents.create({ agentId, title: "Fleet child on A", config });
|
||||||
|
const remote = await parent.subagents.create({
|
||||||
|
agentId,
|
||||||
|
serverId: destination.serverId,
|
||||||
|
workspaceId: destinationWorkspace.id,
|
||||||
|
title: "Fleet child on B",
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
workspace,
|
||||||
|
destination,
|
||||||
|
origin,
|
||||||
|
direct,
|
||||||
|
parent,
|
||||||
|
local,
|
||||||
|
remote,
|
||||||
|
disable: () => origin.hosts.update(registered.registrationId, { enabled: false }),
|
||||||
|
enable: () => origin.hosts.update(registered.registrationId, { enabled: true }),
|
||||||
|
close,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectFleetDirectHost(
|
||||||
|
page: Page,
|
||||||
|
fleet: Awaited<ReturnType<typeof seedFleetSubagents>>,
|
||||||
|
) {
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: fleet.workspace.workspaceId,
|
||||||
|
agentId: fleet.parent.id,
|
||||||
|
});
|
||||||
|
await addConnectedHostAndReload(page, {
|
||||||
|
serverId: fleet.destination.serverId,
|
||||||
|
port: fleet.destination.port,
|
||||||
|
label: "Fleet B",
|
||||||
|
});
|
||||||
|
await page.evaluate(
|
||||||
|
(host) => localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([host])),
|
||||||
|
buildSeededHost({
|
||||||
|
serverId: fleet.destination.serverId,
|
||||||
|
endpoint: `127.0.0.1:${fleet.destination.port}`,
|
||||||
|
nowIso: new Date().toISOString(),
|
||||||
|
label: "Fleet B",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.goto(
|
||||||
|
buildHostAgentDetailRoute(
|
||||||
|
fleet.destination.serverId,
|
||||||
|
fleet.remote.id,
|
||||||
|
fleet.workspace.workspaceId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openFleetParentTrack(page: Page) {
|
||||||
|
await openSessions(page);
|
||||||
|
await clickSessionRow(page, "Fleet parent");
|
||||||
|
await openSubagentsTrack(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readFleetAuthorityState(
|
||||||
|
page: Page,
|
||||||
|
originServerId: string,
|
||||||
|
serverId: string,
|
||||||
|
) {
|
||||||
|
return page.evaluate(
|
||||||
|
({ originServerId: sourceId, serverId: destinationId }) => {
|
||||||
|
const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as HostRuntimeStore;
|
||||||
|
const read = (origin: string) => {
|
||||||
|
const binding = runtime
|
||||||
|
.getAuthorityBindings()
|
||||||
|
.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.reference.originServerId === origin &&
|
||||||
|
candidate.reference.serverId === destinationId,
|
||||||
|
);
|
||||||
|
const queries =
|
||||||
|
binding?.queries
|
||||||
|
.getQueryCache()
|
||||||
|
.findAll({ queryKey: ["providersSnapshot", destinationId] }) ?? [];
|
||||||
|
return {
|
||||||
|
status: binding?.runtime.getSnapshot(destinationId)?.connectionStatus ?? "absent",
|
||||||
|
hydrated: binding?.sessionStore.getState().sessions[destinationId]?.hasHydratedWorkspaces,
|
||||||
|
workspaceIds: [
|
||||||
|
...(binding?.sessionStore.getState().sessions[destinationId]?.workspaces.keys() ?? []),
|
||||||
|
],
|
||||||
|
catalogVersion: Math.max(0, ...queries.map((query) => query.state.dataUpdatedAt)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return { direct: read(destinationId), routed: read(sourceId) };
|
||||||
|
},
|
||||||
|
{ originServerId, serverId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectFleetRouteUnavailable(
|
||||||
|
page: Page,
|
||||||
|
originServerId: string,
|
||||||
|
serverId: string,
|
||||||
|
) {
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readFleetAuthorityState(page, originServerId, serverId)).routed.status)
|
||||||
|
.toMatch(/^(offline|error)$/);
|
||||||
|
const unavailable = page.getByText(/^(Cannot reach .+|.+ is offline)$/).filter({ visible: true });
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
await expect(unavailable.or(composer)).toBeVisible();
|
||||||
|
if (await composer.isVisible()) {
|
||||||
|
if (await composer.isEditable()) {
|
||||||
|
await composer.fill("MUST_NOT_USE_DIRECT_AUTHORITY");
|
||||||
|
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
|
||||||
|
} else await expect(composer).toBeDisabled();
|
||||||
|
} else
|
||||||
|
await expect(page.getByRole("button", { name: "Send message", exact: true })).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Primary children are created by the agent's real MCP endpoint, never the seed SDK. */
|
||||||
|
export async function delegateFleetThroughMcp() {
|
||||||
|
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
||||||
|
const { StreamableHTTPClientTransport } =
|
||||||
|
await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
||||||
|
const { getE2EDaemonPort } = await import("./daemon-port");
|
||||||
|
const { z } = await import("zod");
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "mcp-fleet-journey-" });
|
||||||
|
const destination = await startIsolatedHostDaemon(`fleet_${randomUUID()}`);
|
||||||
|
const createApi = await loadPaseoApiFactory();
|
||||||
|
const aClient = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "mcp-fleet-origin" });
|
||||||
|
const bClient = await connectDaemonClient<DaemonClient>({
|
||||||
|
clientIdPrefix: "mcp-fleet-observer",
|
||||||
|
port: destination.port,
|
||||||
|
});
|
||||||
|
const origin = createApi(aClient);
|
||||||
|
const direct = createApi(bClient);
|
||||||
|
const mcp = new Client({ name: "fleet-delegating-agent", version: "1.0.0" });
|
||||||
|
const close = async () => {
|
||||||
|
await mcp.close();
|
||||||
|
await Promise.allSettled([origin.dispose(), direct.dispose()]);
|
||||||
|
await Promise.allSettled([aClient.close(), bClient.close()]);
|
||||||
|
await destination.close();
|
||||||
|
await workspace.cleanup();
|
||||||
|
};
|
||||||
|
const call = async (name: string, args: Record<string, unknown> = {}) => {
|
||||||
|
const result = await mcp.callTool({ name, arguments: args });
|
||||||
|
expect(result, `${name} result`).not.toMatchObject({ isError: true });
|
||||||
|
return z.record(z.string(), z.unknown()).parse(result.structuredContent);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const config = { provider: "mock/ten-second-stream", modeId: "load-test" };
|
||||||
|
const parent = await origin.workspaces
|
||||||
|
.ref(workspace.workspaceId)
|
||||||
|
.agents.create({ title: "Fleet MCP parent", config });
|
||||||
|
const registered = await origin.hosts.add({
|
||||||
|
connection: `127.0.0.1:${destination.port}`,
|
||||||
|
label: "Fleet B",
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await origin.hosts.list()).hosts.find(
|
||||||
|
(host) => host.registrationId === registered.registrationId,
|
||||||
|
)?.status,
|
||||||
|
)
|
||||||
|
.toBe("online");
|
||||||
|
await mcp.connect(
|
||||||
|
new StreamableHTTPClientTransport(
|
||||||
|
new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents?callerAgentId=${parent.id}`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const hosts = await call("list_hosts");
|
||||||
|
expect(hosts).toMatchObject({
|
||||||
|
hosts: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ serverId: destination.serverId, label: "Fleet B" }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const target = await call("create_workspace", {
|
||||||
|
serverId: destination.serverId,
|
||||||
|
isolation: "local",
|
||||||
|
path: workspace.repoPath,
|
||||||
|
title: "Fleet B workspace",
|
||||||
|
});
|
||||||
|
const targetWorkspaceId = z.string().parse(target.workspaceId);
|
||||||
|
expect(await call("list_workspaces", { serverId: destination.serverId })).toMatchObject({
|
||||||
|
workspaces: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ workspaceId: targetWorkspaceId }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const createChild = async (serverId: string, workspaceId: string, title: string) => {
|
||||||
|
const result = await call("create_agent", {
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
title,
|
||||||
|
provider: config.provider,
|
||||||
|
settings: { modeId: config.modeId },
|
||||||
|
initialPrompt: `Complete ${title}`,
|
||||||
|
notifyOnFinish: true,
|
||||||
|
});
|
||||||
|
const agentId = z.string().parse(result.agentId);
|
||||||
|
const submissionId = z.string().parse(result.submissionId);
|
||||||
|
expect(await call("get_agent_status", { serverId, agentId })).toMatchObject({ serverId });
|
||||||
|
expect(await call("get_agent_activity", { serverId, agentId })).toMatchObject({ serverId });
|
||||||
|
expect(await call("list_agents", { serverId })).toMatchObject({
|
||||||
|
serverId,
|
||||||
|
agents: expect.arrayContaining([expect.objectContaining({ id: agentId })]),
|
||||||
|
});
|
||||||
|
return { id: agentId, serverId, workspaceId, submissionId, title };
|
||||||
|
};
|
||||||
|
const local = await createChild(parent.serverId, workspace.workspaceId, "MCP child on A");
|
||||||
|
const remote = await createChild(destination.serverId, targetWorkspaceId, "MCP child on B");
|
||||||
|
// Opposite-host collisions are unrelated fixture agents. Neither primary child is seeded.
|
||||||
|
await direct.agents.create({
|
||||||
|
agentId: local.id,
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
title: "Unrelated collision on B",
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
await origin.agents.create({
|
||||||
|
agentId: remote.id,
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
title: "Unrelated collision on A",
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
expect((await parent.subagents.list()).rows.map((row) => row.child)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ serverId: local.serverId, agentId: local.id },
|
||||||
|
{ serverId: remote.serverId, agentId: remote.id },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
workspace,
|
||||||
|
destination,
|
||||||
|
parent,
|
||||||
|
local,
|
||||||
|
remote,
|
||||||
|
origin,
|
||||||
|
direct,
|
||||||
|
close,
|
||||||
|
async expectCompletion(child: typeof local) {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const timeline = await parent.timeline.refetch();
|
||||||
|
return timeline.entries
|
||||||
|
.flatMap(({ item }) =>
|
||||||
|
item.type === "tool_call" && item.agentMessage ? [item.agentMessage] : [],
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(message) =>
|
||||||
|
message.event === "finished" &&
|
||||||
|
message.sender.address?.serverId === child.serverId &&
|
||||||
|
message.text.includes(child.submissionId),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 30000 },
|
||||||
|
)
|
||||||
|
.toHaveLength(1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readFleetCompletion(
|
||||||
|
page: Page,
|
||||||
|
child: { title: string; submissionId: string },
|
||||||
|
) {
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: new RegExp(`Agent finished.*${child.title}`) }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records observed requests and runtime-owned sessions without opening extra scopes. */
|
||||||
|
export function observeFleetRouteOwnership(page: Page) {
|
||||||
|
const requests: Record<string, number> = {};
|
||||||
|
let sockets = 0;
|
||||||
|
page.on("websocket", (socket) => {
|
||||||
|
sockets++;
|
||||||
|
socket.on("framesent", ({ payload }) => {
|
||||||
|
const request = inspectSessionWireRequest(payload)?.request;
|
||||||
|
if (!request || typeof request.type !== "string") return;
|
||||||
|
if (request.subscribe !== undefined || request.type === "hosts.route.open.request")
|
||||||
|
requests[request.type] = (requests[request.type] ?? 0) + 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return async () => ({
|
||||||
|
sockets,
|
||||||
|
cumulativeRequests: { ...requests },
|
||||||
|
bindings: await page.evaluate(() => {
|
||||||
|
const registry = Reflect.get(globalThis, "__paseoHostRuntimeStore") as HostRuntimeStore;
|
||||||
|
return registry.getAuthorityBindings().map((binding) => {
|
||||||
|
const id = binding.reference.serverId;
|
||||||
|
const session = binding.sessionStore.getState().sessions[id];
|
||||||
|
return {
|
||||||
|
reference: binding.reference,
|
||||||
|
status: binding.runtime.getSnapshot(id)?.connectionStatus,
|
||||||
|
session: session?.client !== null && session?.client !== undefined,
|
||||||
|
workspaceReplica: session?.hasHydratedWorkspaces === true,
|
||||||
|
timelineReplica: session?.viewedTimelineSync !== undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { HostAuthoritySessions } from "@/runtime/host-authority-sessions";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
|
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
|
||||||
@@ -313,6 +314,7 @@ function HostSessionManager() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<HostAuthoritySessions />
|
||||||
{hosts.map((daemon) => (
|
{hosts.map((daemon) => (
|
||||||
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
|
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -19,13 +19,20 @@ export default function HostRouteLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function KnownHostRoute() {
|
function KnownHostRoute() {
|
||||||
const params = useLocalSearchParams<{ serverId?: string | string[] }>();
|
const params = useLocalSearchParams<{
|
||||||
|
serverId?: string | string[];
|
||||||
|
origin?: string | string[];
|
||||||
|
}>();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const hostRegistryStatus = useHostRegistryStatus();
|
const hostRegistryStatus = useHostRegistryStatus();
|
||||||
const bootstrapState = useHostRuntimeBootstrapState();
|
const bootstrapState = useHostRuntimeBootstrapState();
|
||||||
const routeServerId = typeof params.serverId === "string" ? params.serverId : null;
|
const routeServerId = typeof params.serverId === "string" ? params.serverId : null;
|
||||||
const startupRoute = resolveStartupRoute({
|
const startupRoute = resolveStartupRoute({
|
||||||
route: { kind: "host", serverId: routeServerId },
|
route: {
|
||||||
|
kind: "host",
|
||||||
|
serverId: routeServerId,
|
||||||
|
...(typeof params.origin === "string" ? { originServerId: params.origin } : {}),
|
||||||
|
},
|
||||||
startupBlocker: bootstrapState.startupBlocker,
|
startupBlocker: bootstrapState.startupBlocker,
|
||||||
hostRegistryStatus,
|
hostRegistryStatus,
|
||||||
hosts,
|
hosts,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
@@ -5,21 +6,34 @@ import { useFetchQuery } from "@/data/query";
|
|||||||
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
|
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
|
||||||
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
|
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
import { useHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
import {
|
||||||
|
buildHostRootRoute,
|
||||||
|
buildSettingsHostRoute,
|
||||||
|
withHostRouteAuthority,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
|
const { serverId, origin } = useLocalSearchParams<{ serverId?: string; origin?: string }>();
|
||||||
|
const reference = useMemo(
|
||||||
|
() => ({ originServerId: origin ?? serverId ?? "", serverId: serverId ?? "" }),
|
||||||
|
[origin, serverId],
|
||||||
|
);
|
||||||
|
if (!serverId) return null;
|
||||||
return (
|
return (
|
||||||
<HostRouteBootstrapBoundary>
|
<HostRouteBootstrapBoundary>
|
||||||
<HostAgentReadyRouteContent />
|
<HostAuthorityRoute reference={reference}>
|
||||||
|
<HostAgentReadyRouteContent />
|
||||||
|
</HostAuthorityRoute>
|
||||||
</HostRouteBootstrapBoundary>
|
</HostRouteBootstrapBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HostAgentReadyRouteContent() {
|
function HostAgentReadyRouteContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const runtime = useHostRuntimeStore();
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
@@ -40,10 +54,8 @@ function HostAgentReadyRouteContent() {
|
|||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverId || !agentId || agentWorkspaceId) return;
|
if (!serverId || !agentId || agentWorkspaceId) return;
|
||||||
void getHostRuntimeStore()
|
void runtime.prepareAgentRoute(serverId, agentId).catch(() => undefined);
|
||||||
.prepareAgentRoute(serverId, agentId)
|
}, [agentId, agentWorkspaceId, serverId, runtime]);
|
||||||
.catch(() => undefined);
|
|
||||||
}, [agentId, agentWorkspaceId, serverId]);
|
|
||||||
const shouldLookupAgent = Boolean(
|
const shouldLookupAgent = Boolean(
|
||||||
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
|
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
|
||||||
);
|
);
|
||||||
@@ -106,11 +118,23 @@ function HostAgentReadyRouteContent() {
|
|||||||
handledNavigationRef.current = navigationKey;
|
handledNavigationRef.current = navigationKey;
|
||||||
|
|
||||||
if (resolution.kind === "resolved") {
|
if (resolution.kind === "resolved") {
|
||||||
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
|
navigateToAgent({
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
workspaceId: resolution.workspaceId,
|
||||||
|
authority: runtime.authorityFor(serverId),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
|
router.replace(
|
||||||
}, [agentId, resolution, router, serverId]);
|
resolution.kind === "invalid"
|
||||||
|
? ("/" as Href)
|
||||||
|
: (withHostRouteAuthority(
|
||||||
|
buildHostRootRoute(serverId),
|
||||||
|
runtime.authorityFor(serverId),
|
||||||
|
) as Href),
|
||||||
|
);
|
||||||
|
}, [agentId, resolution, router, serverId, runtime]);
|
||||||
|
|
||||||
const handleRetry = useCallback(() => {
|
const handleRetry = useCallback(() => {
|
||||||
if (resolution.kind === "lookupError") {
|
if (resolution.kind === "lookupError") {
|
||||||
@@ -118,21 +142,28 @@ function HostAgentReadyRouteContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
void getHostRuntimeStore().runProbeCycleNow(serverId);
|
void runtime.runProbeCycleNow(serverId);
|
||||||
}
|
}
|
||||||
}, [lookupQuery, resolution.kind, serverId]);
|
}, [lookupQuery, resolution.kind, serverId, runtime]);
|
||||||
const handleManageHost = useCallback(() => {
|
const handleManageHost = useCallback(() => {
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
router.push(buildSettingsHostRoute(serverId));
|
router.push(buildSettingsHostRoute(runtime.authorityFor(serverId).originServerId));
|
||||||
}
|
}
|
||||||
}, [router, serverId]);
|
}, [router, serverId, runtime]);
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
if (router.canGoBack()) {
|
if (router.canGoBack()) {
|
||||||
router.back();
|
router.back();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
|
router.replace(
|
||||||
}, [router, serverId]);
|
serverId
|
||||||
|
? (withHostRouteAuthority(
|
||||||
|
buildHostRootRoute(serverId),
|
||||||
|
runtime.authorityFor(serverId),
|
||||||
|
) as Href)
|
||||||
|
: ("/" as Href),
|
||||||
|
);
|
||||||
|
}, [router, serverId, runtime]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
resolution.kind === "waitingForHost" ||
|
resolution.kind === "waitingForHost" ||
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Redirect } from "expo-router";
|
import { useMemo } from "react";
|
||||||
|
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
|
||||||
|
import {
|
||||||
|
workspaceSelectionAuthority,
|
||||||
|
type ActiveWorkspaceSelection,
|
||||||
|
} from "@/stores/last-workspace-selection";
|
||||||
|
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||||
import { useHostRouteServerId } from "@/navigation/host-route-context";
|
import { useHostRouteServerId } from "@/navigation/host-route-context";
|
||||||
import {
|
import {
|
||||||
resolveHostIndexRoute,
|
resolveHostIndexRoute,
|
||||||
@@ -13,10 +19,31 @@ import {
|
|||||||
|
|
||||||
export default function HostIndexRoute() {
|
export default function HostIndexRoute() {
|
||||||
const serverId = useHostRouteServerId();
|
const serverId = useHostRouteServerId();
|
||||||
const workspaceSelection = useLastWorkspaceSelection();
|
const remembered = useLastWorkspaceSelection();
|
||||||
|
const { origin } = useLocalSearchParams<{ origin?: string }>();
|
||||||
|
const selection = useMemo(
|
||||||
|
() =>
|
||||||
|
remembered?.serverId === serverId
|
||||||
|
? { ...remembered, ...(origin ? { authority: { originServerId: origin } } : {}) }
|
||||||
|
: null,
|
||||||
|
[remembered, serverId, origin],
|
||||||
|
);
|
||||||
|
if (!selection) return <HostIndexContent workspaceSelection={null} />;
|
||||||
|
return (
|
||||||
|
<HostAuthorityRoute reference={workspaceSelectionAuthority(selection)}>
|
||||||
|
<HostIndexContent workspaceSelection={selection} />
|
||||||
|
</HostAuthorityRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostIndexContent({
|
||||||
|
workspaceSelection,
|
||||||
|
}: {
|
||||||
|
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||||
|
}) {
|
||||||
|
const serverId = useHostRouteServerId();
|
||||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||||
const workspaceSelectionWorkspaceId =
|
const workspaceSelectionWorkspaceId = workspaceSelection?.workspaceId ?? null;
|
||||||
workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null;
|
|
||||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||||
const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
|
const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
|
||||||
|
import { workspaceSelectionAuthority } from "@/stores/last-workspace-selection";
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigation } from "expo-router/react-navigation";
|
import { useNavigation } from "expo-router/react-navigation";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
import { useGlobalSearchParams, useRootNavigationState } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { RetainedPanel } from "@/components/retained-panel";
|
import { RetainedPanel } from "@/components/retained-panel";
|
||||||
import {
|
import {
|
||||||
@@ -24,16 +26,12 @@ import {
|
|||||||
resolveWorkspaceDeckEntries,
|
resolveWorkspaceDeckEntries,
|
||||||
shouldKeepWorkspaceDeckEntryMounted,
|
shouldKeepWorkspaceDeckEntryMounted,
|
||||||
} from "@/screens/workspace/workspace-deck-retention";
|
} from "@/screens/workspace/workspace-deck-retention";
|
||||||
import {
|
import { parseWorkspaceOpenIntent, type WorkspaceOpenIntent } from "@/utils/host-routes";
|
||||||
decodeWorkspaceIdFromPathSegment,
|
|
||||||
parseWorkspaceOpenIntent,
|
|
||||||
type WorkspaceOpenIntent,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
import {
|
import {
|
||||||
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
|
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
|
||||||
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
|
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
|
||||||
} from "@/utils/host-route-browser";
|
} from "@/utils/host-route-browser";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { useWorkspaceTabNavigation } from "@/workspace-tabs/navigation";
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
import { RenderProfile } from "@/utils/render-profiler";
|
import { RenderProfile } from "@/utils/render-profiler";
|
||||||
|
|
||||||
@@ -94,24 +92,34 @@ export default function HostWorkspaceIndexRoute() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HostWorkspaceRouteContent() {
|
function HostWorkspaceRouteContent() {
|
||||||
|
const { open } = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||||
|
return (
|
||||||
|
<WorkspaceDeck
|
||||||
|
recoveryRequested={parseWorkspaceOpenIntent(getParamValue(open))?.kind === "agent"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceOpenIntentBoundary({
|
||||||
|
selection,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
selection: ActiveWorkspaceSelection;
|
||||||
|
active: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { prepareWorkspaceTab } = useWorkspaceTabNavigation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const rootNavigationState = useRootNavigationState();
|
const rootNavigationState = useRootNavigationState();
|
||||||
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
|
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
|
||||||
const consumedIntentRef = useRef<string | null>(null);
|
const consumedIntentRef = useRef<string | null>(null);
|
||||||
const [intentConsumed, setIntentConsumed] = useState(false);
|
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||||
const params = useLocalSearchParams<{
|
|
||||||
serverId?: string | string[];
|
|
||||||
workspaceId?: string | string[];
|
|
||||||
}>();
|
|
||||||
const globalParams = useGlobalSearchParams<{
|
const globalParams = useGlobalSearchParams<{
|
||||||
open?: string | string[];
|
open?: string | string[];
|
||||||
}>();
|
}>();
|
||||||
const serverId = getParamValue(params.serverId);
|
const { serverId, workspaceId } = selection;
|
||||||
const workspaceValue = getParamValue(params.workspaceId);
|
const openValue = active ? getParamValue(globalParams.open) : "";
|
||||||
const workspaceId = workspaceValue
|
|
||||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
|
||||||
: "";
|
|
||||||
const openValue = getParamValue(globalParams.open);
|
|
||||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||||
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
||||||
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
|
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
|
||||||
@@ -178,6 +186,7 @@ function HostWorkspaceRouteContent() {
|
|||||||
openIntent,
|
openIntent,
|
||||||
openValue,
|
openValue,
|
||||||
rootNavigationState?.key,
|
rootNavigationState?.key,
|
||||||
|
prepareWorkspaceTab,
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
]);
|
]);
|
||||||
@@ -190,7 +199,7 @@ function HostWorkspaceRouteContent() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||||
@@ -274,7 +283,20 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceDeckEntry({
|
function WorkspaceDeckEntry(props: {
|
||||||
|
selection: ActiveWorkspaceSelection;
|
||||||
|
active: boolean;
|
||||||
|
recoveryRequested: boolean;
|
||||||
|
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<HostAuthorityRoute reference={workspaceSelectionAuthority(props.selection)}>
|
||||||
|
<WorkspaceDeckEntryContent {...props} />
|
||||||
|
</HostAuthorityRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceDeckEntryContent({
|
||||||
selection,
|
selection,
|
||||||
active,
|
active,
|
||||||
recoveryRequested,
|
recoveryRequested,
|
||||||
@@ -308,12 +330,14 @@ function WorkspaceDeckEntry({
|
|||||||
active={active}
|
active={active}
|
||||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||||
>
|
>
|
||||||
<WorkspaceScreen
|
<WorkspaceOpenIntentBoundary selection={selection} active={active}>
|
||||||
serverId={selection.serverId}
|
<WorkspaceScreen
|
||||||
workspaceId={selection.workspaceId}
|
serverId={selection.serverId}
|
||||||
isRouteFocused={active}
|
workspaceId={selection.workspaceId}
|
||||||
recoveryRequested={active && recoveryRequested}
|
isRouteFocused={active}
|
||||||
/>
|
recoveryRequested={active && recoveryRequested}
|
||||||
|
/>
|
||||||
|
</WorkspaceOpenIntentBoundary>
|
||||||
</RetainedPanel>
|
</RetainedPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,14 @@ export default function Index() {
|
|||||||
hosts,
|
hosts,
|
||||||
anyOnlineHostServerId,
|
anyOnlineHostServerId,
|
||||||
workspaceSelection,
|
workspaceSelection,
|
||||||
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
|
// COMPAT(unboundStartupSelection): added in v0.12, remove after 2027-04-20.
|
||||||
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
|
// Qualified restore reacquires its origin at the host boundary before proving absence.
|
||||||
workspaceExists: workspaceSelectionExists,
|
workspaceSelectionStatus: workspaceSelection?.authority
|
||||||
}),
|
? "unknown"
|
||||||
|
: resolveWorkspaceSelectionStatus({
|
||||||
|
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
|
||||||
|
workspaceExists: workspaceSelectionExists,
|
||||||
|
}),
|
||||||
isWorkspaceSelectionLoaded,
|
isWorkspaceSelectionLoaded,
|
||||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
|
|||||||
options: [{ id: "high", label: "High" }],
|
options: [{ id: "high", label: "High" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
parentAgentId: null,
|
parent: null,
|
||||||
|
managedRelationship: null,
|
||||||
labels: {},
|
labels: {},
|
||||||
...overrides,
|
...overrides,
|
||||||
turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null },
|
turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null },
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { useToast } from "@/contexts/toast-context";
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { RewindMode } from "./use-rewind-capabilities";
|
import type { RewindMode } from "./use-rewind-capabilities";
|
||||||
import { useRewindComposerRestore } from "./composer-restore";
|
import { useRewindComposerRestore } from "./composer-restore";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStoreApi } from "@/stores/session-store";
|
||||||
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
|
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { useHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
|
|
||||||
interface UseRewindAgentMutationInput {
|
interface UseRewindAgentMutationInput {
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
@@ -26,6 +26,8 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
|||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
} {
|
} {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const sessionStore = useSessionStoreApi();
|
||||||
|
const runtime = useHostRuntimeStore();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const composerRestore = useRewindComposerRestore();
|
const composerRestore = useRewindComposerRestore();
|
||||||
const { isPending, mutateAsync } = useMutation({
|
const { isPending, mutateAsync } = useMutation({
|
||||||
@@ -36,12 +38,10 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
|||||||
await input.client.rewindAgent(input.agentId, input.messageId, mode);
|
await input.client.rewindAgent(input.agentId, input.messageId, mode);
|
||||||
if (mode !== "files") {
|
if (mode !== "files") {
|
||||||
const cursor = input.serverId
|
const cursor = input.serverId
|
||||||
? useSessionStore
|
? sessionStore.getState().sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
|
||||||
.getState()
|
|
||||||
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
|
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
|
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||||
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
|
await runtime.fetchAgentTimeline(input.serverId, input.agentId, {
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
projection: "projected",
|
projection: "projected",
|
||||||
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
|
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import {
|
|||||||
resolveComposerAttachmentSubmitFormat,
|
resolveComposerAttachmentSubmitFormat,
|
||||||
splitComposerAttachmentsForSubmit,
|
splitComposerAttachmentsForSubmit,
|
||||||
} from "@/composer/attachments/submit";
|
} from "@/composer/attachments/submit";
|
||||||
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
import {
|
||||||
|
isActiveCreateFlowForDraft,
|
||||||
|
useCreateFlowStore,
|
||||||
|
useCreateFlowStoreApi,
|
||||||
|
} from "@/stores/create-flow-store";
|
||||||
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
|
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStoreApi } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
createUserMessage,
|
createUserMessage,
|
||||||
type StreamItem,
|
type StreamItem,
|
||||||
@@ -122,6 +126,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
onCreateError,
|
onCreateError,
|
||||||
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
|
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const sessionStore = useSessionStoreApi();
|
||||||
|
const flowStore = useCreateFlowStoreApi();
|
||||||
const [localMachine, dispatch] = useReducer(
|
const [localMachine, dispatch] = useReducer(
|
||||||
reducer<TDraftAgent>,
|
reducer<TDraftAgent>,
|
||||||
initialAttempt,
|
initialAttempt,
|
||||||
@@ -235,6 +241,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
images: attempt.images,
|
images: attempt.images,
|
||||||
attachments: attempt.attachments,
|
attachments: attempt.attachments,
|
||||||
}),
|
}),
|
||||||
|
sessionStore,
|
||||||
);
|
);
|
||||||
markPendingCreateLifecycle({ draftId, lifecycle: "sent" });
|
markPendingCreateLifecycle({ draftId, lifecycle: "sent" });
|
||||||
}
|
}
|
||||||
@@ -255,6 +262,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
createRequest,
|
createRequest,
|
||||||
|
sessionStore,
|
||||||
draftId,
|
draftId,
|
||||||
getPendingServerId,
|
getPendingServerId,
|
||||||
markPendingCreateLifecycle,
|
markPendingCreateLifecycle,
|
||||||
@@ -268,7 +276,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
|
|
||||||
const handleCreateFromInput = useCallback(
|
const handleCreateFromInput = useCallback(
|
||||||
async ({ text, attachments, cwd }: SubmitContext) => {
|
async ({ text, attachments, cwd }: SubmitContext) => {
|
||||||
const existing = useCreateFlowStore.getState().pendingByDraftId[draftId];
|
const existing = flowStore.getState().pendingByDraftId[draftId];
|
||||||
if (
|
if (
|
||||||
isSubmitting ||
|
isSubmitting ||
|
||||||
isActiveCreateFlowForDraft({ pending: existing, serverId: getPendingServerId(), draftId })
|
isActiveCreateFlowForDraft({ pending: existing, serverId: getPendingServerId(), draftId })
|
||||||
@@ -285,7 +293,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const supportsForgeSearch =
|
const supportsForgeSearch =
|
||||||
useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
|
sessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
|
||||||
true;
|
true;
|
||||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
|
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
|
||||||
format: resolveComposerAttachmentSubmitFormat({
|
format: resolveComposerAttachmentSubmitFormat({
|
||||||
@@ -342,6 +350,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
draftId,
|
draftId,
|
||||||
getPendingServerId,
|
getPendingServerId,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
flowStore,
|
||||||
|
sessionStore,
|
||||||
onCreateStart,
|
onCreateStart,
|
||||||
runCreateAttempt,
|
runCreateAttempt,
|
||||||
setPendingCreateAttempt,
|
setPendingCreateAttempt,
|
||||||
|
|||||||
@@ -243,7 +243,8 @@ function buildDraftAgentSnapshot(input: {
|
|||||||
model,
|
model,
|
||||||
features: composerState.agentControls.features,
|
features: composerState.agentControls.features,
|
||||||
thinkingOptionId,
|
thinkingOptionId,
|
||||||
parentAgentId: null,
|
parent: null,
|
||||||
|
managedRelationship: null,
|
||||||
labels: {},
|
labels: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import type { ComposerTextSource } from "./text-source";
|
import type { ComposerTextSource } from "./text-source";
|
||||||
import { createStore, type StoreApi } from "zustand/vanilla";
|
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||||
import { useStore } from "zustand";
|
import { useStore } from "zustand";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { useHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
@@ -55,7 +55,11 @@ import {
|
|||||||
} from "@/composer/agent-controls";
|
} from "@/composer/agent-controls";
|
||||||
import { ContextWindowMeter } from "@/components/context-window-meter";
|
import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||||
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
import {
|
||||||
|
selectAgentTurnPresentation,
|
||||||
|
useSessionStore,
|
||||||
|
useSessionStoreApi,
|
||||||
|
} from "@/stores/session-store";
|
||||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||||
import { useFileDrop } from "@/components/file-drop/use-file-drop";
|
import { useFileDrop } from "@/components/file-drop/use-file-drop";
|
||||||
import type { DroppedItem } from "@/components/file-drop/types";
|
import type { DroppedItem } from "@/components/file-drop/types";
|
||||||
@@ -1264,6 +1268,8 @@ function ComposerContentImpl({
|
|||||||
const mode = resolveComposerInputMode(inputMode);
|
const mode = resolveComposerInputMode(inputMode);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const buttonIconSize = resolveComposerButtonIconSize();
|
const buttonIconSize = resolveComposerButtonIconSize();
|
||||||
|
const sessionStore = useSessionStoreApi();
|
||||||
|
const runtime = useHostRuntimeStore();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
||||||
@@ -1560,19 +1566,27 @@ function ComposerContentImpl({
|
|||||||
supportsForgeAttachments: supportsForgeSearch,
|
supportsForgeAttachments: supportsForgeSearch,
|
||||||
}),
|
}),
|
||||||
encodeImages,
|
encodeImages,
|
||||||
submission: createMessageSubmissionWriter(serverId),
|
submission: createMessageSubmissionWriter(serverId, sessionStore),
|
||||||
activeTurnBehavior,
|
activeTurnBehavior,
|
||||||
activeTurnId:
|
activeTurnId:
|
||||||
activeTurnBehavior === "steer"
|
activeTurnBehavior === "steer"
|
||||||
? (selectAgentTurnPresentation(
|
? (selectAgentTurnPresentation(
|
||||||
useSessionStore.getState().sessions[serverId],
|
sessionStore.getState().sessions[serverId],
|
||||||
targetAgentId,
|
targetAgentId,
|
||||||
).turnId ?? undefined)
|
).turnId ?? undefined)
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
onAttentionPromptSend?.();
|
onAttentionPromptSend?.();
|
||||||
};
|
};
|
||||||
}, [appSettings.sendBehavior, client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
|
}, [
|
||||||
|
appSettings.sendBehavior,
|
||||||
|
client,
|
||||||
|
onAttentionPromptSend,
|
||||||
|
serverId,
|
||||||
|
sessionStore,
|
||||||
|
supportsForgeSearch,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSubmitMessageRef.current = onSubmitMessage;
|
onSubmitMessageRef.current = onSubmitMessage;
|
||||||
@@ -1603,10 +1617,10 @@ function ComposerContentImpl({
|
|||||||
|
|
||||||
const queueWriter = useMemo<QueueWriter>(
|
const queueWriter = useMemo<QueueWriter>(
|
||||||
() => ({
|
() => ({
|
||||||
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
|
read: (id) => sessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
|
||||||
write: (updater) => setQueuedMessages(serverId, updater),
|
write: (updater) => setQueuedMessages(serverId, updater),
|
||||||
}),
|
}),
|
||||||
[serverId, setQueuedMessages],
|
[serverId, sessionStore, setQueuedMessages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const queueMessage = useCallback(
|
const queueMessage = useCallback(
|
||||||
@@ -1875,26 +1889,23 @@ function ComposerContentImpl({
|
|||||||
const handleCancelAgent = useCallback(() => {
|
const handleCancelAgent = useCallback(() => {
|
||||||
const targetAgentId = agentIdRef.current;
|
const targetAgentId = agentIdRef.current;
|
||||||
const cancellation = cancelComposerAgent({
|
const cancellation = cancelComposerAgent({
|
||||||
client,
|
client: client
|
||||||
|
? { cancelAgent: (id) => runtime.cancelAgent({ serverId, agentId: id }) }
|
||||||
|
: null,
|
||||||
agentId: targetAgentId,
|
agentId: targetAgentId,
|
||||||
isAgentRunning,
|
isAgentRunning,
|
||||||
isCancellingAgent,
|
isCancellingAgent,
|
||||||
isConnected,
|
isConnected,
|
||||||
});
|
});
|
||||||
if (!cancellation) return;
|
if (!cancellation) return;
|
||||||
const requestId = getHostRuntimeStore().beginAgentCancellation(serverId, targetAgentId);
|
void cancellation.catch((error) => {
|
||||||
void cancellation
|
const message = resolveErrorMessage(error);
|
||||||
.catch((error) => {
|
if (message && message.trim().length > 0) {
|
||||||
const message = resolveErrorMessage(error);
|
toastErrorRef.current(message);
|
||||||
if (message && message.trim().length > 0) {
|
}
|
||||||
toastErrorRef.current(message);
|
});
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
getHostRuntimeStore().settleAgentCancellation(serverId, targetAgentId, requestId);
|
|
||||||
});
|
|
||||||
messageInputRef.current?.focus();
|
messageInputRef.current?.focus();
|
||||||
}, [client, isAgentRunning, isCancellingAgent, isConnected, serverId]);
|
}, [client, runtime, isAgentRunning, isCancellingAgent, isConnected, serverId]);
|
||||||
|
|
||||||
const focusMessageInputForKeyboardAction = useCallback(() => {
|
const focusMessageInputForKeyboardAction = useCallback(() => {
|
||||||
focusMessageInputWithPlatformStrategy(messageInputRef);
|
focusMessageInputWithPlatformStrategy(messageInputRef);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, expect, test } from "vitest";
|
import { afterEach, expect, test } from "vitest";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { createSessionStore, useSessionStore } from "@/stores/session-store";
|
||||||
import type { UserMessageItem } from "@/types/stream";
|
import type { UserMessageItem } from "@/types/stream";
|
||||||
import { createMessageSubmissionWriter } from "./writer";
|
import { createMessageSubmissionWriter } from "./writer";
|
||||||
|
|
||||||
@@ -40,10 +40,45 @@ afterEach(() => {
|
|||||||
test("tracks submissions when canonical prompts are supported", () => {
|
test("tracks submissions when canonical prompts are supported", () => {
|
||||||
initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: true });
|
initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: true });
|
||||||
|
|
||||||
createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID).begin(
|
createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID, useSessionStore).begin(
|
||||||
"agent-1",
|
"agent-1",
|
||||||
submittedMessage("canonical-only-message"),
|
submittedMessage("canonical-only-message"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]);
|
expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each([true, false])(
|
||||||
|
"writes and rejects only in its captured authority store (tracked=%s)",
|
||||||
|
(tracked) => {
|
||||||
|
initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: tracked });
|
||||||
|
const scoped = createSessionStore();
|
||||||
|
scoped.getState().initializeSession(CANONICAL_ONLY_SERVER_ID, null);
|
||||||
|
scoped.getState().updateSessionServerInfo(CANONICAL_ONLY_SERVER_ID, {
|
||||||
|
serverId: CANONICAL_ONLY_SERVER_ID,
|
||||||
|
hostname: null,
|
||||||
|
version: "0.12.0",
|
||||||
|
features: { canonicalSubmittedPrompts: tracked },
|
||||||
|
});
|
||||||
|
const writer = createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID, scoped);
|
||||||
|
writer.begin("agent-1", submittedMessage("scoped-message"));
|
||||||
|
const session = scoped.getState().sessions[CANONICAL_ONLY_SERVER_ID];
|
||||||
|
if (tracked)
|
||||||
|
expect(
|
||||||
|
session.messageSubmissions.get("agent-1")?.map((submission) => submission.clientMessageId),
|
||||||
|
).toEqual(["scoped-message"]);
|
||||||
|
else
|
||||||
|
expect(
|
||||||
|
session.agentStreamTail
|
||||||
|
.get("agent-1")
|
||||||
|
?.map((item) => (item.kind === "user_message" ? item.clientMessageId : null)),
|
||||||
|
).toEqual(["scoped-message"]);
|
||||||
|
expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual([]);
|
||||||
|
expect(
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.sessions[CANONICAL_ONLY_SERVER_ID]?.agentStreamTail.get("agent-1") ?? [],
|
||||||
|
).toEqual([]);
|
||||||
|
expect(writer.reject("agent-1", "scoped-message")).toBe("rejected");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,31 +1,37 @@
|
|||||||
import type { MessageSubmissionWriter } from "@/composer/actions";
|
import type { MessageSubmissionWriter } from "@/composer/actions";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import type { SessionStoreApi } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
appendSubmittedUserMessage,
|
appendSubmittedUserMessage,
|
||||||
removeSubmittedUserMessage,
|
removeSubmittedUserMessage,
|
||||||
type UserMessageItem,
|
type UserMessageItem,
|
||||||
} from "@/types/stream";
|
} from "@/types/stream";
|
||||||
|
|
||||||
function appendUntrackedSubmission(serverId: string, agentId: string, message: UserMessageItem) {
|
function appendUntrackedSubmission(
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
store: SessionStoreApi,
|
||||||
|
serverId: string,
|
||||||
|
agentId: string,
|
||||||
|
message: UserMessageItem,
|
||||||
|
) {
|
||||||
|
const session = store.getState().sessions[serverId];
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const tail = session.agentStreamTail.get(agentId) ?? [];
|
const tail = session.agentStreamTail.get(agentId) ?? [];
|
||||||
const head = session.agentStreamHead.get(agentId) ?? [];
|
const head = session.agentStreamHead.get(agentId) ?? [];
|
||||||
useSessionStore
|
store
|
||||||
.getState()
|
.getState()
|
||||||
.setAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message }));
|
.setAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeUntrackedSubmission(
|
function removeUntrackedSubmission(
|
||||||
|
store: SessionStoreApi,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
clientMessageId: string,
|
clientMessageId: string,
|
||||||
): void {
|
): void {
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
const session = store.getState().sessions[serverId];
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const tail = session.agentStreamTail.get(agentId) ?? [];
|
const tail = session.agentStreamTail.get(agentId) ?? [];
|
||||||
const head = session.agentStreamHead.get(agentId) ?? [];
|
const head = session.agentStreamHead.get(agentId) ?? [];
|
||||||
useSessionStore
|
store
|
||||||
.getState()
|
.getState()
|
||||||
.setAgentStreamState(
|
.setAgentStreamState(
|
||||||
serverId,
|
serverId,
|
||||||
@@ -34,32 +40,37 @@ function removeUntrackedSubmission(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
|
function createUntrackedMessageSubmissionWriter(
|
||||||
|
serverId: string,
|
||||||
|
store: SessionStoreApi,
|
||||||
|
): MessageSubmissionWriter {
|
||||||
return {
|
return {
|
||||||
begin: (agentId, message) => appendUntrackedSubmission(serverId, agentId, message),
|
begin: (agentId, message) => appendUntrackedSubmission(store, serverId, agentId, message),
|
||||||
accept: () => undefined,
|
accept: () => undefined,
|
||||||
reject: (agentId, clientMessageId) => {
|
reject: (agentId, clientMessageId) => {
|
||||||
removeUntrackedSubmission(serverId, agentId, clientMessageId);
|
removeUntrackedSubmission(store, serverId, agentId, clientMessageId);
|
||||||
return "rejected";
|
return "rejected";
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
|
export function createMessageSubmissionWriter(
|
||||||
|
serverId: string,
|
||||||
|
store: SessionStoreApi,
|
||||||
|
): MessageSubmissionWriter {
|
||||||
const supportsTrackedMessageSubmissions =
|
const supportsTrackedMessageSubmissions =
|
||||||
useSessionStore.getState().sessions[serverId]?.serverInfo?.features
|
store.getState().sessions[serverId]?.serverInfo?.features?.canonicalSubmittedPrompts === true;
|
||||||
?.canonicalSubmittedPrompts === true;
|
|
||||||
if (!supportsTrackedMessageSubmissions) {
|
if (!supportsTrackedMessageSubmissions) {
|
||||||
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6.
|
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6.
|
||||||
return createUntrackedMessageSubmissionWriter(serverId);
|
return createUntrackedMessageSubmissionWriter(serverId, store);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
begin: (agentId, message) =>
|
begin: (agentId, message) =>
|
||||||
useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message),
|
store.getState().beginAgentMessageSubmission(serverId, agentId, message),
|
||||||
accept: (agentId, clientMessageId) =>
|
accept: (agentId, clientMessageId) =>
|
||||||
useSessionStore.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId),
|
store.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId),
|
||||||
reject: (agentId, clientMessageId) =>
|
reject: (agentId, clientMessageId) =>
|
||||||
useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId),
|
store.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +78,7 @@ export function handoffCreatedAgentMessageSubmission(
|
|||||||
serverId: string,
|
serverId: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
message: UserMessageItem,
|
message: UserMessageItem,
|
||||||
|
store: SessionStoreApi,
|
||||||
): boolean {
|
): boolean {
|
||||||
return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message);
|
return store.getState().handoffCreatedAgentUserMessage(serverId, agentId, message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useRef, ReactNode, useCallback, useEffect } from "react";
|
import { useRef, ReactNode, useCallback, useEffect } from "react";
|
||||||
import { Buffer } from "buffer";
|
import { Buffer } from "buffer";
|
||||||
import { AppState } from "react-native";
|
import { AppState } from "react-native";
|
||||||
import { observeOpenWorkspaceAgentIds } from "@/stores/workspace-layout-store";
|
import {
|
||||||
|
observeOpenWorkspaceAgentIds,
|
||||||
|
useWorkspaceLayoutStoreApi,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||||
@@ -27,29 +30,25 @@ import {
|
|||||||
|
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||||
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
|
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
|
||||||
import type { AudioPlaybackSource } from "@/audio";
|
import type { AudioPlaybackSource } from "@/audio";
|
||||||
import {
|
import {
|
||||||
selectAgentTimelineState,
|
selectAgentTimelineState,
|
||||||
toDaemonServerInfo,
|
toDaemonServerInfo,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
|
useSessionStoreApi,
|
||||||
type SessionState,
|
type SessionState,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { sendOsNotification } from "@/utils/os-notifications";
|
import { sendOsNotification } from "@/utils/os-notifications";
|
||||||
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
|
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
|
||||||
import {
|
import { getInitKey } from "@/utils/agent-initialization";
|
||||||
getInitKey,
|
|
||||||
getInitDeferred,
|
|
||||||
createInitDeferred,
|
|
||||||
rejectInitDeferred,
|
|
||||||
} from "@/utils/agent-initialization";
|
|
||||||
import { derivePendingPermissionKey } from "@/utils/agent-snapshots";
|
import { derivePendingPermissionKey } from "@/utils/agent-snapshots";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||||
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
|
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
|
||||||
import { resyncProviderSubagents, useProviderSubagentStore } from "@/subagents/provider-store";
|
import { resyncProviderSubagents, useProviderSubagentStoreApi } from "@/subagents/provider-store";
|
||||||
|
|
||||||
// Re-export types from session-store and draft-store for backward compatibility
|
// Re-export types from session-store and draft-store for backward compatibility
|
||||||
export type { DraftInput } from "@/stores/draft-store";
|
export type { DraftInput } from "@/stores/draft-store";
|
||||||
@@ -201,6 +200,10 @@ export function SessionProvider(props: SessionProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
|
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
|
||||||
|
const sessionStore = useSessionStoreApi();
|
||||||
|
const providerStore = useProviderSubagentStoreApi();
|
||||||
|
const layoutStore = useWorkspaceLayoutStoreApi();
|
||||||
|
const runtime = useHostRuntimeStore();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const voiceRuntime = useVoiceRuntimeOptional();
|
const voiceRuntime = useVoiceRuntimeOptional();
|
||||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||||
@@ -265,7 +268,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
notification?: AgentAttentionNotificationPayload;
|
notification?: AgentAttentionNotificationPayload;
|
||||||
}) => {
|
}) => {
|
||||||
const appState = appStateRef.current;
|
const appState = appStateRef.current;
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
const session = sessionStore.getState().sessions[serverId];
|
||||||
const attentionFocusedAgentId = session?.focusedAgentId ?? null;
|
const attentionFocusedAgentId = session?.focusedAgentId ?? null;
|
||||||
if (params.reason === "error") {
|
if (params.reason === "error") {
|
||||||
return;
|
return;
|
||||||
@@ -309,7 +312,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
data: notification.data,
|
data: notification.data,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[serverId],
|
[serverId, sessionStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -377,7 +380,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
|
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
|
||||||
]);
|
]);
|
||||||
if (turnLiveness.length > 0) {
|
if (turnLiveness.length > 0) {
|
||||||
getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness);
|
runtime.applyAgentTurnLiveness(serverId, agentId, turnLiveness);
|
||||||
}
|
}
|
||||||
sync.enqueueStreamEvent(agentId, {
|
sync.enqueueStreamEvent(agentId, {
|
||||||
event: streamEvent,
|
event: streamEvent,
|
||||||
@@ -395,14 +398,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
void requestTimelineReplacement(
|
void requestTimelineReplacement(
|
||||||
{
|
{
|
||||||
fetchAgentTimeline: (agentId, request) =>
|
fetchAgentTimeline: (agentId, request) =>
|
||||||
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request),
|
runtime.fetchAgentTimeline(serverId, agentId, request),
|
||||||
},
|
},
|
||||||
message.payload.agentId,
|
message.payload.agentId,
|
||||||
).catch((error: unknown) => {
|
).catch((error: unknown) => {
|
||||||
console.warn("[Session] timeline replacement refresh failed", { serverId, error });
|
console.warn("[Session] timeline replacement refresh failed", { serverId, error });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, {
|
const sync = runtime.createViewedTimelineOwner(serverId, {
|
||||||
observe: (agentIds) => {
|
observe: (agentIds) => {
|
||||||
const observation = client.observeTimeline(agentIds);
|
const observation = client.observeTimeline(agentIds);
|
||||||
observation.subscribe({
|
observation.subscribe({
|
||||||
@@ -416,7 +419,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
},
|
},
|
||||||
readCursor: (agentId) => {
|
readCursor: (agentId) => {
|
||||||
const timeline = selectAgentTimelineState(
|
const timeline = selectAgentTimelineState(
|
||||||
useSessionStore.getState().sessions[serverId],
|
sessionStore.getState().sessions[serverId],
|
||||||
agentId,
|
agentId,
|
||||||
);
|
);
|
||||||
return timeline.status === "synced" && timeline.range
|
return timeline.status === "synced" && timeline.range
|
||||||
@@ -424,15 +427,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
: undefined;
|
: undefined;
|
||||||
},
|
},
|
||||||
fetchPage: async (agentId, request) => {
|
fetchPage: async (agentId, request) => {
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
const session = sessionStore.getState().sessions[serverId];
|
||||||
const initKey = getInitKey(serverId, agentId);
|
const initKey = getInitKey(serverId, agentId);
|
||||||
const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced";
|
const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced";
|
||||||
if (shouldInitialize) {
|
if (shouldInitialize) {
|
||||||
if (!getInitDeferred(initKey)) {
|
if (!sessionStore.initializations.getInitDeferred(initKey)) {
|
||||||
const deferred = createInitDeferred(initKey, request.direction ?? "tail");
|
const deferred = sessionStore.initializations.createInitDeferred(
|
||||||
|
initKey,
|
||||||
|
request.direction ?? "tail",
|
||||||
|
);
|
||||||
void deferred.promise.catch(() => undefined);
|
void deferred.promise.catch(() => undefined);
|
||||||
}
|
}
|
||||||
refreshAgentInitializationTimeout({
|
refreshAgentInitializationTimeout({
|
||||||
|
store: sessionStore,
|
||||||
key: initKey,
|
key: initKey,
|
||||||
agentId,
|
agentId,
|
||||||
setAgentInitializing,
|
setAgentInitializing,
|
||||||
@@ -440,21 +447,29 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
setAgentInitializing(agentId, true);
|
setAgentInitializing(agentId, true);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const page = await getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request);
|
const page = await runtime.fetchAgentTimeline(serverId, agentId, request);
|
||||||
if (shouldInitialize && getInitDeferred(initKey)) {
|
if (shouldInitialize && sessionStore.initializations.getInitDeferred(initKey)) {
|
||||||
refreshAgentInitializationTimeout({ key: initKey, agentId, setAgentInitializing });
|
refreshAgentInitializationTimeout({
|
||||||
|
store: sessionStore,
|
||||||
|
key: initKey,
|
||||||
|
agentId,
|
||||||
|
setAgentInitializing,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return page;
|
return page;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (shouldInitialize) {
|
if (shouldInitialize) {
|
||||||
setAgentInitializing(agentId, false);
|
setAgentInitializing(agentId, false);
|
||||||
rejectInitDeferred(initKey, error instanceof Error ? error : new Error(String(error)));
|
sessionStore.initializations.rejectInitDeferred(
|
||||||
|
initKey,
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fetchLatestTail: (agentId) =>
|
fetchLatestTail: (agentId) =>
|
||||||
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()),
|
runtime.fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()),
|
||||||
reportError: (error) => {
|
reportError: (error) => {
|
||||||
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
|
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
|
||||||
},
|
},
|
||||||
@@ -466,8 +481,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
viewedTimelineSyncRef.current = sync;
|
viewedTimelineSyncRef.current = sync;
|
||||||
setViewedTimelineSync(serverId, sync);
|
setViewedTimelineSync(serverId, sync);
|
||||||
sync.setActive(getIsAppVisible(appStateRef.current));
|
sync.setActive(getIsAppVisible(appStateRef.current));
|
||||||
const stopObservingOpenChats = observeOpenWorkspaceAgentIds(serverId, (agentIds) =>
|
const stopObservingOpenChats = observeOpenWorkspaceAgentIds(
|
||||||
sync.replaceOpenTabAgentIds(agentIds),
|
serverId,
|
||||||
|
(agentIds) => sync.replaceOpenTabAgentIds(agentIds),
|
||||||
|
layoutStore,
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -478,7 +495,17 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
setViewedTimelineSync(serverId, null);
|
setViewedTimelineSync(serverId, null);
|
||||||
sync.dispose();
|
sync.dispose();
|
||||||
};
|
};
|
||||||
}, [client, serverId, setInitializingAgents, setViewedTimelineSync, voiceRuntime]);
|
}, [
|
||||||
|
client,
|
||||||
|
serverId,
|
||||||
|
sessionStore,
|
||||||
|
runtime,
|
||||||
|
sessionStore.initializations,
|
||||||
|
layoutStore,
|
||||||
|
setInitializingAgents,
|
||||||
|
setViewedTimelineSync,
|
||||||
|
voiceRuntime,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
viewedTimelineSyncRef.current?.setConnected(isConnected);
|
viewedTimelineSyncRef.current?.setConnected(isConnected);
|
||||||
@@ -519,11 +546,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
|
|
||||||
const unsubProviderSubagentUpdate = feeds.subscribe({
|
const unsubProviderSubagentUpdate = feeds.subscribe({
|
||||||
snapshot: () => {
|
snapshot: () => {
|
||||||
void resyncProviderSubagents(client, serverId);
|
void resyncProviderSubagents(client, serverId, providerStore);
|
||||||
},
|
},
|
||||||
update: (message) => {
|
update: (message) => {
|
||||||
if (message.type !== "agent.provider_subagents.update") return;
|
if (message.type !== "agent.provider_subagents.update") return;
|
||||||
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
|
providerStore.getState().applyUpdate(serverId, message.payload);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -709,6 +736,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
clearAgentStreamHead,
|
clearAgentStreamHead,
|
||||||
setInitializingAgents,
|
setInitializingAgents,
|
||||||
setAgents,
|
setAgents,
|
||||||
|
providerStore,
|
||||||
setPendingPermissions,
|
setPendingPermissions,
|
||||||
notifyAgentAttention,
|
notifyAgentAttention,
|
||||||
updateSessionServerInfo,
|
updateSessionServerInfo,
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ function createAgent(status: Agent["status"]): Agent {
|
|||||||
title: "Agent",
|
title: "Agent",
|
||||||
cwd: "/tmp",
|
cwd: "/tmp",
|
||||||
model: null,
|
model: null,
|
||||||
parentAgentId: null,
|
parent: null,
|
||||||
|
managedRelationship: null,
|
||||||
labels: {},
|
labels: {},
|
||||||
projectPlacement: null,
|
projectPlacement: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const createQueryClient = () =>
|
||||||
defaultOptions: {
|
new QueryClient({
|
||||||
queries: {
|
defaultOptions: {
|
||||||
staleTime: Infinity,
|
queries: {
|
||||||
refetchOnMount: false,
|
staleTime: Infinity,
|
||||||
refetchOnReconnect: false,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
export const queryClient = createQueryClient();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { managedAgentFields } from "@/subagents/test-fixtures";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
|
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||||
@@ -769,7 +770,9 @@ function agent(input: {
|
|||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
workspaceId: input.workspaceId,
|
workspaceId: input.workspaceId,
|
||||||
model: null,
|
model: null,
|
||||||
parentAgentId: input.parentAgentId ?? null,
|
...managedAgentFields(
|
||||||
|
input.parentAgentId ? { serverId: "srv", agentId: input.parentAgentId } : null,
|
||||||
|
),
|
||||||
archivedAt: input.archivedAt ?? null,
|
archivedAt: input.archivedAt ?? null,
|
||||||
labels: {},
|
labels: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useInfiniteQuery } from "@tanstack/react-query";
|
|||||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
import { useHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
|
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
|
||||||
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
|
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
|
||||||
@@ -221,7 +221,7 @@ export function useAgentHistory(options: {
|
|||||||
}): AgentHistoryResult {
|
}): AgentHistoryResult {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = useHostRuntimeStore();
|
||||||
const runtimeVersion = useSyncExternalStore(
|
const runtimeVersion = useSyncExternalStore(
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
() => runtime.getVersion(),
|
() => runtime.getVersion(),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { createSessionStore, useSessionStore } from "@/stores/session-store";
|
||||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||||
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-initialization";
|
import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-initialization";
|
||||||
@@ -288,3 +288,45 @@ describe("refreshAgent", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["complete", "fail"] as const)(
|
||||||
|
"keeps routed initialization pending when direct initialization for the same address %s",
|
||||||
|
async (outcome) => {
|
||||||
|
const direct = createSessionStore();
|
||||||
|
const routed = createSessionStore();
|
||||||
|
for (const store of [direct, routed]) store.getState().initializeSession(serverId, null);
|
||||||
|
const directRuntime = new FakeTimelineRuntime();
|
||||||
|
const routedRuntime = new FakeTimelineRuntime();
|
||||||
|
const client = new FakeDaemonClient();
|
||||||
|
const start = (store: typeof direct, runtime: FakeTimelineRuntime) =>
|
||||||
|
ensureAgentIsInitialized({
|
||||||
|
store,
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
client: client as never,
|
||||||
|
runtime,
|
||||||
|
setAgentInitializing: createSetAgentInitializing(
|
||||||
|
serverId,
|
||||||
|
store.getState().setInitializingAgents,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const directReady = start(direct, directRuntime);
|
||||||
|
const routedReady = start(routed, routedRuntime);
|
||||||
|
void directReady.catch(() => {});
|
||||||
|
void routedReady.catch(() => {});
|
||||||
|
try {
|
||||||
|
expect(directReady).not.toBe(routedReady);
|
||||||
|
const key = getInitKey(serverId, agentId);
|
||||||
|
if (outcome === "complete") direct.initializations.resolveInitDeferred(key);
|
||||||
|
else direct.initializations.rejectInitDeferred(key, new Error("Direct history failed"));
|
||||||
|
await Promise.allSettled([directReady]);
|
||||||
|
expect(routed.initializations.getInitDeferred(key)?.promise).toBe(routedReady);
|
||||||
|
routed.initializations.resolveInitDeferred(key);
|
||||||
|
await routedReady;
|
||||||
|
} finally {
|
||||||
|
direct.initializations.resolveInitDeferred(getInitKey(serverId, agentId));
|
||||||
|
routed.initializations.resolveInitDeferred(getInitKey(serverId, agentId));
|
||||||
|
resolveInitDeferred(getInitKey(serverId, agentId));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
|
||||||
import {
|
import {
|
||||||
createInitDeferred,
|
selectAgentTimelineState,
|
||||||
getInitDeferred,
|
useSessionStore,
|
||||||
getInitKey,
|
useSessionStoreApi,
|
||||||
INIT_TIMEOUT_MS,
|
type SessionStoreApi,
|
||||||
rejectInitDeferred,
|
} from "@/stores/session-store";
|
||||||
refreshInitTimeout,
|
import { getInitKey, INIT_TIMEOUT_MS } from "@/utils/agent-initialization";
|
||||||
} from "@/utils/agent-initialization";
|
import { useHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
|
|
||||||
import { planTimelineResumeFetch, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
import { planTimelineResumeFetch, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||||
import { i18n } from "@/i18n/i18next";
|
import { i18n } from "@/i18n/i18next";
|
||||||
|
|
||||||
@@ -22,19 +20,22 @@ export function createHistorySyncTimeoutError(): Error {
|
|||||||
|
|
||||||
export function refreshAgentInitializationTimeout(input: {
|
export function refreshAgentInitializationTimeout(input: {
|
||||||
key: string;
|
key: string;
|
||||||
|
store?: SessionStoreApi;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
setAgentInitializing: SetAgentInitializing;
|
setAgentInitializing: SetAgentInitializing;
|
||||||
}): void {
|
}): void {
|
||||||
refreshInitTimeout({
|
const initializations = (input.store ?? useSessionStore).initializations;
|
||||||
|
initializations.refreshInitTimeout({
|
||||||
key: input.key,
|
key: input.key,
|
||||||
onTimeout: () => {
|
onTimeout: () => {
|
||||||
input.setAgentInitializing(input.agentId, false);
|
input.setAgentInitializing(input.agentId, false);
|
||||||
rejectInitDeferred(input.key, createHistorySyncTimeoutError());
|
initializations.rejectInitDeferred(input.key, createHistorySyncTimeoutError());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnsureAgentIsInitializedInput {
|
export interface EnsureAgentIsInitializedInput {
|
||||||
|
store?: SessionStoreApi;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
client: Pick<DaemonClient, "fetchAgentTimeline"> | null;
|
client: Pick<DaemonClient, "fetchAgentTimeline"> | null;
|
||||||
@@ -45,25 +46,27 @@ export interface EnsureAgentIsInitializedInput {
|
|||||||
|
|
||||||
export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): Promise<void> {
|
export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): Promise<void> {
|
||||||
const { serverId, agentId, client, setAgentInitializing } = input;
|
const { serverId, agentId, client, setAgentInitializing } = input;
|
||||||
|
const store = input.store ?? useSessionStore;
|
||||||
|
const initializations = store.initializations;
|
||||||
const key = getInitKey(serverId, agentId);
|
const key = getInitKey(serverId, agentId);
|
||||||
const existing = getInitDeferred(key);
|
const existing = initializations.getInitDeferred(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing.promise;
|
return existing.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId);
|
const timeline = selectAgentTimelineState(store.getState().sessions[serverId], agentId);
|
||||||
const timelineRequest = planTimelineResumeFetch(
|
const timelineRequest = planTimelineResumeFetch(
|
||||||
timeline.status === "synced" ? timeline.range : null,
|
timeline.status === "synced" ? timeline.range : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const deferred = createInitDeferred(key, timelineRequest.direction);
|
const deferred = initializations.createInitDeferred(key, timelineRequest.direction);
|
||||||
refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing });
|
refreshAgentInitializationTimeout({ key, store, agentId, setAgentInitializing });
|
||||||
|
|
||||||
setAgentInitializing(agentId, true);
|
setAgentInitializing(agentId, true);
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
setAgentInitializing(agentId, false);
|
setAgentInitializing(agentId, false);
|
||||||
rejectInitDeferred(
|
initializations.rejectInitDeferred(
|
||||||
key,
|
key,
|
||||||
new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")),
|
new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")),
|
||||||
);
|
);
|
||||||
@@ -72,7 +75,10 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
|
|||||||
|
|
||||||
input.runtime.fetchAgentTimeline(serverId, agentId, timelineRequest).catch((error) => {
|
input.runtime.fetchAgentTimeline(serverId, agentId, timelineRequest).catch((error) => {
|
||||||
setAgentInitializing(agentId, false);
|
setAgentInitializing(agentId, false);
|
||||||
rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error)));
|
initializations.rejectInitDeferred(
|
||||||
|
key,
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return deferred.promise;
|
return deferred.promise;
|
||||||
@@ -127,6 +133,8 @@ export function useAgentInitialization({
|
|||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const store = useSessionStoreApi();
|
||||||
|
const runtime = useHostRuntimeStore();
|
||||||
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
||||||
const setAgentInitializing = useMemo(
|
const setAgentInitializing = useMemo(
|
||||||
() => createSetAgentInitializing(serverId, setInitializingAgents),
|
() => createSetAgentInitializing(serverId, setInitializingAgents),
|
||||||
@@ -136,14 +144,15 @@ export function useAgentInitialization({
|
|||||||
const ensureAgentIsInitializedCallback = useCallback(
|
const ensureAgentIsInitializedCallback = useCallback(
|
||||||
(agentId: string): Promise<void> =>
|
(agentId: string): Promise<void> =>
|
||||||
ensureAgentIsInitialized({
|
ensureAgentIsInitialized({
|
||||||
|
store,
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
client,
|
client,
|
||||||
runtime: getHostRuntimeStore(),
|
runtime,
|
||||||
setAgentInitializing,
|
setAgentInitializing,
|
||||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||||
}),
|
}),
|
||||||
[client, serverId, setAgentInitializing, t],
|
[client, runtime, serverId, setAgentInitializing, store, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshAgentCallback = useCallback(
|
const refreshAgentCallback = useCallback(
|
||||||
@@ -152,11 +161,11 @@ export function useAgentInitialization({
|
|||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
client,
|
client,
|
||||||
runtime: getHostRuntimeStore(),
|
runtime,
|
||||||
setAgentInitializing,
|
setAgentInitializing,
|
||||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||||
}),
|
}),
|
||||||
[client, serverId, setAgentInitializing, t],
|
[client, runtime, serverId, setAgentInitializing, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ function createAgent(id: string): Agent {
|
|||||||
title: "Agent",
|
title: "Agent",
|
||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
model: null,
|
model: null,
|
||||||
parentAgentId: null,
|
parent: null,
|
||||||
|
managedRelationship: null,
|
||||||
labels: {},
|
labels: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { seedSessionHosts } from "@/test/seed-session";
|
import { seedSessionHosts } from "@/test/seed-session";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
@@ -38,7 +39,8 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
|||||||
title: "Agent 1",
|
title: "Agent 1",
|
||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
model: null,
|
model: null,
|
||||||
parentAgentId: null,
|
parent: null,
|
||||||
|
managedRelationship: null,
|
||||||
labels: {},
|
labels: {},
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -161,6 +163,7 @@ describe("useArchiveAgent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
applyArchivedAgentCloseResults({
|
applyArchivedAgentCloseResults({
|
||||||
|
runtime: getHostRuntimeStore(),
|
||||||
queryClient,
|
queryClient,
|
||||||
serverId: "server-a",
|
serverId: "server-a",
|
||||||
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
||||||
@@ -233,6 +236,7 @@ describe("useArchiveAgent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
applyArchivedAgentCloseResults({
|
applyArchivedAgentCloseResults({
|
||||||
|
runtime: getHostRuntimeStore(),
|
||||||
queryClient,
|
queryClient,
|
||||||
serverId: "server-a",
|
serverId: "server-a",
|
||||||
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { useHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import {
|
import { useQuery, useQueryClient, type QueryClient, type QueryKey } from "@tanstack/react-query";
|
||||||
useMutation,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
type QueryClient,
|
|
||||||
type QueryKey,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStoreApi, type SessionStoreApi } from "@/stores/session-store";
|
||||||
import { agentHistoryQueryKey, allAgentHistoryQueryRootKey } from "./agent-history-query-key";
|
import { agentHistoryQueryKey, allAgentHistoryQueryRootKey } from "./agent-history-query-key";
|
||||||
|
|
||||||
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
|
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
|
||||||
@@ -241,14 +235,15 @@ interface ArchiveAgentMutationContext {
|
|||||||
lists: ArchivedAgentListCacheSnapshot;
|
lists: ArchivedAgentListCacheSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStoredAgentSnapshot(input: ArchiveAgentInput) {
|
function getStoredAgentSnapshot(store: SessionStoreApi, input: ArchiveAgentInput) {
|
||||||
return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId);
|
return store.getState().sessions[input.serverId]?.agents.get(input.agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreAgentSnapshot(
|
function restoreAgentSnapshot(
|
||||||
|
runtime: HostRuntimeStore,
|
||||||
input: ArchiveAgentInput & { agent: ReturnType<typeof getStoredAgentSnapshot> },
|
input: ArchiveAgentInput & { agent: ReturnType<typeof getStoredAgentSnapshot> },
|
||||||
): void {
|
): void {
|
||||||
getHostRuntimeStore().restoreAgentSnapshot(input.serverId, input.agentId, input.agent);
|
runtime.restoreAgentSnapshot(input.serverId, input.agentId, input.agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getArchivedAgentListCacheSnapshot(
|
function getArchivedAgentListCacheSnapshot(
|
||||||
@@ -299,20 +294,20 @@ function restoreArchivedAgentListCacheSnapshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
|
function markAgentArchivedInStore(
|
||||||
|
runtime: HostRuntimeStore,
|
||||||
|
input: ArchiveAgentInput & { archivedAt: string },
|
||||||
|
): void {
|
||||||
const archivedAt = new Date(input.archivedAt);
|
const archivedAt = new Date(input.archivedAt);
|
||||||
if (Number.isNaN(archivedAt.getTime())) {
|
if (Number.isNaN(archivedAt.getTime())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
getHostRuntimeStore().archiveAgentSnapshot(
|
runtime.archiveAgentSnapshot(input.serverId, input.agentId, archivedAt.toISOString());
|
||||||
input.serverId,
|
|
||||||
input.agentId,
|
|
||||||
archivedAt.toISOString(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApplyArchivedAgentCloseResultsInput {
|
interface ApplyArchivedAgentCloseResultsInput {
|
||||||
|
runtime: HostRuntimeStore;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
results: ArchivedAgentCloseResult[];
|
results: ArchivedAgentCloseResult[];
|
||||||
@@ -325,7 +320,7 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const result of input.results) {
|
for (const result of input.results) {
|
||||||
markAgentArchivedInStore({
|
markAgentArchivedInStore(input.runtime, {
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
agentId: result.agentId,
|
agentId: result.agentId,
|
||||||
archivedAt: result.archivedAt,
|
archivedAt: result.archivedAt,
|
||||||
@@ -375,87 +370,69 @@ export function usePendingArchiveAgentIds(serverId: string): ReadonlySet<string>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The action owns optimistic state and rollback within its captured authority. */
|
||||||
|
export function createAgentArchiveAction({
|
||||||
|
runtime,
|
||||||
|
sessionStore,
|
||||||
|
queryClient,
|
||||||
|
unavailableMessage,
|
||||||
|
}: {
|
||||||
|
runtime: HostRuntimeStore;
|
||||||
|
sessionStore: SessionStoreApi;
|
||||||
|
queryClient: QueryClient;
|
||||||
|
unavailableMessage: string;
|
||||||
|
}): (input: ArchiveAgentInput) => Promise<void> {
|
||||||
|
return async (input) => {
|
||||||
|
const context: ArchiveAgentMutationContext = {
|
||||||
|
agent: getStoredAgentSnapshot(sessionStore, input),
|
||||||
|
lists: getArchivedAgentListCacheSnapshot(queryClient, input.serverId),
|
||||||
|
};
|
||||||
|
applyArchivedAgentCloseResults({
|
||||||
|
runtime,
|
||||||
|
queryClient,
|
||||||
|
serverId: input.serverId,
|
||||||
|
results: [{ agentId: input.agentId, archivedAt: new Date().toISOString() }],
|
||||||
|
invalidateQueries: false,
|
||||||
|
});
|
||||||
|
setAgentArchiving({ ...input, queryClient, isArchiving: true });
|
||||||
|
try {
|
||||||
|
const client = runtime.getClient(input.serverId);
|
||||||
|
if (!client) throw new Error(unavailableMessage);
|
||||||
|
const result = await client.archiveAgent(input.agentId);
|
||||||
|
markAgentArchivedInStore(runtime, { ...input, archivedAt: result.archivedAt });
|
||||||
|
} catch (error) {
|
||||||
|
restoreAgentSnapshot(runtime, { ...input, agent: context.agent });
|
||||||
|
restoreArchivedAgentListCacheSnapshot(queryClient, input.serverId, context.lists);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearArchiveAgentPending({ ...input, queryClient });
|
||||||
|
for (const queryKey of [
|
||||||
|
["sidebarAgentsList", input.serverId],
|
||||||
|
["allAgents", input.serverId],
|
||||||
|
agentHistoryQueryKey(input.serverId),
|
||||||
|
allAgentHistoryQueryRootKey(),
|
||||||
|
])
|
||||||
|
void queryClient.invalidateQueries({ queryKey });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useArchiveAgent() {
|
export function useArchiveAgent() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const runtime = useHostRuntimeStore();
|
||||||
|
const sessionStore = useSessionStoreApi();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const pendingQuery = useArchiveAgentPendingQuery();
|
const pendingQuery = useArchiveAgentPendingQuery();
|
||||||
|
const unavailableMessage = t("common.errors.daemonClientUnavailable");
|
||||||
const archiveMutation = useMutation({
|
const archiveAgent = useMemo(
|
||||||
mutationFn: async (input: ArchiveAgentInput): Promise<{ archivedAt: string }> => {
|
() =>
|
||||||
const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null;
|
createAgentArchiveAction({
|
||||||
if (!client) {
|
runtime,
|
||||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
sessionStore,
|
||||||
}
|
|
||||||
return await client.archiveAgent(input.agentId);
|
|
||||||
},
|
|
||||||
onMutate: (input) => {
|
|
||||||
const context: ArchiveAgentMutationContext = {
|
|
||||||
agent: getStoredAgentSnapshot(input),
|
|
||||||
lists: getArchivedAgentListCacheSnapshot(queryClient, input.serverId),
|
|
||||||
};
|
|
||||||
const archivedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
applyArchivedAgentCloseResults({
|
|
||||||
queryClient,
|
queryClient,
|
||||||
serverId: input.serverId,
|
unavailableMessage,
|
||||||
results: [{ agentId: input.agentId, archivedAt }],
|
}),
|
||||||
invalidateQueries: false,
|
[runtime, sessionStore, queryClient, unavailableMessage],
|
||||||
});
|
|
||||||
setAgentArchiving({
|
|
||||||
queryClient,
|
|
||||||
serverId: input.serverId,
|
|
||||||
agentId: input.agentId,
|
|
||||||
isArchiving: true,
|
|
||||||
});
|
|
||||||
return context;
|
|
||||||
},
|
|
||||||
onSuccess: (result, input) => {
|
|
||||||
markAgentArchivedInStore({
|
|
||||||
serverId: input.serverId,
|
|
||||||
agentId: input.agentId,
|
|
||||||
archivedAt: result.archivedAt,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (_error, input, context) => {
|
|
||||||
if (!context) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
restoreAgentSnapshot({
|
|
||||||
serverId: input.serverId,
|
|
||||||
agentId: input.agentId,
|
|
||||||
agent: context.agent,
|
|
||||||
});
|
|
||||||
restoreArchivedAgentListCacheSnapshot(queryClient, input.serverId, context.lists);
|
|
||||||
},
|
|
||||||
onSettled: (_result, _error, input) => {
|
|
||||||
clearArchiveAgentPending({
|
|
||||||
queryClient,
|
|
||||||
serverId: input.serverId,
|
|
||||||
agentId: input.agentId,
|
|
||||||
});
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: ["sidebarAgentsList", input.serverId],
|
|
||||||
});
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: ["allAgents", input.serverId],
|
|
||||||
});
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: agentHistoryQueryKey(input.serverId),
|
|
||||||
});
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: allAgentHistoryQueryRootKey(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const archiveMutateAsync = archiveMutation.mutateAsync;
|
|
||||||
|
|
||||||
const archiveAgent = useCallback(
|
|
||||||
async (input: ArchiveAgentInput): Promise<void> => {
|
|
||||||
await archiveMutateAsync(input);
|
|
||||||
},
|
|
||||||
[archiveMutateAsync],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const isArchivingAgent = useCallback(
|
const isArchivingAgent = useCallback(
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { i18n } from "@/i18n/i18next";
|
|||||||
import {
|
import {
|
||||||
selectAgentTimelineState,
|
selectAgentTimelineState,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
|
useSessionStoreApi,
|
||||||
type AgentTimelineCursorState,
|
type AgentTimelineCursorState,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan";
|
import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { useHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
|
|
||||||
export interface LoadOlderAgentHistoryClient {
|
export interface LoadOlderAgentHistoryClient {
|
||||||
fetchAgentTimeline: (
|
fetchAgentTimeline: (
|
||||||
@@ -78,6 +79,8 @@ export function useLoadOlderAgentHistory({
|
|||||||
toast?: ToastApi | null;
|
toast?: ToastApi | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const sessionStore = useSessionStoreApi();
|
||||||
|
const runtime = useHostRuntimeStore();
|
||||||
const hasOlder = useSessionStore((state) => {
|
const hasOlder = useSessionStore((state) => {
|
||||||
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
|
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
|
||||||
return timeline.status === "synced" && timeline.older === "available";
|
return timeline.status === "synced" && timeline.older === "available";
|
||||||
@@ -110,13 +113,13 @@ export function useLoadOlderAgentHistory({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const loadOlder = useCallback(async (): Promise<boolean> => {
|
const loadOlder = useCallback(async (): Promise<boolean> => {
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
const session = sessionStore.getState().sessions[serverId];
|
||||||
const timeline = selectAgentTimelineState(session, agentId);
|
const timeline = selectAgentTimelineState(session, agentId);
|
||||||
return await loadOlderAgentHistory(agentId, {
|
return await loadOlderAgentHistory(agentId, {
|
||||||
client: session?.client
|
client: session?.client
|
||||||
? {
|
? {
|
||||||
fetchAgentTimeline: (timelineAgentId, request) =>
|
fetchAgentTimeline: (timelineAgentId, request) =>
|
||||||
getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request),
|
runtime.fetchAgentTimeline(serverId, timelineAgentId, request),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
cursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined,
|
cursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined,
|
||||||
@@ -126,7 +129,7 @@ export function useLoadOlderAgentHistory({
|
|||||||
toast,
|
toast,
|
||||||
failedMessage: t("loadOlderHistory.failed"),
|
failedMessage: t("loadOlderHistory.failed"),
|
||||||
});
|
});
|
||||||
}, [agentId, serverId, setInFlight, toast, t]);
|
}, [agentId, serverId, setInFlight, toast, t, runtime, sessionStore]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoadingOlder,
|
isLoadingOlder,
|
||||||
|
|||||||
@@ -428,3 +428,41 @@ describe("host runtime app lifecycle", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores a qualified destination through its saved origin host boundary", () => {
|
||||||
|
const selection = { serverId: "B", workspaceId: "workspace", authority: { originServerId: "A" } };
|
||||||
|
expect(
|
||||||
|
resolveStartupRoute({
|
||||||
|
route: { kind: "index", pathname: "/" },
|
||||||
|
startupBlocker: { kind: "none" },
|
||||||
|
hostRegistryStatus: "ready",
|
||||||
|
hosts: [{ serverId: "A" }],
|
||||||
|
anyOnlineHostServerId: "A",
|
||||||
|
workspaceSelection: selection,
|
||||||
|
workspaceSelectionStatus: "unknown",
|
||||||
|
isWorkspaceSelectionLoaded: true,
|
||||||
|
hasGivenUpWaitingForHost: false,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "redirect", href: "/h/B?origin=A" });
|
||||||
|
expect(
|
||||||
|
resolveHostIndexRoute({
|
||||||
|
serverId: "B",
|
||||||
|
workspaceSelection: selection,
|
||||||
|
workspaceSelectionStatus: "exists",
|
||||||
|
}),
|
||||||
|
).toBe("/h/B/workspace/workspace?origin=A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("authorizes a named host route by its intended origin and never substitutes a directly connected destination", () => {
|
||||||
|
const route = { kind: "host" as const, serverId: "B", originServerId: "A" };
|
||||||
|
const base = {
|
||||||
|
route,
|
||||||
|
startupBlocker: { kind: "none" as const },
|
||||||
|
hostRegistryStatus: "ready" as const,
|
||||||
|
};
|
||||||
|
expect(resolveStartupRoute({ ...base, hosts: [{ serverId: "A" }] })).toEqual({ kind: "render" });
|
||||||
|
expect(resolveStartupRoute({ ...base, hosts: [{ serverId: "B" }] })).toEqual({
|
||||||
|
kind: "redirect",
|
||||||
|
href: "/open-project",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AppState } from "react-native";
|
import type { AppState } from "react-native";
|
||||||
|
import { workspaceSelectionAuthority } from "@/stores/last-workspace-selection";
|
||||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import type {
|
import type {
|
||||||
DaemonStartCondition,
|
DaemonStartCondition,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
buildOpenProjectRoute,
|
buildOpenProjectRoute,
|
||||||
|
withHostRouteAuthority,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
export interface HostRuntimeBootstrapStore {
|
export interface HostRuntimeBootstrapStore {
|
||||||
@@ -100,6 +102,7 @@ export interface IndexStartupRouteTarget {
|
|||||||
export interface HostStartupRouteTarget {
|
export interface HostStartupRouteTarget {
|
||||||
kind: "host";
|
kind: "host";
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
|
originServerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StartupRouteTarget = IndexStartupRouteTarget | HostStartupRouteTarget;
|
export type StartupRouteTarget = IndexStartupRouteTarget | HostStartupRouteTarget;
|
||||||
@@ -161,7 +164,10 @@ export function resolveHostIndexRoute(input: {
|
|||||||
input.workspaceSelection?.serverId === input.serverId &&
|
input.workspaceSelection?.serverId === input.serverId &&
|
||||||
shouldRestoreWorkspaceSelection(input)
|
shouldRestoreWorkspaceSelection(input)
|
||||||
) {
|
) {
|
||||||
return buildHostWorkspaceRoute(input.serverId, input.workspaceSelection.workspaceId);
|
const route = buildHostWorkspaceRoute(input.serverId, input.workspaceSelection.workspaceId);
|
||||||
|
return input.workspaceSelection.authority
|
||||||
|
? (withHostRouteAuthority(route, input.workspaceSelection.authority) as Href)
|
||||||
|
: route;
|
||||||
}
|
}
|
||||||
return buildOpenProjectRoute();
|
return buildOpenProjectRoute();
|
||||||
}
|
}
|
||||||
@@ -188,13 +194,18 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
shouldRestoreWorkspaceSelection(input) &&
|
shouldRestoreWorkspaceSelection(input) &&
|
||||||
hostExists(input.hosts, input.workspaceSelection.serverId)
|
hostExists(input.hosts, workspaceSelectionAuthority(input.workspaceSelection).originServerId)
|
||||||
) {
|
) {
|
||||||
// Native cold launch must enter the host boundary first. The host index
|
// Native cold launch must enter the host boundary first. The host index
|
||||||
// owns workspace restore after its local dynamic params exist.
|
// owns workspace restore after its local dynamic params exist.
|
||||||
return {
|
return {
|
||||||
kind: "redirect",
|
kind: "redirect",
|
||||||
href: buildHostRootRoute(input.workspaceSelection.serverId),
|
href: (input.workspaceSelection.authority
|
||||||
|
? withHostRouteAuthority(
|
||||||
|
buildHostRootRoute(input.workspaceSelection.serverId),
|
||||||
|
input.workspaceSelection.authority,
|
||||||
|
)
|
||||||
|
: buildHostRootRoute(input.workspaceSelection.serverId)) as Href,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +226,7 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveReadyHostStartupRoute(input: ResolveHostStartupRouteInput): StartupRouteDecision {
|
function resolveReadyHostStartupRoute(input: ResolveHostStartupRouteInput): StartupRouteDecision {
|
||||||
if (hostExists(input.hosts, input.route.serverId)) {
|
if (hostExists(input.hosts, input.route.originServerId ?? input.route.serverId)) {
|
||||||
return { kind: "render" };
|
return { kind: "render" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ function dispatchHostWorkspacePopTo(
|
|||||||
serverId: selection.serverId,
|
serverId: selection.serverId,
|
||||||
workspaceId: encodeWorkspaceIdForPathSegment(selection.workspaceId),
|
workspaceId: encodeWorkspaceIdForPathSegment(selection.workspaceId),
|
||||||
...(open ? { open } : {}),
|
...(open ? { open } : {}),
|
||||||
|
...(selection.authority ? { origin: selection.authority.originServerId } : {}),
|
||||||
},
|
},
|
||||||
// React Navigation consumes this nested hint when resolving the host child screen.
|
// React Navigation consumes this nested hint when resolving the host child screen.
|
||||||
// The browser-route canonicalizer strips the resulting ?pop=true URL artifact.
|
// The browser-route canonicalizer strips the resulting ?pop=true URL artifact.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { formatAgentModeLabel } from "@/agent-controls/labels";
|
|||||||
import { formatTokenCount } from "@/components/context-window-meter.utils";
|
import { formatTokenCount } from "@/components/context-window-meter.utils";
|
||||||
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
|
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
|
|
||||||
export interface AgentFactsInput {
|
export interface AgentFactsInput {
|
||||||
models: AgentModelDefinition[] | null;
|
models: AgentModelDefinition[] | null;
|
||||||
@@ -36,30 +36,45 @@ export function buildAgentFactsLine(input: AgentFactsInput): string | null {
|
|||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentFactsSnapshot extends Omit<AgentFactsInput, "models"> {
|
||||||
|
provider: string;
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentFactsSnapshot(agent: Agent): AgentFactsSnapshot {
|
||||||
|
return {
|
||||||
|
provider: agent.provider,
|
||||||
|
cwd: agent.cwd,
|
||||||
|
runtimeModelId: agent.runtimeInfo?.model ?? null,
|
||||||
|
configuredModelId: agent.model,
|
||||||
|
runtimeThinkingOptionId: agent.runtimeInfo?.thinkingOptionId ?? null,
|
||||||
|
explicitThinkingOptionId: agent.thinkingOptionId ?? null,
|
||||||
|
availableModes: agent.availableModes,
|
||||||
|
currentModeId: agent.currentModeId,
|
||||||
|
contextWindowUsedTokens: agent.lastUsage?.contextWindowUsedTokens ?? null,
|
||||||
|
contextWindowMaxTokens: agent.lastUsage?.contextWindowMaxTokens ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uses the destination binding's catalog query and push owner for the supplied snapshot. */
|
||||||
|
export function useAgentSnapshotFactsLine(
|
||||||
|
serverId: string,
|
||||||
|
agent: AgentFactsSnapshot | null,
|
||||||
|
): string | null {
|
||||||
|
const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
|
||||||
|
const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null;
|
||||||
|
return agent ? buildAgentFactsLine({ ...agent, models }) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** The facts line for a Paseo agent in the session store, kept current as the agent reports. */
|
/** The facts line for a Paseo agent in the session store, kept current as the agent reports. */
|
||||||
export function useAgentFactsLine(serverId: string, agentId: string): string | null {
|
export function useAgentFactsLine(serverId: string, agentId: string): string | null {
|
||||||
const agent = useSessionStore(
|
const agent = useSessionStore(
|
||||||
useShallow((state) => {
|
useShallow((state) => {
|
||||||
const current = state.sessions[serverId]?.agents.get(agentId);
|
const current = state.sessions[serverId]?.agents.get(agentId);
|
||||||
return current
|
return current ? agentFactsSnapshot(current) : null;
|
||||||
? {
|
|
||||||
provider: current.provider,
|
|
||||||
cwd: current.cwd,
|
|
||||||
runtimeModelId: current.runtimeInfo?.model ?? null,
|
|
||||||
configuredModelId: current.model ?? null,
|
|
||||||
runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null,
|
|
||||||
explicitThinkingOptionId: current.thinkingOptionId ?? null,
|
|
||||||
availableModes: current.availableModes,
|
|
||||||
currentModeId: current.currentModeId,
|
|
||||||
contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null,
|
|
||||||
contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
|
return useAgentSnapshotFactsLine(serverId, agent);
|
||||||
const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null;
|
|
||||||
return agent ? buildAgentFactsLine({ ...agent, models }) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The mode control shows the current mode, or the provider's first one until the agent reports. */
|
/** The mode control shows the current mode, or the provider's first one until the agent reports. */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { useHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
@@ -87,6 +87,8 @@ import {
|
|||||||
selectAgentTurnPresentation,
|
selectAgentTurnPresentation,
|
||||||
type Agent,
|
type Agent,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
|
useSessionStoreApi,
|
||||||
|
type SessionStoreApi,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
@@ -97,7 +99,7 @@ import type { PendingPermission } from "@/types/shared";
|
|||||||
import type { StreamItem, TodoEntry } from "@/types/stream";
|
import type { StreamItem, TodoEntry } from "@/types/stream";
|
||||||
import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
|
import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
|
||||||
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
|
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
|
||||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
import { getInitKey } from "@/utils/agent-initialization";
|
||||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
||||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||||
@@ -288,6 +290,8 @@ function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean {
|
|||||||
type FetchAgentResult = Awaited<ReturnType<DaemonClient["fetchAgent"]>>;
|
type FetchAgentResult = Awaited<ReturnType<DaemonClient["fetchAgent"]>>;
|
||||||
|
|
||||||
function storeFetchedAgentDetail(input: {
|
function storeFetchedAgentDetail(input: {
|
||||||
|
runtime: HostRuntimeStore;
|
||||||
|
sessionStore: SessionStoreApi;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
result: NonNullable<FetchAgentResult>;
|
result: NonNullable<FetchAgentResult>;
|
||||||
}): Agent {
|
}): Agent {
|
||||||
@@ -299,10 +303,10 @@ function storeFetchedAgentDetail(input: {
|
|||||||
projectPlacement: input.result.project,
|
projectPlacement: input.result.project,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const store = useSessionStore.getState();
|
const store = input.sessionStore.getState();
|
||||||
|
|
||||||
if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) {
|
if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) {
|
||||||
getHostRuntimeStore().acceptAgentSnapshot(input.serverId, hydrated);
|
input.runtime.acceptAgentSnapshot(input.serverId, hydrated);
|
||||||
} else {
|
} else {
|
||||||
store.setAgentDetails(input.serverId, (previous) => {
|
store.setAgentDetails(input.serverId, (previous) => {
|
||||||
const next = new Map(previous);
|
const next = new Map(previous);
|
||||||
@@ -388,6 +392,7 @@ function AgentPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DraftPanel() {
|
function DraftPanel() {
|
||||||
|
const runtime = useHostRuntimeStore();
|
||||||
const {
|
const {
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -404,10 +409,10 @@ function DraftPanel() {
|
|||||||
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
||||||
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
||||||
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
|
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
|
||||||
getHostRuntimeStore().acceptAgentSnapshot(serverId, agent);
|
runtime.acceptAgentSnapshot(serverId, agent);
|
||||||
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
||||||
},
|
},
|
||||||
[retargetCurrentTab, serverId],
|
[retargetCurrentTab, runtime, serverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -593,10 +598,12 @@ function AgentPanelBody({
|
|||||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const sessionStore = useSessionStoreApi();
|
||||||
|
const runtime = useHostRuntimeStore();
|
||||||
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
|
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
|
||||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||||
const projectPlacement = useStoreWithEqualityFn(
|
const projectPlacement = useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
(state) => {
|
(state) => {
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -656,7 +663,7 @@ function AgentPanelBody({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
storeFetchedAgentDetail({ serverId, result });
|
storeFetchedAgentDetail({ serverId, result, sessionStore, runtime });
|
||||||
setLookupState({ tag: "idle" });
|
setLookupState({ tag: "idle" });
|
||||||
return;
|
return;
|
||||||
})
|
})
|
||||||
@@ -671,7 +678,17 @@ function AgentPanelBody({
|
|||||||
}
|
}
|
||||||
setLookupState({ tag: "error", message });
|
setLookupState({ tag: "error", message });
|
||||||
});
|
});
|
||||||
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
|
}, [
|
||||||
|
agentId,
|
||||||
|
agentState.id,
|
||||||
|
client,
|
||||||
|
hasSession,
|
||||||
|
isConnected,
|
||||||
|
lookupState.tag,
|
||||||
|
runtime,
|
||||||
|
serverId,
|
||||||
|
sessionStore,
|
||||||
|
]);
|
||||||
|
|
||||||
if (lookupState.tag === "not_found") {
|
if (lookupState.tag === "not_found") {
|
||||||
return (
|
return (
|
||||||
@@ -748,6 +765,8 @@ function ChatAgentContent({
|
|||||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const sessionStore = useSessionStoreApi();
|
||||||
|
const runtime = useHostRuntimeStore();
|
||||||
const isPaneVisible = useRetainedPanelActive();
|
const isPaneVisible = useRetainedPanelActive();
|
||||||
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
|
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
|
||||||
const { isArchivingAgent } = useArchiveAgent();
|
const { isArchivingAgent } = useArchiveAgent();
|
||||||
@@ -763,7 +782,7 @@ function ChatAgentContent({
|
|||||||
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
|
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
|
||||||
);
|
);
|
||||||
const projectPlacement = useStoreWithEqualityFn(
|
const projectPlacement = useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
(state) => {
|
(state) => {
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -863,8 +882,8 @@ function ChatAgentContent({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const initKey = getInitKey(serverId, agentId);
|
const initKey = getInitKey(serverId, agentId);
|
||||||
return Boolean(getInitDeferred(initKey));
|
return Boolean(sessionStore.initializations.getInitDeferred(initKey));
|
||||||
}, [agentId, isInitializing, serverId]);
|
}, [agentId, isInitializing, serverId, sessionStore]);
|
||||||
const needsAuthoritativeSync = useMemo(() => {
|
const needsAuthoritativeSync = useMemo(() => {
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1005,7 +1024,7 @@ function ChatAgentContent({
|
|||||||
if (attemptToken !== initAttemptTokenRef.current) {
|
if (attemptToken !== initAttemptTokenRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentSession = useSessionStore.getState().sessions[serverId];
|
const currentSession = sessionStore.getState().sessions[serverId];
|
||||||
const currentAgent =
|
const currentAgent =
|
||||||
currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId);
|
currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId);
|
||||||
if (!currentAgent) {
|
if (!currentAgent) {
|
||||||
@@ -1020,7 +1039,7 @@ function ChatAgentContent({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
storeFetchedAgentDetail({ serverId, result });
|
storeFetchedAgentDetail({ serverId, result, sessionStore, runtime });
|
||||||
}
|
}
|
||||||
if (attemptToken !== initAttemptTokenRef.current) {
|
if (attemptToken !== initAttemptTokenRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -1050,6 +1069,8 @@ function ChatAgentContent({
|
|||||||
isPaneVisible,
|
isPaneVisible,
|
||||||
missingAgentState.kind,
|
missingAgentState.kind,
|
||||||
serverId,
|
serverId,
|
||||||
|
sessionStore,
|
||||||
|
runtime,
|
||||||
viewedTimelineSync,
|
viewedTimelineSync,
|
||||||
visibilityCatchUpStatus,
|
visibilityCatchUpStatus,
|
||||||
]);
|
]);
|
||||||
@@ -1440,7 +1461,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
|||||||
);
|
);
|
||||||
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
|
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
|
||||||
const pendingPermissionList = useStoreWithEqualityFn(
|
const pendingPermissionList = useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
(state) => {
|
(state) => {
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
return EMPTY_PENDING_PERMISSION_LIST;
|
return EMPTY_PENDING_PERMISSION_LIST;
|
||||||
@@ -1573,6 +1594,7 @@ function ActiveAgentComposer({
|
|||||||
{ initialIsBelow: isCompactFormFactor },
|
{ initialIsBelow: isCompactFormFactor },
|
||||||
);
|
);
|
||||||
const paneContext = usePaneContext();
|
const paneContext = usePaneContext();
|
||||||
|
const sessionStore = useSessionStoreApi();
|
||||||
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
||||||
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
|
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
|
||||||
const { archiveAgent } = useArchiveAgent();
|
const { archiveAgent } = useArchiveAgent();
|
||||||
@@ -1604,7 +1626,7 @@ function ActiveAgentComposer({
|
|||||||
|
|
||||||
const handleClientSlashCommand = useCallback(
|
const handleClientSlashCommand = useCallback(
|
||||||
async (command: ClientSlashCommand) => {
|
async (command: ClientSlashCommand) => {
|
||||||
const agent = resolveChatAgentFromSession(useSessionStore.getState(), serverId, agentId);
|
const agent = resolveChatAgentFromSession(sessionStore.getState(), serverId, agentId);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
throw new Error("Agent not found");
|
throw new Error("Agent not found");
|
||||||
}
|
}
|
||||||
@@ -1634,6 +1656,7 @@ function ActiveAgentComposer({
|
|||||||
hideWorkspaceAgent,
|
hideWorkspaceAgent,
|
||||||
retargetCurrentTab,
|
retargetCurrentTab,
|
||||||
serverId,
|
serverId,
|
||||||
|
sessionStore,
|
||||||
tabId,
|
tabId,
|
||||||
unpinWorkspaceAgent,
|
unpinWorkspaceAgent,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
import { useSettings } from "@/hooks/use-settings";
|
import { useSettings } from "@/hooks/use-settings";
|
||||||
import { PluginComposerPills } from "@/plugins";
|
import { PluginComposerPills } from "@/plugins";
|
||||||
|
import { subagentRowBelongsToWorkspace } from "@/subagents/opens-in-sheet";
|
||||||
|
import type { PaseoSubagentRow } from "@/subagents/select";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
type ArchiveFinishedStatus,
|
type ArchiveFinishedStatus,
|
||||||
@@ -19,8 +21,8 @@ import { SubagentsTrack } from "@/subagents/track";
|
|||||||
import type { TodoEntry } from "@/types/stream";
|
import type { TodoEntry } from "@/types/stream";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { openPreferredWorkspaceTarget } from "@/workspace-tabs/open-beside";
|
import { useWorkspaceTargetActions } from "@/workspace-tabs/open-beside";
|
||||||
import { openComposerChanges } from "@/workspace-tabs/open-supporting-view";
|
import { useSupportingViewActions } from "@/workspace-tabs/open-supporting-view";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above
|
* The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above
|
||||||
@@ -49,6 +51,8 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
hasPluginComposerPills: boolean;
|
hasPluginComposerPills: boolean;
|
||||||
}): ReactElement | null {
|
}): ReactElement | null {
|
||||||
const { tabId } = usePaneContext();
|
const { tabId } = usePaneContext();
|
||||||
|
const { openPreferredWorkspaceTarget } = useWorkspaceTargetActions();
|
||||||
|
const { openComposerChanges } = useSupportingViewActions();
|
||||||
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
||||||
@@ -56,8 +60,8 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
const canDetachSubagents = useSessionStore(
|
const canDetachSubagents = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
|
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
|
||||||
);
|
);
|
||||||
const archiveSubagent = useArchiveSubagent({ serverId });
|
const archiveSubagent = useArchiveSubagent({ parent: { serverId, agentId } });
|
||||||
const detachSubagent = useDetachSubagent({ serverId });
|
const detachSubagent = useDetachSubagent({ parent: { serverId, agentId } });
|
||||||
const openSubagentTarget = useCallback(
|
const openSubagentTarget = useCallback(
|
||||||
(target: WorkspaceTabTarget) => {
|
(target: WorkspaceTabTarget) => {
|
||||||
openPreferredWorkspaceTarget({
|
openPreferredWorkspaceTarget({
|
||||||
@@ -68,18 +72,19 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
parentTabId: tabId,
|
parentTabId: tabId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[openInSidePane, tabId, workspaceKey],
|
[openInSidePane, openPreferredWorkspaceTarget, tabId, workspaceKey],
|
||||||
);
|
);
|
||||||
const handleOpenSubagent = useCallback(
|
const handleOpenSubagent = useCallback(
|
||||||
(subagentId: string) => {
|
(row: PaseoSubagentRow) => {
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
if (!subagentRowBelongsToWorkspace(row, { serverId, workspaceId })) {
|
||||||
const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId);
|
navigateToAgent({
|
||||||
// A subagent in another workspace lives in that workspace's tabs.
|
...row.address,
|
||||||
if (agent?.workspaceId && agent.workspaceId !== workspaceId) {
|
workspaceId: row.workspace?.workspaceId,
|
||||||
navigateToAgent({ serverId, agentId: subagentId });
|
authority: row.authority,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openSubagentTarget({ kind: "agent", agentId: subagentId });
|
openSubagentTarget({ kind: "agent", agentId: row.address.agentId });
|
||||||
},
|
},
|
||||||
[openSubagentTarget, serverId, workspaceId],
|
[openSubagentTarget, serverId, workspaceId],
|
||||||
);
|
);
|
||||||
@@ -89,7 +94,7 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
},
|
},
|
||||||
[openSubagentTarget],
|
[openSubagentTarget],
|
||||||
);
|
);
|
||||||
const opensInSheet = useSubagentOpensInSheet(workspaceKey);
|
const opensInSheet = useSubagentOpensInSheet(workspaceKey, { serverId, workspaceId });
|
||||||
const handleOpenChanges = useCallback(() => {
|
const handleOpenChanges = useCallback(() => {
|
||||||
if (!workspaceKey) {
|
if (!workspaceKey) {
|
||||||
return;
|
return;
|
||||||
@@ -99,7 +104,7 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
workspaceKey,
|
workspaceKey,
|
||||||
preferences: openInSidePane,
|
preferences: openInSidePane,
|
||||||
});
|
});
|
||||||
}, [isCompact, openInSidePane, workspaceKey]);
|
}, [isCompact, openComposerChanges, openInSidePane, workspaceKey]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!hasWorkspaceDiffStat &&
|
!hasWorkspaceDiffStat &&
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
refreshProviderSubagents,
|
refreshProviderSubagents,
|
||||||
observeProviderSubagentTimeline,
|
observeProviderSubagentTimeline,
|
||||||
useProviderSubagentStore,
|
useProviderSubagentStore,
|
||||||
|
useProviderSubagentStoreApi,
|
||||||
} from "@/subagents/provider-store";
|
} from "@/subagents/provider-store";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
@@ -114,6 +115,7 @@ function useProviderSubagentDescriptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProviderSubagentPanel() {
|
function ProviderSubagentPanel() {
|
||||||
|
const providerStore = useProviderSubagentStoreApi();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext();
|
const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext();
|
||||||
invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target");
|
invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target");
|
||||||
@@ -145,6 +147,7 @@ function ProviderSubagentPanel() {
|
|||||||
const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact);
|
const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact);
|
||||||
const opensInSheet = useSubagentOpensInSheet(
|
const opensInSheet = useSubagentOpensInSheet(
|
||||||
buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||||
|
{ serverId, workspaceId },
|
||||||
);
|
);
|
||||||
const openProviderChild = useCallback(
|
const openProviderChild = useCallback(
|
||||||
(parentAgentId: string, subagentId: string) => {
|
(parentAgentId: string, subagentId: string) => {
|
||||||
@@ -155,13 +158,16 @@ function ProviderSubagentPanel() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client || !supported) return;
|
if (!client || !supported) return;
|
||||||
void refreshProviderSubagents(client, serverId, target.parentAgentId).catch(() => undefined);
|
void refreshProviderSubagents(client, serverId, target.parentAgentId, providerStore).catch(
|
||||||
}, [client, serverId, supported, target.parentAgentId]);
|
() => undefined,
|
||||||
|
);
|
||||||
|
}, [client, providerStore, serverId, supported, target.parentAgentId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client || !supported) return;
|
if (!client || !supported) return;
|
||||||
return observeProviderSubagentTimeline({
|
return observeProviderSubagentTimeline({
|
||||||
client,
|
client,
|
||||||
|
store: providerStore,
|
||||||
serverId,
|
serverId,
|
||||||
parentAgentId: target.parentAgentId,
|
parentAgentId: target.parentAgentId,
|
||||||
subagentId: target.subagentId,
|
subagentId: target.subagentId,
|
||||||
@@ -175,7 +181,7 @@ function ProviderSubagentPanel() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [client, supported, serverId, target.parentAgentId, target.subagentId]);
|
}, [client, providerStore, supported, serverId, target.parentAgentId, target.subagentId]);
|
||||||
|
|
||||||
const loadOlder = useCallback((): boolean => {
|
const loadOlder = useCallback((): boolean => {
|
||||||
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) {
|
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) {
|
||||||
@@ -191,7 +197,7 @@ function ProviderSubagentPanel() {
|
|||||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||||
})
|
})
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
|
providerStore.getState().replaceTimeline(serverId, payload);
|
||||||
return undefined;
|
return undefined;
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
@@ -199,6 +205,7 @@ function ProviderSubagentPanel() {
|
|||||||
return true;
|
return true;
|
||||||
}, [
|
}, [
|
||||||
client,
|
client,
|
||||||
|
providerStore,
|
||||||
isLoadingOlder,
|
isLoadingOlder,
|
||||||
serverId,
|
serverId,
|
||||||
supported,
|
supported,
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ const agent: Agent = {
|
|||||||
thinkingOptionId: "high",
|
thinkingOptionId: "high",
|
||||||
requiresAttention: true,
|
requiresAttention: true,
|
||||||
attentionReason: "permission",
|
attentionReason: "permission",
|
||||||
parentAgentId: null,
|
parent: null,
|
||||||
|
managedRelationship: null,
|
||||||
labels: { phase: "implementation" },
|
labels: { phase: "implementation" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,6 +104,7 @@ describe("plugin context snapshots", () => {
|
|||||||
thinkingOptionId: "high",
|
thinkingOptionId: "high",
|
||||||
requiresAttention: true,
|
requiresAttention: true,
|
||||||
attentionReason: "permission",
|
attentionReason: "permission",
|
||||||
|
parent: null,
|
||||||
parentAgentId: null,
|
parentAgentId: null,
|
||||||
labels: { phase: "implementation" },
|
labels: { phase: "implementation" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
|
|||||||
thinkingOptionId: agent.thinkingOptionId ?? null,
|
thinkingOptionId: agent.thinkingOptionId ?? null,
|
||||||
requiresAttention: agent.requiresAttention ?? false,
|
requiresAttention: agent.requiresAttention ?? false,
|
||||||
attentionReason: agent.attentionReason ?? null,
|
attentionReason: agent.attentionReason ?? null,
|
||||||
parentAgentId: agent.parentAgentId,
|
parent: agent.parent ? Object.freeze({ ...agent.parent }) : null,
|
||||||
|
// COMPAT(pluginParentId): added in v0.12.0, remove after 2027-04-20.
|
||||||
|
parentAgentId: agent.parent?.agentId ?? null,
|
||||||
labels: Object.freeze({ ...agent.labels }),
|
labels: Object.freeze({ ...agent.labels }),
|
||||||
});
|
});
|
||||||
const byWorkspace = agentSnapshots.get(agent) ?? new Map<string, PluginAgentSnapshot>();
|
const byWorkspace = agentSnapshots.get(agent) ?? new Map<string, PluginAgentSnapshot>();
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const agent: PluginAgentSnapshot = {
|
|||||||
thinkingOptionId: null,
|
thinkingOptionId: null,
|
||||||
requiresAttention: false,
|
requiresAttention: false,
|
||||||
attentionReason: null,
|
attentionReason: null,
|
||||||
|
parent: null,
|
||||||
parentAgentId: null,
|
parentAgentId: null,
|
||||||
labels: {},
|
labels: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,14 +4,18 @@ import {
|
|||||||
getReviewDraftComments,
|
getReviewDraftComments,
|
||||||
clearSentReviewDraftComments,
|
clearSentReviewDraftComments,
|
||||||
} from "../store";
|
} from "../store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import {
|
||||||
|
useWorkspaceLayoutStore,
|
||||||
|
useWorkspaceLayoutStoreApi,
|
||||||
|
type WorkspaceLayoutStoreApi,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
|
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { dispatchComposerAgentMessage } from "@/composer/actions";
|
import { dispatchComposerAgentMessage } from "@/composer/actions";
|
||||||
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
|
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
|
||||||
import { useSessionStore, selectAgentTurnPresentation } from "@/stores/session-store";
|
import { useSessionStoreApi, selectAgentTurnPresentation } from "@/stores/session-store";
|
||||||
import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button";
|
import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { buttonControlHeight } from "@/components/ui/control-geometry";
|
import { buttonControlHeight } from "@/components/ui/control-geometry";
|
||||||
@@ -35,6 +39,8 @@ export function useReviewFeedback(input: {
|
|||||||
}) {
|
}) {
|
||||||
const { serverId, reviewDraftKey, workspaceId } = input;
|
const { serverId, reviewDraftKey, workspaceId } = input;
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const layoutStore = useWorkspaceLayoutStoreApi();
|
||||||
|
const sessionStore = useSessionStoreApi();
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const workspaceKey = workspaceId
|
const workspaceKey = workspaceId
|
||||||
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
|
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
|
||||||
@@ -68,7 +74,7 @@ export function useReviewFeedback(input: {
|
|||||||
comments: getReviewDraftComments(reviewDraftKey) ?? [],
|
comments: getReviewDraftComments(reviewDraftKey) ?? [],
|
||||||
attachment: live.current.attachment,
|
attachment: live.current.attachment,
|
||||||
connected: live.current.connected,
|
connected: live.current.connected,
|
||||||
recipients: readRecipients(workspaceKey),
|
recipients: readRecipients(workspaceKey, layoutStore),
|
||||||
}),
|
}),
|
||||||
send: async (recipient, attachment) => {
|
send: async (recipient, attachment) => {
|
||||||
const { client: targetClient, sendBehavior } = live.current;
|
const { client: targetClient, sendBehavior } = live.current;
|
||||||
@@ -80,12 +86,12 @@ export function useReviewFeedback(input: {
|
|||||||
text: i18n.t("review.feedback.prompt"),
|
text: i18n.t("review.feedback.prompt"),
|
||||||
attachments: [attachment],
|
attachments: [attachment],
|
||||||
encodeImages: async () => [],
|
encodeImages: async () => [],
|
||||||
submission: createMessageSubmissionWriter(serverId),
|
submission: createMessageSubmissionWriter(serverId, sessionStore),
|
||||||
activeTurnBehavior,
|
activeTurnBehavior,
|
||||||
activeTurnId:
|
activeTurnId:
|
||||||
activeTurnBehavior === "steer"
|
activeTurnBehavior === "steer"
|
||||||
? (selectAgentTurnPresentation(
|
? (selectAgentTurnPresentation(
|
||||||
useSessionStore.getState().sessions[serverId],
|
sessionStore.getState().sessions[serverId],
|
||||||
recipient.agentId,
|
recipient.agentId,
|
||||||
).turnId ?? undefined)
|
).turnId ?? undefined)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -103,7 +109,7 @@ export function useReviewFeedback(input: {
|
|||||||
clearSentReviewDraftComments({ key: reviewDraftKey, comments: sentComments }),
|
clearSentReviewDraftComments({ key: reviewDraftKey, comments: sentComments }),
|
||||||
failedMessage: () => i18n.t("review.feedback.failed"),
|
failedMessage: () => i18n.t("review.feedback.failed"),
|
||||||
}),
|
}),
|
||||||
[reviewDraftKey, serverId, workspaceKey, workspaceId],
|
[layoutStore, sessionStore, reviewDraftKey, serverId, workspaceKey, workspaceId],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
model.refresh();
|
model.refresh();
|
||||||
@@ -119,10 +125,11 @@ export function useReviewFeedback(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readRecipients(workspaceKey: string | null): FeedbackRecipient[] {
|
function readRecipients(
|
||||||
const tabs = workspaceKey
|
workspaceKey: string | null,
|
||||||
? useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)
|
store: WorkspaceLayoutStoreApi,
|
||||||
: [];
|
): FeedbackRecipient[] {
|
||||||
|
const tabs = workspaceKey ? store.getState().getWorkspaceTabs(workspaceKey) : [];
|
||||||
const descriptors = deriveWorkspaceTabModel({ tabs }).tabs;
|
const descriptors = deriveWorkspaceTabModel({ tabs }).tabs;
|
||||||
const recipients: FeedbackRecipient[] = [];
|
const recipients: FeedbackRecipient[] = [];
|
||||||
for (const { descriptor } of descriptors) {
|
for (const { descriptor } of descriptors) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||||
import {
|
import {
|
||||||
|
createSessionStore,
|
||||||
selectAgentTimelineState,
|
selectAgentTimelineState,
|
||||||
selectAgentTurnPresentation,
|
selectAgentTurnPresentation,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
@@ -391,3 +392,34 @@ describe("AgentDirectoryReplica", () => {
|
|||||||
store.clearSession(serverId);
|
store.clearSession(serverId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("partitions equal destination identities by the owning authority store, including late snapshots", () => {
|
||||||
|
const direct = createSessionStore();
|
||||||
|
const routed = createSessionStore();
|
||||||
|
const serverId = "srv_B";
|
||||||
|
direct.getState().initializeSession(serverId, null);
|
||||||
|
routed.getState().initializeSession(serverId, null);
|
||||||
|
const directReplica = new AgentDirectoryReplica(
|
||||||
|
serverId,
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
direct,
|
||||||
|
);
|
||||||
|
const routedReplica = new AgentDirectoryReplica(
|
||||||
|
serverId,
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
routed,
|
||||||
|
);
|
||||||
|
directReplica.commitSnapshot([entry(payload("Broader direct data"))], []);
|
||||||
|
expect(routed.getState().sessions[serverId]?.agents.size).toBe(0);
|
||||||
|
routedReplica.commitSnapshot([entry(payload("Origin-authorized data"))], []);
|
||||||
|
directReplica.commitSnapshot([entry(payload("Late direct snapshot"))], []);
|
||||||
|
expect(routed.getState().sessions[serverId]?.agents.get("agent")?.title).toBe(
|
||||||
|
"Origin-authorized data",
|
||||||
|
);
|
||||||
|
expect(direct.getState().sessions[serverId]?.agents.get("agent")?.title).toBe(
|
||||||
|
"Late direct snapshot",
|
||||||
|
);
|
||||||
|
expect(routedReplica.snapshot().get("agent")?.title).toBe("Origin-authorized data");
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
|||||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||||
import { queryClient } from "@/data/query-client";
|
import { queryClient } from "@/data/query-client";
|
||||||
import type { Agent } from "@/stores/session-store";
|
import { useSessionStore, type SessionStoreApi, type Agent } from "@/stores/session-store";
|
||||||
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
|
import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
|
||||||
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
|
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
|
||||||
@@ -38,8 +38,9 @@ export class AgentDirectoryReplica {
|
|||||||
private readonly serverId: string,
|
private readonly serverId: string,
|
||||||
private readonly onStoppedRunning: (agentId: string) => void,
|
private readonly onStoppedRunning: (agentId: string) => void,
|
||||||
private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void,
|
private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void,
|
||||||
|
private readonly store: SessionStoreApi = useSessionStore,
|
||||||
) {
|
) {
|
||||||
this.storeProjection = new AgentStoreProjection(serverId);
|
this.storeProjection = new AgentStoreProjection(serverId, store);
|
||||||
}
|
}
|
||||||
|
|
||||||
captureTimeline(agentId: string): AgentLifecycleToken {
|
captureTimeline(agentId: string): AgentLifecycleToken {
|
||||||
@@ -80,7 +81,11 @@ export class AgentDirectoryReplica {
|
|||||||
const existing = this.storeProjection.get(token.agentId);
|
const existing = this.storeProjection.get(token.agentId);
|
||||||
const timelineAgent = applyLegacyDaemonWorkspaceOwnership({
|
const timelineAgent = applyLegacyDaemonWorkspaceOwnership({
|
||||||
serverId: this.serverId,
|
serverId: this.serverId,
|
||||||
agent: normalizeAgentSnapshot(payload, this.serverId),
|
agent: normalizeAgentSnapshot(
|
||||||
|
payload,
|
||||||
|
this.serverId,
|
||||||
|
this.store.getState().sessions[this.serverId]?.serverInfo,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
const normalized: Agent = {
|
const normalized: Agent = {
|
||||||
...timelineAgent,
|
...timelineAgent,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
normalizeProjectDescriptor,
|
normalizeProjectDescriptor,
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
|
type SessionStoreApi,
|
||||||
type Agent,
|
type Agent,
|
||||||
type ProjectDescriptor,
|
type ProjectDescriptor,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
@@ -172,11 +173,17 @@ export class DirectorySync {
|
|||||||
markAgentError: (error: string) => void;
|
markAgentError: (error: string) => void;
|
||||||
},
|
},
|
||||||
private readonly checkpoints?: DirectoryCheckpointStorage,
|
private readonly checkpoints?: DirectoryCheckpointStorage,
|
||||||
|
private readonly store: SessionStoreApi = useSessionStore,
|
||||||
) {
|
) {
|
||||||
const persist = (mutations: readonly DirectoryReplicaMutation[]) =>
|
const persist = (mutations: readonly DirectoryReplicaMutation[]) =>
|
||||||
this.checkpoints?.commitDirectoryMutations(this.serverId, mutations);
|
this.checkpoints?.commitDirectoryMutations(this.serverId, mutations);
|
||||||
this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist);
|
this.agents = new AgentDirectoryReplica(
|
||||||
this.workspaces = new WorkspaceDirectoryReplica(serverId);
|
serverId,
|
||||||
|
callbacks.onAgentStoppedRunning,
|
||||||
|
persist,
|
||||||
|
store,
|
||||||
|
);
|
||||||
|
this.workspaces = new WorkspaceDirectoryReplica(serverId, store);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionChanged(connection: DirectoryConnection): boolean {
|
connectionChanged(connection: DirectoryConnection): boolean {
|
||||||
@@ -401,18 +408,18 @@ export class DirectorySync {
|
|||||||
|
|
||||||
async loadCachedAgent(agentId: string): Promise<void> {
|
async loadCachedAgent(agentId: string): Promise<void> {
|
||||||
if (!this.checkpoints) return;
|
if (!this.checkpoints) return;
|
||||||
if (useSessionStore.getState().sessions[this.serverId]?.agents.has(agentId)) return;
|
if (this.store.getState().sessions[this.serverId]?.agents.has(agentId)) return;
|
||||||
const token = this.agents.captureCache(agentId);
|
const token = this.agents.captureCache(agentId);
|
||||||
const agent = await this.checkpoints.readAgent(this.serverId, agentId);
|
const agent = await this.checkpoints.readAgent(this.serverId, agentId);
|
||||||
if (!agent) return;
|
if (!agent) return;
|
||||||
const session = useSessionStore.getState().sessions[this.serverId];
|
const session = this.store.getState().sessions[this.serverId];
|
||||||
if (!session || session.agents.has(agentId)) return;
|
if (!session || session.agents.has(agentId)) return;
|
||||||
this.agents.commitCachedAgent(token, agent);
|
this.agents.commitCachedAgent(token, agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepareAgentRoute(agentId: string): Promise<void> {
|
async prepareAgentRoute(agentId: string): Promise<void> {
|
||||||
await this.loadCachedAgent(agentId);
|
await this.loadCachedAgent(agentId);
|
||||||
const agent = useSessionStore.getState().sessions[this.serverId]?.agents.get(agentId);
|
const agent = this.store.getState().sessions[this.serverId]?.agents.get(agentId);
|
||||||
if (agent?.workspaceId) await this.loadCachedWorkspace(agent.workspaceId);
|
if (agent?.workspaceId) await this.loadCachedWorkspace(agent.workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,12 +429,12 @@ export class DirectorySync {
|
|||||||
|
|
||||||
private async loadCachedWorkspace(workspaceId: string): Promise<void> {
|
private async loadCachedWorkspace(workspaceId: string): Promise<void> {
|
||||||
if (!this.checkpoints) return;
|
if (!this.checkpoints) return;
|
||||||
if (useSessionStore.getState().sessions[this.serverId]?.workspaces.has(workspaceId)) return;
|
if (this.store.getState().sessions[this.serverId]?.workspaces.has(workspaceId)) return;
|
||||||
const revision = this.workspaceRevision;
|
const revision = this.workspaceRevision;
|
||||||
const cached = await this.checkpoints.readWorkspace(this.serverId, workspaceId);
|
const cached = await this.checkpoints.readWorkspace(this.serverId, workspaceId);
|
||||||
if (!cached) return;
|
if (!cached) return;
|
||||||
if (this.workspaceRevision !== revision) return;
|
if (this.workspaceRevision !== revision) return;
|
||||||
const session = useSessionStore.getState().sessions[this.serverId];
|
const session = this.store.getState().sessions[this.serverId];
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
this.workspaces.commitCachedWorkspace(cached.workspace, cached.project);
|
this.workspaces.commitCachedWorkspace(cached.workspace, cached.project);
|
||||||
}
|
}
|
||||||
@@ -439,7 +446,7 @@ export class DirectorySync {
|
|||||||
const revision = this.revision;
|
const revision = this.revision;
|
||||||
const cached = await checkpoints.readDirectory(this.serverId);
|
const cached = await checkpoints.readDirectory(this.serverId);
|
||||||
if (this.cacheAccepted) return;
|
if (this.cacheAccepted) return;
|
||||||
if (!useSessionStore.getState().sessions[this.serverId]) return;
|
if (!this.store.getState().sessions[this.serverId]) return;
|
||||||
this.agents.commitCached(cached.agents);
|
this.agents.commitCached(cached.agents);
|
||||||
this.workspaces.commitCached(cached);
|
this.workspaces.commitCached(cached);
|
||||||
if (this.revision === revision) this.cursors = cached.checkpoint ?? {};
|
if (this.revision === revision) this.cursors = cached.checkpoint ?? {};
|
||||||
@@ -495,7 +502,7 @@ export class DirectorySync {
|
|||||||
if (!onlineConnection) {
|
if (!onlineConnection) {
|
||||||
this.callbacks.markAgentReady();
|
this.callbacks.markAgentReady();
|
||||||
return {
|
return {
|
||||||
agents: new Map(useSessionStore.getState().sessions[this.serverId]?.agents),
|
agents: new Map(this.store.getState().sessions[this.serverId]?.agents),
|
||||||
subscriptionId: null,
|
subscriptionId: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -560,7 +567,7 @@ export class DirectorySync {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await this.waitForSessionMetadata(client, source);
|
await this.waitForSessionMetadata(client, source);
|
||||||
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
|
const serverInfo = this.store.getState().sessions[this.serverId]?.serverInfo;
|
||||||
const supportsProjectList = serverInfo?.features?.projectList === true;
|
const supportsProjectList = serverInfo?.features?.projectList === true;
|
||||||
const supportsDirectorySync = serverInfo?.features?.directorySync === true;
|
const supportsDirectorySync = serverInfo?.features?.directorySync === true;
|
||||||
const projectsReady = supportsProjectList
|
const projectsReady = supportsProjectList
|
||||||
@@ -690,7 +697,7 @@ export class DirectorySync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
markWorkspacesHydrated(hydrated: boolean): void {
|
markWorkspacesHydrated(hydrated: boolean): void {
|
||||||
useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, hydrated);
|
this.store.getState().setHasHydratedWorkspaces(this.serverId, hydrated);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchAgents(
|
private async fetchAgents(
|
||||||
@@ -783,8 +790,7 @@ export class DirectorySync {
|
|||||||
|
|
||||||
private supportsDirectorySync(): boolean {
|
private supportsDirectorySync(): boolean {
|
||||||
return (
|
return (
|
||||||
useSessionStore.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync ===
|
this.store.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync === true
|
||||||
true
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,7 +970,7 @@ export class DirectorySync {
|
|||||||
source: DirectorySourceToken,
|
source: DirectorySourceToken,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.waitForSessionState(client, source, () => {
|
await this.waitForSessionState(client, source, () => {
|
||||||
const session = useSessionStore.getState().sessions[this.serverId];
|
const session = this.store.getState().sessions[this.serverId];
|
||||||
return this.hasMatchingSession(client, source) && session?.serverInfo !== null;
|
return this.hasMatchingSession(client, source) && session?.serverInfo !== null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -995,13 +1001,13 @@ export class DirectorySync {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.abortSessionWaits.add(abort);
|
this.abortSessionWaits.add(abort);
|
||||||
unsubscribe = useSessionStore.subscribe(check);
|
unsubscribe = this.store.subscribe(check);
|
||||||
check();
|
check();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasMatchingSession(client: DaemonClient, source: DirectorySourceToken): boolean {
|
private hasMatchingSession(client: DaemonClient, source: DirectorySourceToken): boolean {
|
||||||
const session = useSessionStore.getState().sessions[this.serverId];
|
const session = this.store.getState().sessions[this.serverId];
|
||||||
return session?.client === client && session.clientGeneration === source.clientGeneration;
|
return session?.client === client && session.clientGeneration === source.clientGeneration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import equal from "fast-deep-equal";
|
import equal from "fast-deep-equal";
|
||||||
import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type SessionStoreApi, type Agent } from "@/stores/session-store";
|
||||||
import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy";
|
import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy";
|
||||||
import { buildAgentDirectoryState, type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
|
import { buildAgentDirectoryState, type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
|
||||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
@@ -9,7 +9,7 @@ import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
|||||||
import { queryClient } from "@/data/query-client";
|
import { queryClient } from "@/data/query-client";
|
||||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization";
|
import { getInitKey } from "@/utils/agent-initialization";
|
||||||
import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness";
|
import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness";
|
||||||
|
|
||||||
function withoutAgent(agentId: string) {
|
function withoutAgent(agentId: string) {
|
||||||
@@ -32,14 +32,17 @@ function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AgentStoreProjection {
|
export class AgentStoreProjection {
|
||||||
constructor(private readonly serverId: string) {}
|
constructor(
|
||||||
|
private readonly serverId: string,
|
||||||
|
private readonly store: SessionStoreApi = useSessionStore,
|
||||||
|
) {}
|
||||||
|
|
||||||
snapshot(): Map<string, Agent> {
|
snapshot(): Map<string, Agent> {
|
||||||
return useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map();
|
return this.store.getState().sessions[this.serverId]?.agents ?? new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
get(agentId: string): Agent | undefined {
|
get(agentId: string): Agent | undefined {
|
||||||
const session = useSessionStore.getState().sessions[this.serverId];
|
const session = this.store.getState().sessions[this.serverId];
|
||||||
return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,17 +50,17 @@ export class AgentStoreProjection {
|
|||||||
const merged = new Map(agents);
|
const merged = new Map(agents);
|
||||||
for (const [agentId, agent] of this.snapshot()) merged.set(agentId, agent);
|
for (const [agentId, agent] of this.snapshot()) merged.set(agentId, agent);
|
||||||
for (const agent of merged.values()) this.publishActivity(agent);
|
for (const agent of merged.values()) this.publishActivity(agent);
|
||||||
useSessionStore.getState().setAgents(this.serverId, merged);
|
this.store.getState().setAgents(this.serverId, merged);
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
publishActivity(agent: Agent): void {
|
publishActivity(agent: Agent): void {
|
||||||
useSessionStore.getState().setAgentLastActivity(agent.id, agent.lastActivityAt);
|
this.store.getState().setAgentLastActivity(agent.id, agent.lastActivityAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
accept(agent: Agent): Agent {
|
accept(agent: Agent): Agent {
|
||||||
let accepted = agent;
|
let accepted = agent;
|
||||||
useSessionStore.getState().setAgents(this.serverId, (current) => {
|
this.store.getState().setAgents(this.serverId, (current) => {
|
||||||
const previous = current.get(agent.id);
|
const previous = current.get(agent.id);
|
||||||
accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
|
accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
|
||||||
if (accepted === previous) return current;
|
if (accepted === previous) return current;
|
||||||
@@ -77,8 +80,12 @@ export class AgentStoreProjection {
|
|||||||
this.removeFromDirectory(delta.agentId);
|
this.removeFromDirectory(delta.agentId);
|
||||||
return { agentId: delta.agentId, stoppedRunning: false };
|
return { agentId: delta.agentId, stoppedRunning: false };
|
||||||
}
|
}
|
||||||
const normalized = normalizeAgentSnapshot(delta.agent, this.serverId);
|
const normalized = normalizeAgentSnapshot(
|
||||||
const session = useSessionStore.getState().sessions[this.serverId];
|
delta.agent,
|
||||||
|
this.serverId,
|
||||||
|
this.store.getState().sessions[this.serverId]?.serverInfo,
|
||||||
|
);
|
||||||
|
const session = this.store.getState().sessions[this.serverId];
|
||||||
const previous = session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id);
|
const previous = session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id);
|
||||||
const legacyWorkspaceId =
|
const legacyWorkspaceId =
|
||||||
previous?.workspaceId ??
|
previous?.workspaceId ??
|
||||||
@@ -98,7 +105,7 @@ export class AgentStoreProjection {
|
|||||||
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id });
|
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id });
|
||||||
}
|
}
|
||||||
this.replacePendingPermissions(accepted);
|
this.replacePendingPermissions(accepted);
|
||||||
useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt);
|
this.store.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt);
|
||||||
return {
|
return {
|
||||||
agentId: accepted.id,
|
agentId: accepted.id,
|
||||||
stoppedRunning: previous?.turn.phase === "open" && accepted.turn.phase === "idle",
|
stoppedRunning: previous?.turn.phase === "open" && accepted.turn.phase === "idle",
|
||||||
@@ -110,8 +117,9 @@ export class AgentStoreProjection {
|
|||||||
const { agents: fetched, pendingPermissions } = buildAgentDirectoryState({
|
const { agents: fetched, pendingPermissions } = buildAgentDirectoryState({
|
||||||
serverId: this.serverId,
|
serverId: this.serverId,
|
||||||
entries,
|
entries,
|
||||||
|
serverInfo: this.store.getState().sessions[this.serverId]?.serverInfo,
|
||||||
});
|
});
|
||||||
const store = useSessionStore.getState();
|
const store = this.store.getState();
|
||||||
const current = store.sessions[this.serverId]?.agents ?? new Map<string, Agent>();
|
const current = store.sessions[this.serverId]?.agents ?? new Map<string, Agent>();
|
||||||
const agents = new Map<string, Agent>();
|
const agents = new Map<string, Agent>();
|
||||||
for (const [agentId, fetchedAgent] of fetched) {
|
for (const [agentId, fetchedAgent] of fetched) {
|
||||||
@@ -141,17 +149,17 @@ export class AgentStoreProjection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
replacePendingPermissions(agent: Agent): void {
|
replacePendingPermissions(agent: Agent): void {
|
||||||
const pending = new Map(useSessionStore.getState().sessions[this.serverId]?.pendingPermissions);
|
const pending = new Map(this.store.getState().sessions[this.serverId]?.pendingPermissions);
|
||||||
for (const [key, entry] of pending) if (entry.agentId === agent.id) pending.delete(key);
|
for (const [key, entry] of pending) if (entry.agentId === agent.id) pending.delete(key);
|
||||||
for (const request of agent.pendingPermissions) {
|
for (const request of agent.pendingPermissions) {
|
||||||
const key = derivePendingPermissionKey(agent.id, request);
|
const key = derivePendingPermissionKey(agent.id, request);
|
||||||
pending.set(key, { key, agentId: agent.id, request });
|
pending.set(key, { key, agentId: agent.id, request });
|
||||||
}
|
}
|
||||||
useSessionStore.getState().setPendingPermissions(this.serverId, pending);
|
this.store.getState().setPendingPermissions(this.serverId, pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeFromDirectory(agentId: string): void {
|
removeFromDirectory(agentId: string): void {
|
||||||
const store = useSessionStore.getState();
|
const store = this.store.getState();
|
||||||
const removeKey = withoutAgent(agentId);
|
const removeKey = withoutAgent(agentId);
|
||||||
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId });
|
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId });
|
||||||
store.setAgents(this.serverId, removeKey);
|
store.setAgents(this.serverId, removeKey);
|
||||||
@@ -163,7 +171,7 @@ export class AgentStoreProjection {
|
|||||||
for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key);
|
for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key);
|
||||||
return next.size === current.size ? current : next;
|
return next.size === current.size ? current : next;
|
||||||
});
|
});
|
||||||
useSessionStore.setState((state) => {
|
this.store.setState((state) => {
|
||||||
if (!state.agentLastActivity.has(agentId)) return state;
|
if (!state.agentLastActivity.has(agentId)) return state;
|
||||||
const agentLastActivity = new Map(state.agentLastActivity);
|
const agentLastActivity = new Map(state.agentLastActivity);
|
||||||
agentLastActivity.delete(agentId);
|
agentLastActivity.delete(agentId);
|
||||||
@@ -173,8 +181,11 @@ export class AgentStoreProjection {
|
|||||||
draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }),
|
draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }),
|
||||||
});
|
});
|
||||||
const initKey = getInitKey(this.serverId, agentId);
|
const initKey = getInitKey(this.serverId, agentId);
|
||||||
if (getInitDeferred(initKey)) {
|
if (this.store.initializations.getInitDeferred(initKey)) {
|
||||||
rejectInitDeferred(initKey, new Error("Agent was removed during initialization"));
|
this.store.initializations.rejectInitDeferred(
|
||||||
|
initKey,
|
||||||
|
new Error("Agent was removed during initialization"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +193,7 @@ export class AgentStoreProjection {
|
|||||||
this.removeFromDirectory(agentId);
|
this.removeFromDirectory(agentId);
|
||||||
// Only entity deletion destroys the transcript. A filtered active-list
|
// Only entity deletion destroys the transcript. A filtered active-list
|
||||||
// response can arrive after a fresh history response for an archived agent.
|
// response can arrive after a fresh history response for an archived agent.
|
||||||
const store = useSessionStore.getState();
|
const store = this.store.getState();
|
||||||
const removeKey = withoutAgent(agentId);
|
const removeKey = withoutAgent(agentId);
|
||||||
store.setAgentTimelineCursor(this.serverId, removeKey);
|
store.setAgentTimelineCursor(this.serverId, removeKey);
|
||||||
store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false);
|
store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false);
|
||||||
@@ -192,7 +203,7 @@ export class AgentStoreProjection {
|
|||||||
|
|
||||||
archive(agentId: string, archivedAt: string): Agent | null {
|
archive(agentId: string, archivedAt: string): Agent | null {
|
||||||
let archived: Agent | null = null;
|
let archived: Agent | null = null;
|
||||||
useSessionStore.getState().setAgents(this.serverId, (current) => {
|
this.store.getState().setAgents(this.serverId, (current) => {
|
||||||
const agent = current.get(agentId);
|
const agent = current.get(agentId);
|
||||||
if (!agent) return current;
|
if (!agent) return current;
|
||||||
archived = {
|
archived = {
|
||||||
@@ -213,7 +224,7 @@ export class AgentStoreProjection {
|
|||||||
): Agent | null {
|
): Agent | null {
|
||||||
const transitions = Array.isArray(transition) ? transition : [transition];
|
const transitions = Array.isArray(transition) ? transition : [transition];
|
||||||
let accepted: Agent | null = null;
|
let accepted: Agent | null = null;
|
||||||
useSessionStore.getState().setAgents(this.serverId, (current) => {
|
this.store.getState().setAgents(this.serverId, (current) => {
|
||||||
const agent = current.get(agentId);
|
const agent = current.get(agentId);
|
||||||
if (!agent) return current;
|
if (!agent) return current;
|
||||||
const turn = transitions.reduce(reduceTurnLiveness, agent.turn);
|
const turn = transitions.reduce(reduceTurnLiveness, agent.turn);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
normalizeProjectDescriptor,
|
normalizeProjectDescriptor,
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
|
type SessionStoreApi,
|
||||||
type ProjectDescriptor,
|
type ProjectDescriptor,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
@@ -38,7 +39,10 @@ export class WorkspaceDirectoryReplica {
|
|||||||
private projects = new Map<string, ProjectDescriptor>();
|
private projects = new Map<string, ProjectDescriptor>();
|
||||||
private workspaceIdsByProject = new Map<string, Set<string>>();
|
private workspaceIdsByProject = new Map<string, Set<string>>();
|
||||||
|
|
||||||
constructor(private readonly serverId: string) {}
|
constructor(
|
||||||
|
private readonly serverId: string,
|
||||||
|
private readonly store: SessionStoreApi = useSessionStore,
|
||||||
|
) {}
|
||||||
|
|
||||||
applyDelta(delta: WorkspaceDirectoryDelta): DirectoryReplicaMutation[] {
|
applyDelta(delta: WorkspaceDirectoryDelta): DirectoryReplicaMutation[] {
|
||||||
if (delta.kind === "script_status") return this.applyScriptStatus(delta.update);
|
if (delta.kind === "script_status") return this.applyScriptStatus(delta.update);
|
||||||
@@ -55,7 +59,7 @@ export class WorkspaceDirectoryReplica {
|
|||||||
workspaces: new Map([...input.workspaces, ...this.workspaces]),
|
workspaces: new Map([...input.workspaces, ...this.workspaces]),
|
||||||
projects: new Map([...input.projects, ...this.projects]),
|
projects: new Map([...input.projects, ...this.projects]),
|
||||||
});
|
});
|
||||||
useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true);
|
this.store.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
commitCachedWorkspace(
|
commitCachedWorkspace(
|
||||||
@@ -73,7 +77,7 @@ export class WorkspaceDirectoryReplica {
|
|||||||
): DirectoryReplicaMutation[] {
|
): DirectoryReplicaMutation[] {
|
||||||
this.replace(snapshot);
|
this.replace(snapshot);
|
||||||
const mutations = deltas.flatMap((delta) => this.applyDelta(delta));
|
const mutations = deltas.flatMap((delta) => this.applyDelta(delta));
|
||||||
useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, true);
|
this.store.getState().setHasHydratedWorkspaces(this.serverId, true);
|
||||||
return mutations;
|
return mutations;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +110,7 @@ export class WorkspaceDirectoryReplica {
|
|||||||
this.workspaces = workspaces;
|
this.workspaces = workspaces;
|
||||||
this.projects = new Map(snapshot.projects);
|
this.projects = new Map(snapshot.projects);
|
||||||
this.rebuildProjectIndex();
|
this.rebuildProjectIndex();
|
||||||
const store = useSessionStore.getState();
|
const store = this.store.getState();
|
||||||
store.setWorkspaces(this.serverId, this.workspaces);
|
store.setWorkspaces(this.serverId, this.workspaces);
|
||||||
store.setProjects(this.serverId, this.projects.values());
|
store.setProjects(this.serverId, this.projects.values());
|
||||||
}
|
}
|
||||||
@@ -133,7 +137,7 @@ export class WorkspaceDirectoryReplica {
|
|||||||
{ kind: "project", type: "delete", id: delta.projectId },
|
{ kind: "project", type: "delete", id: delta.projectId },
|
||||||
];
|
];
|
||||||
this.projects.delete(delta.projectId);
|
this.projects.delete(delta.projectId);
|
||||||
useSessionStore.getState().removeProject(this.serverId, delta.projectId);
|
this.store.getState().removeProject(this.serverId, delta.projectId);
|
||||||
for (const workspaceId of this.workspaceIdsByProject.get(delta.projectId) ?? []) {
|
for (const workspaceId of this.workspaceIdsByProject.get(delta.projectId) ?? []) {
|
||||||
this.deleteWorkspace(workspaceId);
|
this.deleteWorkspace(workspaceId);
|
||||||
mutations.push({ kind: "workspace", type: "delete", id: workspaceId });
|
mutations.push({ kind: "workspace", type: "delete", id: workspaceId });
|
||||||
@@ -179,7 +183,7 @@ export class WorkspaceDirectoryReplica {
|
|||||||
}
|
}
|
||||||
if (delta.removedProjectId) {
|
if (delta.removedProjectId) {
|
||||||
this.projects.delete(delta.removedProjectId);
|
this.projects.delete(delta.removedProjectId);
|
||||||
useSessionStore.getState().removeProject(this.serverId, delta.removedProjectId);
|
this.store.getState().removeProject(this.serverId, delta.removedProjectId);
|
||||||
mutations.push({ kind: "project", type: "delete", id: delta.removedProjectId });
|
mutations.push({ kind: "project", type: "delete", id: delta.removedProjectId });
|
||||||
}
|
}
|
||||||
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId: delta.id });
|
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId: delta.id });
|
||||||
@@ -217,19 +221,19 @@ export class WorkspaceDirectoryReplica {
|
|||||||
const projectWorkspaces = this.workspaceIdsByProject.get(workspace.projectId) ?? new Set();
|
const projectWorkspaces = this.workspaceIdsByProject.get(workspace.projectId) ?? new Set();
|
||||||
projectWorkspaces.add(workspace.id);
|
projectWorkspaces.add(workspace.id);
|
||||||
this.workspaceIdsByProject.set(workspace.projectId, projectWorkspaces);
|
this.workspaceIdsByProject.set(workspace.projectId, projectWorkspaces);
|
||||||
useSessionStore.getState().mergeWorkspaces(this.serverId, [workspace]);
|
this.store.getState().mergeWorkspaces(this.serverId, [workspace]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private deleteWorkspace(workspaceId: string): void {
|
private deleteWorkspace(workspaceId: string): void {
|
||||||
const workspace = this.workspaces.get(workspaceId);
|
const workspace = this.workspaces.get(workspaceId);
|
||||||
if (workspace) this.workspaceIdsByProject.get(workspace.projectId)?.delete(workspaceId);
|
if (workspace) this.workspaceIdsByProject.get(workspace.projectId)?.delete(workspaceId);
|
||||||
this.workspaces.delete(workspaceId);
|
this.workspaces.delete(workspaceId);
|
||||||
useSessionStore.getState().removeWorkspace(this.serverId, workspaceId);
|
this.store.getState().removeWorkspace(this.serverId, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setProject(project: ProjectDescriptor): void {
|
private setProject(project: ProjectDescriptor): void {
|
||||||
this.projects.set(project.projectId, project);
|
this.projects.set(project.projectId, project);
|
||||||
useSessionStore.getState().upsertProject(this.serverId, project);
|
this.store.getState().upsertProject(this.serverId, project);
|
||||||
}
|
}
|
||||||
|
|
||||||
private rebuildProjectIndex(): void {
|
private rebuildProjectIndex(): void {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
import {
|
||||||
|
createWorkspaceLayoutStore,
|
||||||
|
useWorkspaceLayoutStoreApi,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks";
|
||||||
|
import {
|
||||||
|
createProviderSubagentStore,
|
||||||
|
useProviderSubagentStoreApi,
|
||||||
|
} from "@/subagents/provider-store";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createSessionStore, useSessionStoreApi } from "@/stores/session-store";
|
||||||
|
import { useCreateFlowStoreApi } from "@/stores/create-flow-store";
|
||||||
|
import { HostRuntimeStore, useHostRuntimeStore, type HostAuthorityBinding } from "./host-runtime";
|
||||||
|
import { HostAuthorityProvider } from "./host-authority-provider";
|
||||||
|
|
||||||
|
it("ordinary consumer hooks retain the runtime, store and creation owner of each authority", async () => {
|
||||||
|
const binding = (originServerId: string): HostAuthorityBinding => {
|
||||||
|
const sessionStore = createSessionStore();
|
||||||
|
return {
|
||||||
|
reference: { originServerId, serverId: "B" },
|
||||||
|
namespace: JSON.stringify(["host-authority-v1", originServerId, "B"]),
|
||||||
|
sessionStore,
|
||||||
|
queries: new QueryClient(),
|
||||||
|
providerSubagents: createProviderSubagentStore(),
|
||||||
|
layout: createWorkspaceLayoutStore(),
|
||||||
|
runtime: new HostRuntimeStore({ sessionStore }),
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const direct = binding("B");
|
||||||
|
const routed = binding("A");
|
||||||
|
for (const owner of [direct, routed]) {
|
||||||
|
owner.sessionStore.getState().initializeSession("B", null);
|
||||||
|
owner.sessionStore.getState().setHasHydratedWorkspaces("B", owner === routed);
|
||||||
|
}
|
||||||
|
function OrdinaryConsumer({ expected }: { expected: HostAuthorityBinding }) {
|
||||||
|
expect(useHasHydratedWorkspaces("B")).toBe(expected === routed);
|
||||||
|
const runtime = useHostRuntimeStore();
|
||||||
|
const store = useSessionStoreApi();
|
||||||
|
const creates = useCreateFlowStoreApi();
|
||||||
|
expect(useQueryClient()).toBe(expected.queries);
|
||||||
|
expect(useProviderSubagentStoreApi()).toBe(expected.providerSubagents);
|
||||||
|
expect(useWorkspaceLayoutStoreApi()).toBe(expected.layout);
|
||||||
|
expect(runtime).toBe(expected.runtime);
|
||||||
|
expect(store).toBe(expected.sessionStore);
|
||||||
|
expect(creates).toBe(expected.sessionStore.createFlows);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const root = createRoot(container);
|
||||||
|
try {
|
||||||
|
await act(async () =>
|
||||||
|
root.render(
|
||||||
|
<>
|
||||||
|
<HostAuthorityProvider binding={direct}>
|
||||||
|
<OrdinaryConsumer expected={direct} />
|
||||||
|
</HostAuthorityProvider>
|
||||||
|
<HostAuthorityProvider binding={routed}>
|
||||||
|
<OrdinaryConsumer expected={routed} />
|
||||||
|
</HostAuthorityProvider>
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { WorkspaceLayoutStoreContext } from "@/stores/workspace-layout-store";
|
||||||
|
import { ProviderSubagentStoreContext } from "@/subagents/provider-store";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { SessionStoreContext } from "@/stores/session-store";
|
||||||
|
import { CreateFlowStoreContext } from "@/stores/create-flow-store";
|
||||||
|
import { HostRuntimeContext, type HostAuthorityBinding } from "./host-runtime";
|
||||||
|
|
||||||
|
/** The retained screen owns acquisition/release; descendants receive one bound capability. */
|
||||||
|
export function HostAuthorityProvider({
|
||||||
|
binding,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
binding: HostAuthorityBinding;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={binding.queries}>
|
||||||
|
<ProviderSubagentStoreContext.Provider value={binding.providerSubagents}>
|
||||||
|
<HostRuntimeContext.Provider value={binding.runtime}>
|
||||||
|
<SessionStoreContext.Provider value={binding.sessionStore}>
|
||||||
|
<CreateFlowStoreContext.Provider value={binding.sessionStore.createFlows}>
|
||||||
|
<WorkspaceLayoutStoreContext.Provider value={binding.layout}>
|
||||||
|
{children}
|
||||||
|
</WorkspaceLayoutStoreContext.Provider>
|
||||||
|
</CreateFlowStoreContext.Provider>
|
||||||
|
</SessionStoreContext.Provider>
|
||||||
|
</HostRuntimeContext.Provider>
|
||||||
|
</ProviderSubagentStoreContext.Provider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
import { HostAuthorityProvider } from "./host-authority-provider";
|
||||||
|
import {
|
||||||
|
getHostRuntimeStore,
|
||||||
|
type HostAuthorityReference,
|
||||||
|
type OwnedHostAuthorityBinding,
|
||||||
|
} from "./host-runtime";
|
||||||
|
|
||||||
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||||
|
const spinnerColors = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
|
/** The retained route owns its lease; destination identity never selects another origin. */
|
||||||
|
export function HostAuthorityRoute({
|
||||||
|
reference,
|
||||||
|
children,
|
||||||
|
pending = <ThemedLoadingSpinner uniProps={spinnerColors} />,
|
||||||
|
}: {
|
||||||
|
reference: HostAuthorityReference;
|
||||||
|
children: ReactNode;
|
||||||
|
pending?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const registry = getHostRuntimeStore();
|
||||||
|
const { originServerId, serverId } = reference;
|
||||||
|
useSyncExternalStore(
|
||||||
|
(changed) => registry.subscribe(originServerId, changed),
|
||||||
|
() => registry.getSnapshot(originServerId),
|
||||||
|
() => registry.getSnapshot(originServerId),
|
||||||
|
);
|
||||||
|
const serverInfo = registry.getServerInfo(originServerId);
|
||||||
|
const features = serverInfo?.features;
|
||||||
|
// COMPAT(preFleetDirectRoutes): added in v0.12, remove after 2027-04-20.
|
||||||
|
// Old direct hosts retain their existing shell/session owner. Owned SDK scopes still reject unsupported capability.
|
||||||
|
const legacy =
|
||||||
|
originServerId === serverId && serverInfo !== null && features?.hostSessionScopes !== true;
|
||||||
|
const [lease, setLease] = useState<OwnedHostAuthorityBinding | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (legacy) return;
|
||||||
|
const owned = registry.acquireBinding({ originServerId, serverId });
|
||||||
|
setLease(owned);
|
||||||
|
return () => {
|
||||||
|
void owned.release();
|
||||||
|
};
|
||||||
|
}, [legacy, originServerId, registry, serverId]);
|
||||||
|
if (legacy) return children;
|
||||||
|
if (
|
||||||
|
!lease ||
|
||||||
|
lease.binding.reference.originServerId !== originServerId ||
|
||||||
|
lease.binding.reference.serverId !== serverId
|
||||||
|
)
|
||||||
|
return pending;
|
||||||
|
return <HostAuthorityProvider binding={lease.binding}>{children}</HostAuthorityProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
|
import { WorkspaceLayoutModePublisher } from "@/workspace-tabs/layout-mode-publisher";
|
||||||
|
import { HostAuthorityProvider } from "./host-authority-provider";
|
||||||
|
import {
|
||||||
|
getHostRuntimeStore,
|
||||||
|
useHostRuntimeClient,
|
||||||
|
type HostAuthorityBinding,
|
||||||
|
} from "./host-runtime";
|
||||||
|
|
||||||
|
/** One ordinary session adapter per runtime-owned binding, shared by all retained screens. */
|
||||||
|
export function HostAuthoritySessions() {
|
||||||
|
const registry = getHostRuntimeStore();
|
||||||
|
const bindings = useSyncExternalStore(
|
||||||
|
(changed) => registry.subscribeAll(changed),
|
||||||
|
registry.getAuthorityBindings,
|
||||||
|
registry.getAuthorityBindings,
|
||||||
|
);
|
||||||
|
return bindings.map((binding) => (
|
||||||
|
<HostAuthorityProvider key={binding.namespace} binding={binding}>
|
||||||
|
<WorkspaceLayoutModePublisher />
|
||||||
|
<AuthoritySession binding={binding} />
|
||||||
|
</HostAuthorityProvider>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthoritySession({ binding }: { binding: HostAuthorityBinding }) {
|
||||||
|
const client = useHostRuntimeClient(binding.reference.serverId);
|
||||||
|
if (!client) return null;
|
||||||
|
return (
|
||||||
|
<SessionProvider serverId={binding.reference.serverId} client={client}>
|
||||||
|
{null}
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||||
|
import { indexedDB as fakeIndexedDb, IDBKeyRange as FakeIDBKeyRange } from "fake-indexeddb";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { QueryObserver } from "@tanstack/react-query";
|
||||||
import type { AppStateStatus } from "react-native";
|
import type { AppStateStatus } from "react-native";
|
||||||
import {
|
import {
|
||||||
bindHostRuntimeAppState,
|
bindHostRuntimeAppState,
|
||||||
@@ -13,12 +16,15 @@ import type {
|
|||||||
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
|
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
|
import { MutableDaemonConfigSchema } from "@getpaseo/protocol/messages";
|
||||||
|
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||||
import { defaultHostAppearance } from "@/hosts/appearance";
|
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
||||||
|
import { createAgentArchiveAction } from "@/hooks/use-archive-agent";
|
||||||
import { queryClient } from "@/data/query-client";
|
import { queryClient } from "@/data/query-client";
|
||||||
import {
|
import {
|
||||||
HostRuntimeController,
|
HostRuntimeController,
|
||||||
@@ -26,6 +32,7 @@ import {
|
|||||||
readInitialDaemonConnectionHint,
|
readInitialDaemonConnectionHint,
|
||||||
type HostRuntimeControllerDeps,
|
type HostRuntimeControllerDeps,
|
||||||
type HostRuntimeStorage,
|
type HostRuntimeStorage,
|
||||||
|
type HostAuthorityBinding,
|
||||||
} from "./host-runtime";
|
} from "./host-runtime";
|
||||||
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
|
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
|
||||||
|
|
||||||
@@ -52,6 +59,22 @@ it("requests the managed connection credential through desktop main without a we
|
|||||||
});
|
});
|
||||||
|
|
||||||
class FakeDaemonClient {
|
class FakeDaemonClient {
|
||||||
|
private readonly eventListeners = new Set<(message: SessionOutboundMessage) => void>();
|
||||||
|
publishEvent(message: SessionOutboundMessage): void {
|
||||||
|
for (const receive of this.eventListeners) receive(message);
|
||||||
|
}
|
||||||
|
readonly cancelCalls: string[] = [];
|
||||||
|
cancelResponse: Promise<void> | null = null;
|
||||||
|
async cancelAgent(agentId: string): Promise<void> {
|
||||||
|
this.cancelCalls.push(agentId);
|
||||||
|
await this.cancelResponse;
|
||||||
|
}
|
||||||
|
readonly archiveCalls: string[] = [];
|
||||||
|
archiveResponse: Promise<{ archivedAt: string }> | null = null;
|
||||||
|
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
|
||||||
|
this.archiveCalls.push(agentId);
|
||||||
|
return this.archiveResponse ?? { archivedAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
supportsBackgroundWorkspaces(): boolean {
|
supportsBackgroundWorkspaces(): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -96,7 +119,12 @@ class FakeDaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
observeEvents() {
|
observeEvents() {
|
||||||
return subscriptionFixture(Promise.resolve({}), () => () => {});
|
return subscriptionFixture(Promise.resolve({}), (receive) => {
|
||||||
|
this.eventListeners.add(receive);
|
||||||
|
return () => {
|
||||||
|
this.eventListeners.delete(receive);
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForAgentUpdates(): Promise<void> {
|
async waitForAgentUpdates(): Promise<void> {
|
||||||
@@ -161,6 +189,29 @@ class FakeDaemonClient {
|
|||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly hostDirectoryListeners = new Set<(message: SessionOutboundMessage) => void>();
|
||||||
|
observeHosts(): ReturnType<DaemonClient["observeHosts"]> {
|
||||||
|
return subscriptionFixture(
|
||||||
|
Promise.resolve({ self: { serverId: this.serverId, label: "Origin" }, hosts: [] }),
|
||||||
|
(receive) => {
|
||||||
|
this.hostDirectoryListeners.add(receive);
|
||||||
|
return () => {
|
||||||
|
this.hostDirectoryListeners.delete(receive);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
hostsChanged(): void {
|
||||||
|
for (const receive of this.hostDirectoryListeners)
|
||||||
|
receive({
|
||||||
|
type: "hosts.changed",
|
||||||
|
payload: {
|
||||||
|
subscriptionId: "hosts",
|
||||||
|
directory: { self: { serverId: this.serverId, label: "Origin" }, hosts: [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public serverId = "srv_test";
|
||||||
public ownedSubscriptions = true;
|
public ownedSubscriptions = true;
|
||||||
private daemonVersion: string | null = "0.8.0";
|
private daemonVersion: string | null = "0.8.0";
|
||||||
|
|
||||||
@@ -168,7 +219,7 @@ class FakeDaemonClient {
|
|||||||
if (this.daemonVersion === null) return null;
|
if (this.daemonVersion === null) return null;
|
||||||
return {
|
return {
|
||||||
status: "server_info",
|
status: "server_info",
|
||||||
serverId: "srv_test",
|
serverId: this.serverId,
|
||||||
hostname: "test",
|
hostname: "test",
|
||||||
version: this.daemonVersion,
|
version: this.daemonVersion,
|
||||||
features: { ownedSubscriptions: this.ownedSubscriptions },
|
features: { ownedSubscriptions: this.ownedSubscriptions },
|
||||||
@@ -3391,7 +3442,8 @@ describe("HostRuntimeStore", () => {
|
|||||||
lastActivityAt: new Date(stale.updatedAt),
|
lastActivityAt: new Date(stale.updatedAt),
|
||||||
archivedAt: stale.archivedAt ? new Date(stale.archivedAt) : null,
|
archivedAt: stale.archivedAt ? new Date(stale.archivedAt) : null,
|
||||||
attentionTimestamp: stale.attentionTimestamp ? new Date(stale.attentionTimestamp) : null,
|
attentionTimestamp: stale.attentionTimestamp ? new Date(stale.attentionTimestamp) : null,
|
||||||
parentAgentId: null,
|
parent: null,
|
||||||
|
managedRelationship: null,
|
||||||
};
|
};
|
||||||
return new Map([[stale.id, staleAgent]]);
|
return new Map([[stale.id, staleAgent]]);
|
||||||
});
|
});
|
||||||
@@ -4440,3 +4492,340 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
|||||||
expect(store.getHosts()).toHaveLength(0);
|
expect(store.getHosts()).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function agentInBinding(binding: HostAuthorityBinding, address: AgentAddress): Agent {
|
||||||
|
const agent = binding.sessionStore
|
||||||
|
.getState()
|
||||||
|
.sessions[address.serverId]?.agents.get(address.agentId);
|
||||||
|
if (!agent) throw new Error(`Expected agent in binding ${binding.namespace}`);
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("retains one authority binding for direct and routed consumers without changing a retained direct destination", async () => {
|
||||||
|
globalThis.indexedDB = fakeIndexedDb;
|
||||||
|
globalThis.IDBKeyRange = FakeIDBKeyRange;
|
||||||
|
const prefix = crypto.randomUUID();
|
||||||
|
const aId = `A-${prefix}`;
|
||||||
|
const bId = `B-${prefix}`;
|
||||||
|
const clients = new Map<string, FakeDaemonClient>();
|
||||||
|
const resolutions: Array<{ origin: DaemonClient; serverId: string }> = [];
|
||||||
|
const releases: string[] = [];
|
||||||
|
const routeClient = makeConnectedProbeClient(5);
|
||||||
|
routeClient.serverId = bId;
|
||||||
|
const store = new HostRuntimeStore({
|
||||||
|
storage: createMemoryHostRuntimeStorage(),
|
||||||
|
replicaRowStore: createMemoryReplicaRowStore(),
|
||||||
|
deps: {
|
||||||
|
createClient: ({ host }) => {
|
||||||
|
const client = makeConnectedProbeClient(5);
|
||||||
|
client.serverId = host.serverId;
|
||||||
|
clients.set(host.serverId, client);
|
||||||
|
return client as unknown as DaemonClient;
|
||||||
|
},
|
||||||
|
connectToDaemon: async () => {
|
||||||
|
throw new Error("Probe is not needed");
|
||||||
|
},
|
||||||
|
getClientId: async () => "binding-client",
|
||||||
|
},
|
||||||
|
resolveHostConnection: async (origin, serverId) => {
|
||||||
|
resolutions.push({ origin, serverId });
|
||||||
|
const client =
|
||||||
|
origin === (clients.get(aId) as unknown as DaemonClient) ? routeClient : origin;
|
||||||
|
return {
|
||||||
|
client: client as DaemonClient,
|
||||||
|
release: async () => {
|
||||||
|
releases.push(serverId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const hosts = [aId, bId].map((serverId) =>
|
||||||
|
makeHost({ serverId, connections: [makeHost().connections[0]!] }),
|
||||||
|
);
|
||||||
|
const initial = new Map<string, { connectionId: string; existingClient: DaemonClient }>();
|
||||||
|
for (const host of hosts) {
|
||||||
|
const client = makeConnectedProbeClient(5);
|
||||||
|
client.serverId = host.serverId;
|
||||||
|
clients.set(host.serverId, client);
|
||||||
|
initial.set(host.serverId, {
|
||||||
|
connectionId: host.connections[0]!.id,
|
||||||
|
existingClient: client as unknown as DaemonClient,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
store.syncHosts(hosts, { initialConnectionByServerId: initial });
|
||||||
|
await onceHostSnapshotMatches(store, aId, (snapshot) => snapshot?.connectionStatus === "online");
|
||||||
|
await onceHostSnapshotMatches(store, bId, (snapshot) => snapshot?.connectionStatus === "online");
|
||||||
|
const direct = store.acquireBinding({ originServerId: bId, serverId: bId });
|
||||||
|
const routed = store.acquireBinding({ originServerId: aId, serverId: bId });
|
||||||
|
const sibling = store.acquireBinding({ originServerId: aId, serverId: bId });
|
||||||
|
try {
|
||||||
|
await Promise.all([direct.binding.ready, routed.binding.ready]);
|
||||||
|
expect(sibling.binding).toBe(routed.binding);
|
||||||
|
expect(direct.binding.runtime.getClient(bId)).toBe(clients.get(bId));
|
||||||
|
expect(routed.binding.runtime.getClient(bId)).toBe(routeClient);
|
||||||
|
expect(direct.binding.sessionStore).not.toBe(routed.binding.sessionStore);
|
||||||
|
expect(direct.binding.namespace).not.toBe(routed.binding.namespace);
|
||||||
|
expect(routed.binding.queries.getDefaultOptions()).toEqual(queryClient.getDefaultOptions());
|
||||||
|
const key = ["schedules", bId];
|
||||||
|
let directFetches = 0;
|
||||||
|
let routedFetches = 0;
|
||||||
|
direct.binding.queries.setQueryData(key, "Direct cache");
|
||||||
|
routed.binding.queries.setQueryData(key, "Routed cache");
|
||||||
|
queryClient.setQueryData(key, "Registry cache");
|
||||||
|
const directObserver = new QueryObserver(direct.binding.queries, {
|
||||||
|
queryKey: key,
|
||||||
|
queryFn: async () => {
|
||||||
|
directFetches++;
|
||||||
|
return "Direct recovered";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const routedObserver = new QueryObserver(routed.binding.queries, {
|
||||||
|
queryKey: key,
|
||||||
|
queryFn: async () => {
|
||||||
|
routedFetches++;
|
||||||
|
return "Routed recovered";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const stopDirect = directObserver.subscribe(() => {});
|
||||||
|
const stopRouted = routedObserver.subscribe(() => {});
|
||||||
|
try {
|
||||||
|
expect([directFetches, routedFetches]).toEqual([0, 0]);
|
||||||
|
routeClient.setConnectionState({ status: "disconnected", reason: "Recover routed cache" });
|
||||||
|
routeClient.setConnectionState({ status: "connected" });
|
||||||
|
await expect.poll(() => routed.binding.queries.getQueryData(key)).toBe("Routed recovered");
|
||||||
|
expect([directFetches, routedFetches]).toEqual([0, 1]);
|
||||||
|
expect(direct.binding.queries.getQueryData(key)).toBe("Direct cache");
|
||||||
|
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(false);
|
||||||
|
clients
|
||||||
|
.get(bId)!
|
||||||
|
.setConnectionState({ status: "disconnected", reason: "Recover direct cache" });
|
||||||
|
clients.get(bId)!.setConnectionState({ status: "connected" });
|
||||||
|
await expect.poll(() => direct.binding.queries.getQueryData(key)).toBe("Direct recovered");
|
||||||
|
expect([directFetches, routedFetches]).toEqual([1, 1]);
|
||||||
|
} finally {
|
||||||
|
stopDirect();
|
||||||
|
stopRouted();
|
||||||
|
queryClient.removeQueries({ queryKey: key });
|
||||||
|
}
|
||||||
|
const configKey = daemonConfigQueryKey(bId);
|
||||||
|
const config = MutableDaemonConfigSchema.parse({
|
||||||
|
relay: { enabled: false },
|
||||||
|
mcp: { injectIntoAgents: true },
|
||||||
|
});
|
||||||
|
const directConfig = MutableDaemonConfigSchema.parse({
|
||||||
|
relay: { enabled: true },
|
||||||
|
mcp: { injectIntoAgents: true },
|
||||||
|
});
|
||||||
|
direct.binding.queries.setQueryData(configKey, directConfig);
|
||||||
|
routeClient.publishEvent({
|
||||||
|
type: "status",
|
||||||
|
payload: { status: "daemon_config_changed", config },
|
||||||
|
});
|
||||||
|
await expect.poll(() => routed.binding.queries.getQueryData(configKey)).toEqual(config);
|
||||||
|
expect(direct.binding.queries.getQueryData(configKey)).toEqual(directConfig);
|
||||||
|
const agent = replicaAgent(
|
||||||
|
makeFetchAgentsEntry({
|
||||||
|
id: "same-agent",
|
||||||
|
cwd: "/repo",
|
||||||
|
title: "Direct",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}).agent,
|
||||||
|
bId,
|
||||||
|
);
|
||||||
|
direct.binding.runtime.acceptAgentSnapshot(bId, agent);
|
||||||
|
expect(routed.binding.sessionStore.getState().sessions[bId]?.agents.size).toBe(0);
|
||||||
|
routed.binding.runtime.acceptAgentSnapshot(bId, { ...agent, title: "Routed" });
|
||||||
|
direct.binding.runtime.acceptAgentSnapshot(bId, { ...agent, title: "Late direct" });
|
||||||
|
expect(routed.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.title).toBe(
|
||||||
|
"Routed",
|
||||||
|
);
|
||||||
|
const archive = createAgentArchiveAction({
|
||||||
|
runtime: routed.binding.runtime,
|
||||||
|
sessionStore: routed.binding.sessionStore,
|
||||||
|
queryClient: routed.binding.queries,
|
||||||
|
unavailableMessage: "Authority unavailable",
|
||||||
|
});
|
||||||
|
const running = {
|
||||||
|
...agent,
|
||||||
|
status: "running" as const,
|
||||||
|
turn: {
|
||||||
|
phase: "open" as const,
|
||||||
|
turnId: "owned-turn",
|
||||||
|
startedAt: new Date(),
|
||||||
|
cancellationRequestId: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
direct.binding.runtime.acceptAgentSnapshot(bId, running);
|
||||||
|
routed.binding.runtime.acceptAgentSnapshot(bId, { ...running, title: "Routed" });
|
||||||
|
const cancelGate = createDeferred<void>();
|
||||||
|
routeClient.cancelResponse = cancelGate.promise;
|
||||||
|
const canceling = routed.binding.runtime.cancelAgent({ serverId: bId, agentId: agent.id });
|
||||||
|
void canceling.catch(() => {});
|
||||||
|
await expect.poll(() => routeClient.cancelCalls).toEqual([agent.id]);
|
||||||
|
expect(clients.get(bId)!.cancelCalls).toEqual([]);
|
||||||
|
expect(
|
||||||
|
agentInBinding(routed.binding, { serverId: bId, agentId: agent.id }).turn
|
||||||
|
.cancellationRequestId,
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
agentInBinding(direct.binding, { serverId: bId, agentId: agent.id }).turn
|
||||||
|
.cancellationRequestId,
|
||||||
|
).toBeNull();
|
||||||
|
cancelGate.reject(new Error("Selected cancellation rejected"));
|
||||||
|
await expect(canceling).rejects.toThrow("Selected cancellation rejected");
|
||||||
|
expect(
|
||||||
|
agentInBinding(routed.binding, { serverId: bId, agentId: agent.id }).turn
|
||||||
|
.cancellationRequestId,
|
||||||
|
).toBeNull();
|
||||||
|
expect(agentInBinding(routed.binding, { serverId: bId, agentId: agent.id }).turn.phase).toBe(
|
||||||
|
"open",
|
||||||
|
);
|
||||||
|
expect(agentInBinding(direct.binding, { serverId: bId, agentId: agent.id }).turn.phase).toBe(
|
||||||
|
"open",
|
||||||
|
);
|
||||||
|
routeClient.cancelResponse = Promise.resolve();
|
||||||
|
await routed.binding.runtime.cancelAgent({ serverId: bId, agentId: agent.id });
|
||||||
|
expect(
|
||||||
|
agentInBinding(routed.binding, { serverId: bId, agentId: agent.id }).turn
|
||||||
|
.cancellationRequestId,
|
||||||
|
).toBeNull();
|
||||||
|
const gate = createDeferred<{ archivedAt: string }>();
|
||||||
|
routeClient.archiveResponse = gate.promise;
|
||||||
|
const archiving = archive({ serverId: bId, agentId: agent.id });
|
||||||
|
void archiving.catch(() => {});
|
||||||
|
expect(routeClient.archiveCalls).toEqual([agent.id]);
|
||||||
|
expect(clients.get(bId)!.archiveCalls).toEqual([]);
|
||||||
|
expect(
|
||||||
|
isAgentArchiving({ queryClient: routed.binding.queries, serverId: bId, agentId: agent.id }),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isAgentArchiving({ queryClient: direct.binding.queries, serverId: bId, agentId: agent.id }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
direct.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.archivedAt,
|
||||||
|
).toBeNull();
|
||||||
|
gate.reject(new Error("Rejected by selected authority"));
|
||||||
|
await expect(archiving).rejects.toThrow("Rejected by selected authority");
|
||||||
|
expect(
|
||||||
|
routed.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.archivedAt,
|
||||||
|
).toBeNull();
|
||||||
|
routeClient.setConnectionState({ status: "disconnected", reason: "Route revoked" });
|
||||||
|
expect(routed.binding.runtime.getSnapshot(bId)?.connectionStatus).toBe("error");
|
||||||
|
expect(direct.binding.runtime.getSnapshot(bId)?.connectionStatus).toBe("online");
|
||||||
|
expect(routed.binding.runtime.getClient(bId)).toBe(routeClient);
|
||||||
|
expect(resolutions).toHaveLength(2);
|
||||||
|
// Withdrawal must retire command sources even if the old client stays alive.
|
||||||
|
routeClient.setConnectionState({ status: "connected" });
|
||||||
|
store.syncHosts(hosts.filter((host) => host.serverId === bId));
|
||||||
|
expect(routed.binding.runtime.getClient(bId)).toBeNull();
|
||||||
|
expect(routed.binding.sessionStore.getState().sessions[bId]?.client).toBeNull();
|
||||||
|
expect(routeClient.getConnectionState().status).toBe("connected");
|
||||||
|
routeClient.publishEvent({
|
||||||
|
type: "status",
|
||||||
|
payload: { status: "daemon_config_changed", config: directConfig },
|
||||||
|
});
|
||||||
|
expect(routed.binding.queries.getQueryData(configKey)).toEqual(config);
|
||||||
|
await expect(archive({ serverId: bId, agentId: agent.id })).rejects.toThrow(
|
||||||
|
"Authority unavailable",
|
||||||
|
);
|
||||||
|
expect(routeClient.archiveCalls).toEqual([agent.id]);
|
||||||
|
expect(clients.get(bId)!.archiveCalls).toEqual([]);
|
||||||
|
expect(
|
||||||
|
routed.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.archivedAt,
|
||||||
|
).toBeNull();
|
||||||
|
expect(direct.binding.runtime.getSnapshot(bId)?.connectionStatus).toBe("online");
|
||||||
|
expect(direct.binding.sessionStore.getState().sessions[bId]?.client).toBe(clients.get(bId));
|
||||||
|
await routed.release();
|
||||||
|
expect(releases).toEqual([bId]);
|
||||||
|
expect(sibling.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.title).toBe(
|
||||||
|
"Routed",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await Promise.all([direct.release(), routed.release(), sibling.release()]);
|
||||||
|
store.syncHosts([]);
|
||||||
|
}
|
||||||
|
expect(releases).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["after failure", "during acquisition"])(
|
||||||
|
"recovers a cold offline authority when the same origin enables the destination %s",
|
||||||
|
async (timing) => {
|
||||||
|
globalThis.indexedDB = fakeIndexedDb;
|
||||||
|
globalThis.IDBKeyRange = FakeIDBKeyRange;
|
||||||
|
const aId = `cold-A-${crypto.randomUUID()}`;
|
||||||
|
const bId = `cold-B-${crypto.randomUUID()}`;
|
||||||
|
const origin = makeConnectedProbeClient(5);
|
||||||
|
origin.serverId = aId;
|
||||||
|
const target = makeConnectedProbeClient(5);
|
||||||
|
target.serverId = bId;
|
||||||
|
const gate = createDeferred<void>();
|
||||||
|
let attempts = 0;
|
||||||
|
let enabled = false;
|
||||||
|
const store = new HostRuntimeStore({
|
||||||
|
storage: createMemoryHostRuntimeStorage(),
|
||||||
|
replicaRowStore: createMemoryReplicaRowStore(),
|
||||||
|
resolveHostConnection: async (source, destination) => {
|
||||||
|
expect(source).toBe(origin);
|
||||||
|
expect(destination).toBe(bId);
|
||||||
|
attempts++;
|
||||||
|
if (attempts === 1) {
|
||||||
|
await gate.promise;
|
||||||
|
throw new Error("Destination disabled");
|
||||||
|
}
|
||||||
|
if (!enabled) throw new Error("Destination disabled");
|
||||||
|
return { client: target as unknown as DaemonClient, release: async () => {} };
|
||||||
|
},
|
||||||
|
deps: {
|
||||||
|
createClient: () => {
|
||||||
|
throw new Error("Origin must remain the same client");
|
||||||
|
},
|
||||||
|
connectToDaemon: async () => {
|
||||||
|
throw new Error("No new connection");
|
||||||
|
},
|
||||||
|
getClientId: async () => "cold-authority",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const host = makeHost({ serverId: aId, connections: [makeHost().connections[0]!] });
|
||||||
|
store.syncHosts([host], {
|
||||||
|
initialConnectionByServerId: new Map([
|
||||||
|
[
|
||||||
|
aId,
|
||||||
|
{
|
||||||
|
connectionId: host.connections[0]!.id,
|
||||||
|
existingClient: origin as unknown as DaemonClient,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
await onceHostSnapshotMatches(
|
||||||
|
store,
|
||||||
|
aId,
|
||||||
|
(snapshot) => snapshot?.connectionStatus === "online",
|
||||||
|
);
|
||||||
|
origin.setConnectionState({ status: "disconnected", reason: "Cold offline origin" });
|
||||||
|
const retained = store.acquireBinding({ originServerId: aId, serverId: bId });
|
||||||
|
try {
|
||||||
|
origin.setConnectionState({ status: "connected" });
|
||||||
|
await expect.poll(() => attempts).toBe(1);
|
||||||
|
if (timing === "after failure") {
|
||||||
|
gate.resolve();
|
||||||
|
await expect
|
||||||
|
.poll(() => retained.binding.runtime.getSnapshot(bId)?.connectionStatus)
|
||||||
|
.toBe("error");
|
||||||
|
}
|
||||||
|
enabled = true;
|
||||||
|
origin.hostsChanged();
|
||||||
|
gate.resolve();
|
||||||
|
await expect
|
||||||
|
.poll(() => retained.binding.runtime.getSnapshot(bId)?.connectionStatus)
|
||||||
|
.toBe("online");
|
||||||
|
expect(store.getClient(aId)).toBe(origin);
|
||||||
|
expect(retained.binding.runtime.getClient(bId)).toBe(target);
|
||||||
|
expect(attempts).toBeGreaterThanOrEqual(2);
|
||||||
|
} finally {
|
||||||
|
gate.resolve();
|
||||||
|
await retained.release();
|
||||||
|
store.syncHosts([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
import { useSyncExternalStore, useMemo } from "react";
|
import type { HostDirectory } from "@getpaseo/protocol/hosts";
|
||||||
|
import {
|
||||||
|
createWorkspaceLayoutStore,
|
||||||
|
type WorkspaceLayoutStoreApi,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
|
import { createPaseoApi, type PaseoAgentHandle } from "@getpaseo/client";
|
||||||
|
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||||
|
import {
|
||||||
|
acquireHostConnection,
|
||||||
|
type HostConnectionLease,
|
||||||
|
} from "@getpaseo/client/internal/host-connection";
|
||||||
|
import { createContext, useContext, useSyncExternalStore, useMemo } from "react";
|
||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
createProviderSubagentStore,
|
||||||
|
type ProviderSubagentStoreApi,
|
||||||
|
} from "@/subagents/provider-store";
|
||||||
|
import { ManagedSubagents } from "@/subagents/managed";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import equal from "fast-deep-equal/es6";
|
import equal from "fast-deep-equal/es6";
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +69,8 @@ import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
|||||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||||
import {
|
import {
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
|
type SessionStoreApi,
|
||||||
|
createSessionStore,
|
||||||
toDaemonServerInfo,
|
toDaemonServerInfo,
|
||||||
type Agent,
|
type Agent,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
@@ -60,7 +79,7 @@ import {
|
|||||||
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
|
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
|
||||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||||
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
|
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
|
||||||
import { queryClient } from "@/data/query-client";
|
import { queryClient, createQueryClient } from "@/data/query-client";
|
||||||
import {
|
import {
|
||||||
invalidateServerDataQueriesAfterReconnect,
|
invalidateServerDataQueriesAfterReconnect,
|
||||||
mountServerDataPushRouter,
|
mountServerDataPushRouter,
|
||||||
@@ -608,21 +627,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
}),
|
}),
|
||||||
getClientId: () => getOrCreateClientId(),
|
getClientId: () => getOrCreateClientId(),
|
||||||
mountClientHandlers: ({ client, host }) => {
|
mountClientHandlers: ({ client, host }) => {
|
||||||
const unmountServerData = mountServerDataPushRouter({
|
if (!browserAutomationCapabilities) return () => {};
|
||||||
client,
|
return mountBrowserAutomationDaemonClientHandler(client, { serverId: host.serverId });
|
||||||
queryClient,
|
|
||||||
serverId: host.serverId,
|
|
||||||
});
|
|
||||||
if (!browserAutomationCapabilities) {
|
|
||||||
return unmountServerData;
|
|
||||||
}
|
|
||||||
const unmountBrowserAutomation = mountBrowserAutomationDaemonClientHandler(client, {
|
|
||||||
serverId: host.serverId,
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
unmountBrowserAutomation();
|
|
||||||
unmountServerData();
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1486,8 +1492,64 @@ interface AgentDirectoryRefreshInput {
|
|||||||
page?: FetchAgentsOptions["page"];
|
page?: FetchAgentsOptions["page"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HostAuthorityReference {
|
||||||
|
originServerId: string;
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostAuthorityBinding {
|
||||||
|
readonly reference: HostAuthorityReference;
|
||||||
|
readonly namespace: string;
|
||||||
|
readonly sessionStore: SessionStoreApi;
|
||||||
|
readonly queries: QueryClient;
|
||||||
|
readonly providerSubagents: ProviderSubagentStoreApi;
|
||||||
|
readonly layout: WorkspaceLayoutStoreApi;
|
||||||
|
readonly runtime: HostRuntimeStore;
|
||||||
|
/** Initial acquisition only. Current availability comes from runtime snapshots. */
|
||||||
|
readonly ready: Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OwnedHostAuthorityBinding {
|
||||||
|
readonly binding: HostAuthorityBinding;
|
||||||
|
release(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorityNamespace(reference: HostAuthorityReference): string {
|
||||||
|
return JSON.stringify(["host-authority-v1", reference.originServerId, reference.serverId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialAuthoritySnapshot(serverId: string): HostRuntimeSnapshot {
|
||||||
|
return {
|
||||||
|
serverId,
|
||||||
|
activeConnectionId: null,
|
||||||
|
activeConnection: null,
|
||||||
|
connectionStatus: "connecting",
|
||||||
|
client: null,
|
||||||
|
lastError: null,
|
||||||
|
lastOnlineAt: null,
|
||||||
|
agentDirectoryStatus: "idle",
|
||||||
|
agentDirectoryError: null,
|
||||||
|
hasEverLoadedAgentDirectory: false,
|
||||||
|
probeByConnectionId: new Map(),
|
||||||
|
clientGeneration: 0,
|
||||||
|
connectionEpoch: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class HostRuntimeStore {
|
export class HostRuntimeStore {
|
||||||
|
readonly subagents: ManagedSubagents;
|
||||||
|
private readonly authority: HostAuthorityReference | null;
|
||||||
|
private readonly bindingOwner: HostRuntimeStore;
|
||||||
|
private disposed = false;
|
||||||
|
private readonly lifetime = new AbortController();
|
||||||
private controllers = new Map<string, HostRuntimeController>();
|
private controllers = new Map<string, HostRuntimeController>();
|
||||||
|
private readonly controllerObservations = new WeakMap<HostRuntimeController, () => void>();
|
||||||
|
private readonly authorityBindings = new Map<
|
||||||
|
string,
|
||||||
|
{ binding: HostAuthorityBinding; refs: number; close(): Promise<void> }
|
||||||
|
>();
|
||||||
|
private bindingSnapshot: readonly HostAuthorityBinding[] = [];
|
||||||
|
private fleetDirectory: HostDirectory | null = null;
|
||||||
private serverListeners = new Map<string, Set<() => void>>();
|
private serverListeners = new Map<string, Set<() => void>>();
|
||||||
private globalListeners = new Set<() => void>();
|
private globalListeners = new Set<() => void>();
|
||||||
private hostListListeners = new Set<() => void>();
|
private hostListListeners = new Set<() => void>();
|
||||||
@@ -1510,22 +1572,387 @@ export class HostRuntimeStore {
|
|||||||
private readonly hostConfirmations = new HostConfirmations();
|
private readonly hostConfirmations = new HostConfirmations();
|
||||||
private storage: HostRuntimeStorage;
|
private storage: HostRuntimeStorage;
|
||||||
private replicaCache: ReplicaCache;
|
private replicaCache: ReplicaCache;
|
||||||
|
private readonly sessionStore: SessionStoreApi;
|
||||||
|
private readonly queries: QueryClient;
|
||||||
|
private readonly serverDataClients = new Map<string, { client: DaemonClient; release(): void }>();
|
||||||
|
private readonly resolveHostConnection: typeof acquireHostConnection;
|
||||||
|
private readonly snapshots = new Map<string, HostRuntimeSnapshot>();
|
||||||
private readonly revokePushNotifications: typeof revokePushNotifications;
|
private readonly revokePushNotifications: typeof revokePushNotifications;
|
||||||
|
|
||||||
constructor(input?: {
|
constructor(input?: {
|
||||||
deps?: HostRuntimeControllerDeps;
|
deps?: HostRuntimeControllerDeps;
|
||||||
storage?: HostRuntimeStorage;
|
storage?: HostRuntimeStorage;
|
||||||
replicaRowStore?: ReplicaRowStore;
|
replicaRowStore?: ReplicaRowStore;
|
||||||
|
sessionStore?: SessionStoreApi;
|
||||||
|
queries?: QueryClient;
|
||||||
|
authority?: HostAuthorityReference;
|
||||||
|
bindingOwner?: HostRuntimeStore;
|
||||||
|
resolveHostConnection?: typeof acquireHostConnection;
|
||||||
revokePushNotifications?: typeof revokePushNotifications;
|
revokePushNotifications?: typeof revokePushNotifications;
|
||||||
}) {
|
}) {
|
||||||
this.deps = input?.deps ?? createDefaultDeps();
|
this.deps = input?.deps ?? createDefaultDeps();
|
||||||
|
this.sessionStore = input?.sessionStore ?? useSessionStore;
|
||||||
|
this.queries = input?.queries ?? queryClient;
|
||||||
|
this.authority = input?.authority ?? null;
|
||||||
|
this.bindingOwner = input?.bindingOwner ?? this;
|
||||||
|
this.subagents = new ManagedSubagents(this);
|
||||||
|
this.resolveHostConnection = input?.resolveHostConnection ?? acquireHostConnection;
|
||||||
this.storage = input?.storage ?? AsyncStorage;
|
this.storage = input?.storage ?? AsyncStorage;
|
||||||
this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore());
|
this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore());
|
||||||
this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications;
|
this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A retained screen owns a lease; route identity never grants access by itself. */
|
||||||
|
authorityFor(serverId: string): HostAuthorityReference {
|
||||||
|
return { originServerId: this.authority?.originServerId ?? serverId, serverId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve an ordinary agent action through the retained origin, owning its entire scope. */
|
||||||
|
async withAgent<T>(
|
||||||
|
address: AgentAddress,
|
||||||
|
operation: (agent: PaseoAgentHandle) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
if (this.disposed) throw new Error("Host authority binding is disposed");
|
||||||
|
const authority = this.authorityFor(address.serverId);
|
||||||
|
if (this.bindingOwner.getSnapshot(authority.originServerId)?.connectionStatus !== "online")
|
||||||
|
throw new Error("Selected host authority is unavailable");
|
||||||
|
const lease = this.bindingOwner.acquireBinding(authority);
|
||||||
|
let api: ReturnType<typeof createPaseoApi> | null = null;
|
||||||
|
try {
|
||||||
|
const client = await lease.binding.runtime.awaitActiveClient(
|
||||||
|
address.serverId,
|
||||||
|
this.lifetime.signal,
|
||||||
|
);
|
||||||
|
if (this.disposed) throw new Error("Host authority binding is disposed");
|
||||||
|
api = createPaseoApi(client);
|
||||||
|
return await operation(api.agents.ref(address.agentId));
|
||||||
|
} finally {
|
||||||
|
// Cleanup cannot erase the acknowledged outcome of a submitted action.
|
||||||
|
await Promise.allSettled([api?.dispose(), lease.release()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private awaitActiveClient(serverId: string, signal: AbortSignal): Promise<DaemonClient> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let stop = () => {};
|
||||||
|
const finish = (failure: Error | null, client?: DaemonClient) => {
|
||||||
|
stop();
|
||||||
|
signal.removeEventListener("abort", aborted);
|
||||||
|
if (failure) reject(failure);
|
||||||
|
else resolve(client!);
|
||||||
|
};
|
||||||
|
const aborted = () => finish(new Error("Host authority binding is disposed"));
|
||||||
|
const changed = () => {
|
||||||
|
if (signal.aborted || this.disposed) return aborted();
|
||||||
|
const snapshot = this.getSnapshot(serverId);
|
||||||
|
if (snapshot?.connectionStatus === "online" && snapshot.client)
|
||||||
|
finish(null, snapshot.client);
|
||||||
|
else if (snapshot?.connectionStatus !== "connecting")
|
||||||
|
finish(new Error(snapshot?.lastError ?? "Selected host authority is unavailable"));
|
||||||
|
};
|
||||||
|
stop = this.subscribe(serverId, changed);
|
||||||
|
signal.addEventListener("abort", aborted, { once: true });
|
||||||
|
changed();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
acquireBinding(reference: HostAuthorityReference): OwnedHostAuthorityBinding {
|
||||||
|
const normalized = { originServerId: reference.originServerId, serverId: reference.serverId };
|
||||||
|
const namespace = authorityNamespace(normalized);
|
||||||
|
let entry = this.authorityBindings.get(namespace);
|
||||||
|
if (!entry) {
|
||||||
|
const queries = createQueryClient();
|
||||||
|
const scope = this.createAuthorityScope(normalized, namespace, queries);
|
||||||
|
entry = {
|
||||||
|
binding: {
|
||||||
|
reference: normalized,
|
||||||
|
namespace,
|
||||||
|
sessionStore: scope.runtime.sessionStore,
|
||||||
|
queries,
|
||||||
|
providerSubagents: createProviderSubagentStore(),
|
||||||
|
layout: createWorkspaceLayoutStore(undefined, this.storage, {
|
||||||
|
namespace,
|
||||||
|
sessionStore: scope.runtime.sessionStore,
|
||||||
|
// COMPAT(preFleetDirectLayout): added in v0.12, remove after 2027-04-20.
|
||||||
|
...(normalized.originServerId === normalized.serverId
|
||||||
|
? { legacyServerId: normalized.serverId }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
runtime: scope.runtime,
|
||||||
|
ready: scope.ready,
|
||||||
|
},
|
||||||
|
refs: 0,
|
||||||
|
close: async () => {
|
||||||
|
try {
|
||||||
|
await scope.close();
|
||||||
|
} finally {
|
||||||
|
queries.clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.authorityBindings.set(namespace, entry);
|
||||||
|
this.publishAuthorityBindings();
|
||||||
|
}
|
||||||
|
entry.refs++;
|
||||||
|
const retained = entry;
|
||||||
|
let released: Promise<void> | null = null;
|
||||||
|
return {
|
||||||
|
binding: retained.binding,
|
||||||
|
release: () => {
|
||||||
|
if (!released) {
|
||||||
|
retained.refs--;
|
||||||
|
if (retained.refs === 0) {
|
||||||
|
if (this.authorityBindings.get(namespace) === retained) {
|
||||||
|
this.authorityBindings.delete(namespace);
|
||||||
|
this.publishAuthorityBindings();
|
||||||
|
}
|
||||||
|
released = retained.close();
|
||||||
|
} else released = Promise.resolve();
|
||||||
|
}
|
||||||
|
return released;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthorityBindings = (): readonly HostAuthorityBinding[] => this.bindingSnapshot;
|
||||||
|
|
||||||
|
private publishAuthorityBindings(): void {
|
||||||
|
this.bindingSnapshot = [...this.authorityBindings.values()].map((entry) => entry.binding);
|
||||||
|
this.version++;
|
||||||
|
for (const listener of this.globalListeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createAuthorityScope(
|
||||||
|
reference: HostAuthorityReference,
|
||||||
|
namespace: string,
|
||||||
|
queries: QueryClient,
|
||||||
|
): {
|
||||||
|
runtime: HostRuntimeStore;
|
||||||
|
ready: Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
} {
|
||||||
|
const scoped = new HostRuntimeStore({
|
||||||
|
sessionStore: createSessionStore(),
|
||||||
|
queries,
|
||||||
|
authority: reference,
|
||||||
|
bindingOwner: this.bindingOwner,
|
||||||
|
replicaRowStore: createReplicaRowStore(namespace),
|
||||||
|
storage: this.storage,
|
||||||
|
deps: this.deps,
|
||||||
|
});
|
||||||
|
const { serverId, originServerId } = reference;
|
||||||
|
scoped.sessionStore.getState().initializeSession(serverId, null);
|
||||||
|
scoped.snapshots.set(serverId, initialAuthoritySnapshot(serverId));
|
||||||
|
const patch = (change: Partial<HostRuntimeSnapshot>) => {
|
||||||
|
const previous = scoped.snapshots.get(serverId)!;
|
||||||
|
scoped.snapshots.set(serverId, { ...previous, ...change });
|
||||||
|
scoped.emit(serverId);
|
||||||
|
};
|
||||||
|
scoped.initializeReplica(serverId, {
|
||||||
|
onAgentStoppedRunning: (agentId) => scoped.drainQueuedAgentMessage(serverId, agentId),
|
||||||
|
markAgentLoading: () =>
|
||||||
|
patch({ agentDirectoryStatus: "initial_loading", agentDirectoryError: null }),
|
||||||
|
markAgentReady: () =>
|
||||||
|
patch({
|
||||||
|
agentDirectoryStatus: "ready",
|
||||||
|
agentDirectoryError: null,
|
||||||
|
hasEverLoadedAgentDirectory: true,
|
||||||
|
}),
|
||||||
|
markAgentError: (failure) =>
|
||||||
|
patch({
|
||||||
|
agentDirectoryStatus: scoped.getSnapshot(serverId)?.hasEverLoadedAgentDirectory
|
||||||
|
? "error_after_ready"
|
||||||
|
: "error_before_first_success",
|
||||||
|
agentDirectoryError: failure,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// A bound workspace needs its own directory, including destinations absent
|
||||||
|
// from the global sidebar host list. DirectorySync shares all demand sources.
|
||||||
|
const releaseDirectoryDemand = scoped.acquireDirectoryDemand(serverId);
|
||||||
|
let stopped = false;
|
||||||
|
let origin: DaemonClient | null = null;
|
||||||
|
let controller = new AbortController();
|
||||||
|
let lease: HostConnectionLease | null = null;
|
||||||
|
let stopTarget: (() => void) | null = null;
|
||||||
|
let directory: ReturnType<DaemonClient["observeHosts"]> | null = null;
|
||||||
|
let acquiring: Promise<void> | null = null;
|
||||||
|
let directoryRevision = 0;
|
||||||
|
let resolveReady!: () => void;
|
||||||
|
let rejectReady!: (failure: unknown) => void;
|
||||||
|
const ready = new Promise<void>((resolve, reject) => {
|
||||||
|
resolveReady = resolve;
|
||||||
|
rejectReady = reject;
|
||||||
|
});
|
||||||
|
void ready.catch(() => {});
|
||||||
|
let generation = 0;
|
||||||
|
const publish = (
|
||||||
|
client: DaemonClient | null,
|
||||||
|
state: ConnectionState,
|
||||||
|
failure: string | null = null,
|
||||||
|
) => {
|
||||||
|
const previous = scoped.snapshots.get(serverId)!;
|
||||||
|
const online = state.status === "connected";
|
||||||
|
let connectionStatus: HostRuntimeConnectionStatus = failure ? "error" : "offline";
|
||||||
|
if (state.status === "connecting") connectionStatus = "connecting";
|
||||||
|
if (online) connectionStatus = "online";
|
||||||
|
const snapshot: HostRuntimeSnapshot = {
|
||||||
|
...previous,
|
||||||
|
client,
|
||||||
|
connectionStatus,
|
||||||
|
lastError: failure,
|
||||||
|
clientGeneration: generation,
|
||||||
|
connectionEpoch:
|
||||||
|
online && previous.connectionStatus !== "online"
|
||||||
|
? previous.connectionEpoch + 1
|
||||||
|
: previous.connectionEpoch,
|
||||||
|
lastOnlineAt: online ? new Date().toISOString() : previous.lastOnlineAt,
|
||||||
|
};
|
||||||
|
scoped.syncSessionReplica(serverId, snapshot);
|
||||||
|
scoped.syncDirectoryConnection(serverId);
|
||||||
|
scoped.emit(serverId);
|
||||||
|
};
|
||||||
|
const acquire = () => {
|
||||||
|
if (
|
||||||
|
stopped ||
|
||||||
|
acquiring ||
|
||||||
|
lease ||
|
||||||
|
!origin ||
|
||||||
|
origin.getConnectionState().status !== "connected"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const source = origin;
|
||||||
|
const signal = controller.signal;
|
||||||
|
const attempt = generation;
|
||||||
|
const observedRevision = directoryRevision;
|
||||||
|
publish(null, { status: "connecting", attempt: 1 });
|
||||||
|
acquiring = (async () => {
|
||||||
|
try {
|
||||||
|
const acquired = await this.resolveHostConnection(source, serverId, signal);
|
||||||
|
if (stopped || signal.aborted || generation !== attempt) {
|
||||||
|
await acquired.release();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lease = acquired;
|
||||||
|
stopTarget = acquired.client.subscribeConnectionStatus((state) => {
|
||||||
|
if (!stopped && generation === attempt)
|
||||||
|
publish(acquired.client, state, acquired.client.lastError);
|
||||||
|
});
|
||||||
|
resolveReady();
|
||||||
|
} catch (failure) {
|
||||||
|
if (!stopped && generation === attempt) {
|
||||||
|
publish(null, { status: "disconnected" }, toErrorMessage(failure));
|
||||||
|
rejectReady(failure);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (generation === attempt) {
|
||||||
|
acquiring = null;
|
||||||
|
if (!lease && directoryRevision !== observedRevision) acquire();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
const retired = new Set<Promise<unknown>>();
|
||||||
|
const retire = (release: Promise<unknown>) => {
|
||||||
|
retired.add(release);
|
||||||
|
void release.then(
|
||||||
|
() => retired.delete(release),
|
||||||
|
() => retired.delete(release),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const syncOrigin = () => {
|
||||||
|
if (stopped) return;
|
||||||
|
const snapshot = this.getSnapshot(originServerId);
|
||||||
|
const nextOrigin = snapshot?.client ?? null;
|
||||||
|
if (origin !== nextOrigin) {
|
||||||
|
generation++;
|
||||||
|
controller.abort();
|
||||||
|
stopTarget?.();
|
||||||
|
stopTarget = null;
|
||||||
|
if (lease) retire(lease.release());
|
||||||
|
if (directory) retire(directory.release());
|
||||||
|
if (acquiring) retire(acquiring);
|
||||||
|
lease = null;
|
||||||
|
directory = null;
|
||||||
|
acquiring = null;
|
||||||
|
controller = new AbortController();
|
||||||
|
origin = nextOrigin;
|
||||||
|
publish(null, { status: "disconnected" });
|
||||||
|
}
|
||||||
|
if (origin && origin.getConnectionState().status === "connected" && !directory) {
|
||||||
|
try {
|
||||||
|
directory = origin.observeHosts();
|
||||||
|
const changed = (value: HostDirectory) => {
|
||||||
|
scoped.fleetDirectory = value;
|
||||||
|
scoped.emit(serverId);
|
||||||
|
directoryRevision++;
|
||||||
|
acquire();
|
||||||
|
};
|
||||||
|
directory.subscribe({
|
||||||
|
snapshot: changed,
|
||||||
|
update: (message) => {
|
||||||
|
if (message.type === "hosts.changed") changed(message.payload.directory);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
void directory.ready.catch(() => {});
|
||||||
|
} catch (failure) {
|
||||||
|
publish(null, { status: "disconnected" }, toErrorMessage(failure));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (snapshot?.connectionStatus === "online") acquire();
|
||||||
|
else if (!lease)
|
||||||
|
publish(
|
||||||
|
null,
|
||||||
|
{ status: "disconnected" },
|
||||||
|
snapshot?.lastError ?? "Origin authority is unavailable",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const stopOrigin = this.subscribe(originServerId, syncOrigin);
|
||||||
|
syncOrigin();
|
||||||
|
return {
|
||||||
|
runtime: scoped,
|
||||||
|
ready,
|
||||||
|
close: async () => {
|
||||||
|
scoped.disposed = true;
|
||||||
|
scoped.lifetime.abort();
|
||||||
|
stopped = true;
|
||||||
|
generation++;
|
||||||
|
stopOrigin();
|
||||||
|
controller.abort();
|
||||||
|
stopTarget?.();
|
||||||
|
rejectReady(new Error("Host authority binding is disposed"));
|
||||||
|
scoped.syncServerDataClient(serverId, null);
|
||||||
|
releaseDirectoryDemand();
|
||||||
|
scoped.directorySyncByServer.get(serverId)?.dispose();
|
||||||
|
scoped.sessionStore.getState().sessions[serverId]?.viewedTimelineSync?.dispose();
|
||||||
|
for (const agentId of scoped.sessionStore
|
||||||
|
.getState()
|
||||||
|
.sessions[serverId]?.initializingAgents.keys() ?? []) {
|
||||||
|
scoped.sessionStore.initializations.rejectInitDeferred(
|
||||||
|
scoped.sessionStore.initializations.getInitKey(serverId, agentId),
|
||||||
|
new Error("Host authority binding is disposed"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Promise.allSettled([
|
||||||
|
lease?.release(),
|
||||||
|
directory?.release(),
|
||||||
|
acquiring,
|
||||||
|
...retired,
|
||||||
|
scoped.subagents.releaseServer(serverId),
|
||||||
|
]);
|
||||||
|
await scoped.replicaCache.flush();
|
||||||
|
scoped.sessionStore.getState().clearSession(serverId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// --- Host registry ---
|
// --- Host registry ---
|
||||||
|
|
||||||
|
getHostLabel(serverId: string): string {
|
||||||
|
const directory = this.fleetDirectory;
|
||||||
|
if (directory?.self.serverId === serverId) return directory.self.label;
|
||||||
|
return directory?.hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||||
|
}
|
||||||
|
|
||||||
getHosts(): HostProfile[] {
|
getHosts(): HostProfile[] {
|
||||||
return this.hosts;
|
return this.hosts;
|
||||||
}
|
}
|
||||||
@@ -1772,6 +2199,7 @@ export class HostRuntimeStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rekeyMap(this.controllers, oldServerId, newServerId);
|
rekeyMap(this.controllers, oldServerId, newServerId);
|
||||||
|
this.snapshots.delete(oldServerId);
|
||||||
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
|
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
|
||||||
rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId);
|
rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId);
|
||||||
this.replicaCache.reconcileServerId(oldServerId, newServerId);
|
this.replicaCache.reconcileServerId(oldServerId, newServerId);
|
||||||
@@ -1779,25 +2207,12 @@ export class HostRuntimeStore {
|
|||||||
this.directorySyncByServer.get(oldServerId)?.dispose();
|
this.directorySyncByServer.get(oldServerId)?.dispose();
|
||||||
this.directorySyncByServer.delete(oldServerId);
|
this.directorySyncByServer.delete(oldServerId);
|
||||||
this.timelineReplicaByServer.delete(oldServerId);
|
this.timelineReplicaByServer.delete(oldServerId);
|
||||||
const directory = new DirectorySync(
|
const directory = this.initializeReplica(newServerId, {
|
||||||
newServerId,
|
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
|
||||||
{
|
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||||
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
|
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
});
|
||||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
|
||||||
},
|
|
||||||
this.replicaCache,
|
|
||||||
);
|
|
||||||
this.directorySyncByServer.set(newServerId, directory);
|
|
||||||
this.timelineReplicaByServer.set(
|
|
||||||
newServerId,
|
|
||||||
createTimelineReplica({
|
|
||||||
serverId: newServerId,
|
|
||||||
storage: this.replicaCache,
|
|
||||||
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
controller.adoptReconciledServerId(newServerId);
|
controller.adoptReconciledServerId(newServerId);
|
||||||
const snapshot = controller.getSnapshot();
|
const snapshot = controller.getSnapshot();
|
||||||
this.clearHostReplica(oldServerId);
|
this.clearHostReplica(oldServerId);
|
||||||
@@ -2258,7 +2673,10 @@ export class HostRuntimeStore {
|
|||||||
if (nextIds.has(serverId)) {
|
if (nextIds.has(serverId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
this.controllerObservations.get(controller)?.();
|
||||||
|
this.controllerObservations.delete(controller);
|
||||||
this.controllers.delete(serverId);
|
this.controllers.delete(serverId);
|
||||||
|
this.snapshots.delete(serverId);
|
||||||
this.lastConnectionStatusByServer.delete(serverId);
|
this.lastConnectionStatusByServer.delete(serverId);
|
||||||
this.connectionStatusStartedAtByServer.delete(serverId);
|
this.connectionStatusStartedAtByServer.delete(serverId);
|
||||||
this.directorySyncByServer.get(serverId)?.dispose();
|
this.directorySyncByServer.get(serverId)?.dispose();
|
||||||
@@ -2287,35 +2705,24 @@ export class HostRuntimeStore {
|
|||||||
onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId),
|
onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId),
|
||||||
});
|
});
|
||||||
this.controllers.set(host.serverId, controller);
|
this.controllers.set(host.serverId, controller);
|
||||||
useSessionStore.getState().initializeSession(host.serverId, null);
|
this.sessionStore.getState().initializeSession(host.serverId, null);
|
||||||
const directory = new DirectorySync(
|
this.initializeReplica(host.serverId, {
|
||||||
host.serverId,
|
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
|
||||||
{
|
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||||
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
|
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
});
|
||||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
|
||||||
},
|
|
||||||
this.replicaCache,
|
|
||||||
);
|
|
||||||
this.directorySyncByServer.set(host.serverId, directory);
|
|
||||||
this.timelineReplicaByServer.set(
|
|
||||||
host.serverId,
|
|
||||||
createTimelineReplica({
|
|
||||||
serverId: host.serverId,
|
|
||||||
storage: this.replicaCache,
|
|
||||||
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const initialSnapshot = controller.getSnapshot();
|
const initialSnapshot = controller.getSnapshot();
|
||||||
|
this.syncSessionReplica(host.serverId, initialSnapshot);
|
||||||
this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus);
|
this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus);
|
||||||
this.connectionStatusStartedAtByServer.set(host.serverId, Date.now());
|
this.connectionStatusStartedAtByServer.set(host.serverId, Date.now());
|
||||||
controller.subscribe(() => {
|
const stopObservation = controller.subscribe(() => {
|
||||||
const snapshot = controller.getSnapshot();
|
const snapshot = controller.getSnapshot();
|
||||||
this.syncSessionReplica(snapshot.serverId, snapshot);
|
this.syncSessionReplica(snapshot.serverId, snapshot);
|
||||||
this.syncDirectoryConnection(snapshot.serverId);
|
this.syncDirectoryConnection(snapshot.serverId);
|
||||||
this.emit(snapshot.serverId);
|
this.emit(snapshot.serverId);
|
||||||
});
|
});
|
||||||
|
this.controllerObservations.set(controller, stopObservation);
|
||||||
void controller
|
void controller
|
||||||
.start(
|
.start(
|
||||||
initialConnection
|
initialConnection
|
||||||
@@ -2332,13 +2739,31 @@ export class HostRuntimeStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private initializeReplica(
|
||||||
|
serverId: string,
|
||||||
|
callbacks: ConstructorParameters<typeof DirectorySync>[1],
|
||||||
|
): DirectorySync {
|
||||||
|
const directory = new DirectorySync(serverId, callbacks, this.replicaCache, this.sessionStore);
|
||||||
|
this.directorySyncByServer.set(serverId, directory);
|
||||||
|
this.timelineReplicaByServer.set(
|
||||||
|
serverId,
|
||||||
|
createTimelineReplica({
|
||||||
|
serverId,
|
||||||
|
store: this.sessionStore,
|
||||||
|
storage: this.replicaCache,
|
||||||
|
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
private syncSessionReplica(serverId: string, snapshot: HostRuntimeSnapshot): void {
|
private syncSessionReplica(serverId: string, snapshot: HostRuntimeSnapshot): void {
|
||||||
if (!snapshot.client) {
|
this.snapshots.set(serverId, snapshot);
|
||||||
return;
|
const sessionStore = this.sessionStore.getState();
|
||||||
}
|
|
||||||
const sessionStore = useSessionStore.getState();
|
|
||||||
sessionStore.initializeSession(serverId, snapshot.client, snapshot.clientGeneration);
|
sessionStore.initializeSession(serverId, snapshot.client, snapshot.clientGeneration);
|
||||||
sessionStore.updateSessionClient(serverId, snapshot.client, snapshot.clientGeneration);
|
sessionStore.updateSessionClient(serverId, snapshot.client, snapshot.clientGeneration);
|
||||||
|
this.syncServerDataClient(serverId, snapshot.client);
|
||||||
|
if (!snapshot.client) return;
|
||||||
// A reconnect keeps the same client, so the daemon's handshake (a restart or upgrade can
|
// A reconnect keeps the same client, so the daemon's handshake (a restart or upgrade can
|
||||||
// change its version and features) only reaches the store here. The client clears it while
|
// change its version and features) only reaches the store here. The client clears it while
|
||||||
// disconnected; keep the last known value until the next handshake.
|
// disconnected; keep the last known value until the next handshake.
|
||||||
@@ -2348,19 +2773,32 @@ export class HostRuntimeStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncServerDataClient(serverId: string, client: DaemonClient | null): void {
|
||||||
|
const current = this.serverDataClients.get(serverId);
|
||||||
|
if (current?.client === client) return;
|
||||||
|
current?.release();
|
||||||
|
this.serverDataClients.delete(serverId);
|
||||||
|
if (client)
|
||||||
|
this.serverDataClients.set(serverId, {
|
||||||
|
client,
|
||||||
|
release: mountServerDataPushRouter({ client, serverId, queryClient: this.queries }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private clearHostReplica(serverId: string): void {
|
private clearHostReplica(serverId: string): void {
|
||||||
useSessionStore.getState().clearSession(serverId);
|
this.syncServerDataClient(serverId, null);
|
||||||
|
void this.subagents.releaseServer(serverId);
|
||||||
|
this.sessionStore.getState().clearSession(serverId);
|
||||||
useWorkspaceSetupStore.getState().clearServer(serverId);
|
useWorkspaceSetupStore.getState().clearServer(serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncDirectoryConnection(serverId: string): void {
|
private syncDirectoryConnection(serverId: string): void {
|
||||||
const controller = this.controllers.get(serverId);
|
const snapshot = this.snapshots.get(serverId);
|
||||||
if (!controller) {
|
if (!snapshot) {
|
||||||
this.lastConnectionStatusByServer.delete(serverId);
|
this.lastConnectionStatusByServer.delete(serverId);
|
||||||
this.connectionStatusStartedAtByServer.delete(serverId);
|
this.connectionStatusStartedAtByServer.delete(serverId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const snapshot = controller.getSnapshot();
|
|
||||||
const directory = this.directorySyncByServer.get(serverId);
|
const directory = this.directorySyncByServer.get(serverId);
|
||||||
directory?.connectionChanged({
|
directory?.connectionChanged({
|
||||||
client: snapshot.client,
|
client: snapshot.client,
|
||||||
@@ -2383,20 +2821,20 @@ export class HostRuntimeStore {
|
|||||||
const didTransitionOnline =
|
const didTransitionOnline =
|
||||||
snapshot.connectionStatus === "online" && previousStatus !== "online";
|
snapshot.connectionStatus === "online" && previousStatus !== "online";
|
||||||
if (didTransitionOnline) {
|
if (didTransitionOnline) {
|
||||||
useSessionStore.getState().bumpHistorySyncGeneration(serverId);
|
this.sessionStore.getState().bumpHistorySyncGeneration(serverId);
|
||||||
// Checkout git data is push-driven; pushes emitted while disconnected are gone for
|
// Checkout git data is push-driven; pushes emitted while disconnected are gone for
|
||||||
// good (the daemon dedupes by snapshot fingerprint). Mark the caches stale so active
|
// good (the daemon dedupes by snapshot fingerprint). Mark the caches stale so active
|
||||||
// queries refetch now and evicted ones on their next mount.
|
// queries refetch now and evicted ones on their next mount.
|
||||||
void invalidateCheckoutGitQueriesForServer(queryClient, serverId);
|
void invalidateCheckoutGitQueriesForServer(this.queries, serverId);
|
||||||
invalidateServerDataQueriesAfterReconnect({ queryClient, serverId });
|
invalidateServerDataQueriesAfterReconnect({ queryClient: this.queries, serverId });
|
||||||
void queryClient.invalidateQueries({ queryKey: schedulesQueryBaseKey });
|
void this.queries.invalidateQueries({ queryKey: schedulesQueryBaseKey });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drainQueuedAgentMessage(serverId: string, agentId: string): void {
|
drainQueuedAgentMessage(serverId: string, agentId: string): void {
|
||||||
const drainKey = `${serverId}:${agentId}`;
|
const drainKey = `${serverId}:${agentId}`;
|
||||||
if (this.queuedAgentDrainInFlight.has(drainKey)) return;
|
if (this.queuedAgentDrainInFlight.has(drainKey)) return;
|
||||||
const store = useSessionStore.getState();
|
const store = this.sessionStore.getState();
|
||||||
const session = store.sessions[serverId];
|
const session = store.sessions[serverId];
|
||||||
const queue = session?.queuedMessages.get(agentId);
|
const queue = session?.queuedMessages.get(agentId);
|
||||||
const client = session?.client;
|
const client = session?.client;
|
||||||
@@ -2410,12 +2848,13 @@ export class HostRuntimeStore {
|
|||||||
messageId: next.id,
|
messageId: next.id,
|
||||||
queue: {
|
queue: {
|
||||||
read: (queuedAgentId) =>
|
read: (queuedAgentId) =>
|
||||||
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
|
this.sessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
|
||||||
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
|
write: (update) => this.sessionStore.getState().setQueuedMessages(serverId, update),
|
||||||
},
|
},
|
||||||
submitMessage: async ({ text, attachments }) => {
|
submitMessage: async ({ text, attachments }) => {
|
||||||
const supportsForgeAttachments =
|
const supportsForgeAttachments =
|
||||||
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true;
|
this.sessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch ===
|
||||||
|
true;
|
||||||
await dispatchComposerAgentMessage({
|
await dispatchComposerAgentMessage({
|
||||||
client,
|
client,
|
||||||
agentId,
|
agentId,
|
||||||
@@ -2425,7 +2864,7 @@ export class HostRuntimeStore {
|
|||||||
supportsForgeAttachments,
|
supportsForgeAttachments,
|
||||||
}),
|
}),
|
||||||
encodeImages,
|
encodeImages,
|
||||||
submission: createMessageSubmissionWriter(serverId),
|
submission: createMessageSubmissionWriter(serverId, this.sessionStore),
|
||||||
// The queue drains when the agent goes idle, but the agent can start a turn of its own
|
// The queue drains when the agent goes idle, but the agent can start a turn of its own
|
||||||
// in that same moment (a wake-up after background work). Steering folds the message
|
// in that same moment (a wake-up after background work). Steering folds the message
|
||||||
// into that turn instead of interrupting it; with no turn running, the daemon starts
|
// into that turn instead of interrupting it; with no turn running, the daemon starts
|
||||||
@@ -2457,13 +2896,29 @@ export class HostRuntimeStore {
|
|||||||
this.directorySyncByServer.get(serverId)?.applyAgentTurnLiveness(agentId, transition);
|
this.directorySyncByServer.get(serverId)?.applyAgentTurnLiveness(agentId, transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
beginAgentCancellation(serverId: string, agentId: string): number {
|
async cancelAgent(address: AgentAddress): Promise<void> {
|
||||||
|
const client = this.getClient(address.serverId);
|
||||||
|
if (
|
||||||
|
this.disposed ||
|
||||||
|
!client ||
|
||||||
|
this.getSnapshot(address.serverId)?.connectionStatus !== "online"
|
||||||
|
)
|
||||||
|
throw new Error("Selected host authority is unavailable");
|
||||||
|
const requestId = this.beginAgentCancellation(address.serverId, address.agentId);
|
||||||
|
try {
|
||||||
|
await client.cancelAgent(address.agentId);
|
||||||
|
} finally {
|
||||||
|
this.settleAgentCancellation(address.serverId, address.agentId, requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private beginAgentCancellation(serverId: string, agentId: string): number {
|
||||||
const requestId = ++this.nextCancellationRequestId;
|
const requestId = ++this.nextCancellationRequestId;
|
||||||
this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_started", requestId });
|
this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_started", requestId });
|
||||||
return requestId;
|
return requestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
settleAgentCancellation(serverId: string, agentId: string, requestId: number): void {
|
private settleAgentCancellation(serverId: string, agentId: string, requestId: number): void {
|
||||||
this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_settled", requestId });
|
this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_settled", requestId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2500,7 +2955,7 @@ export class HostRuntimeStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
getSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
||||||
return this.controllers.get(serverId)?.getSnapshot() ?? null;
|
return this.snapshots.get(serverId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getConnectionStatusSince(serverId: string): number | null {
|
getConnectionStatusSince(serverId: string): number | null {
|
||||||
@@ -2511,8 +2966,12 @@ export class HostRuntimeStore {
|
|||||||
return this.version;
|
return this.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getServerInfo(serverId: string) {
|
||||||
|
return this.sessionStore.getState().sessions[serverId]?.serverInfo ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
getClient(serverId: string): DaemonClient | null {
|
getClient(serverId: string): DaemonClient | null {
|
||||||
return this.controllers.get(serverId)?.getClient() ?? null;
|
return this.snapshots.get(serverId)?.client ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(serverId: string, listener: () => void): () => void {
|
subscribe(serverId: string, listener: () => void): () => void {
|
||||||
@@ -2639,9 +3098,9 @@ export class HostRuntimeStore {
|
|||||||
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
|
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
|
||||||
const directory = this.directorySyncByServer.get(serverId);
|
const directory = this.directorySyncByServer.get(serverId);
|
||||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||||
const owner = useSessionStore.getState().sessions[serverId]?.viewedTimelineSync;
|
const owner = this.sessionStore.getState().sessions[serverId]?.viewedTimelineSync;
|
||||||
const page = await directory.fetchTimeline(agentId, request);
|
const page = await directory.fetchTimeline(agentId, request);
|
||||||
if (owner && useSessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) {
|
if (owner && this.sessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) {
|
||||||
owner.flushStreamAgent(agentId);
|
owner.flushStreamAgent(agentId);
|
||||||
owner.applyTimelineResponse(page);
|
owner.applyTimelineResponse(page);
|
||||||
}
|
}
|
||||||
@@ -2657,6 +3116,7 @@ export class HostRuntimeStore {
|
|||||||
const replica = this.timelineReplicaByServer.get(serverId);
|
const replica = this.timelineReplicaByServer.get(serverId);
|
||||||
if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||||
return createViewedTimelineOwner({
|
return createViewedTimelineOwner({
|
||||||
|
store: this.sessionStore,
|
||||||
serverId,
|
serverId,
|
||||||
replica,
|
replica,
|
||||||
replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds),
|
replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds),
|
||||||
@@ -2706,8 +3166,15 @@ export function getHostRuntimeConnectionStatusSince(serverId: string): number |
|
|||||||
return getHostRuntimeStore().getConnectionStatusSince(serverId);
|
return getHostRuntimeStore().getConnectionStatusSince(serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const HostRuntimeContext = createContext<HostRuntimeStore | null>(null);
|
||||||
|
|
||||||
|
/** Ordinary conversation hooks read the capability bound to their retained screen. */
|
||||||
|
export function useHostRuntimeStore(): HostRuntimeStore {
|
||||||
|
return useContext(HostRuntimeContext) ?? getHostRuntimeStore();
|
||||||
|
}
|
||||||
|
|
||||||
export function useHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
export function useHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
||||||
const store = getHostRuntimeStore();
|
const store = useHostRuntimeStore();
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||||
() => store.getSnapshot(serverId),
|
() => store.getSnapshot(serverId),
|
||||||
@@ -2716,7 +3183,7 @@ export function useHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot |
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useHostRuntimeClient(serverId: string): DaemonClient | null {
|
export function useHostRuntimeClient(serverId: string): DaemonClient | null {
|
||||||
const store = getHostRuntimeStore();
|
const store = useHostRuntimeStore();
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||||
() => store.getSnapshot(serverId)?.client ?? null,
|
() => store.getSnapshot(serverId)?.client ?? null,
|
||||||
@@ -2725,7 +3192,7 @@ export function useHostRuntimeClient(serverId: string): DaemonClient | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useHostRuntimeIsConnected(serverId: string): boolean {
|
export function useHostRuntimeIsConnected(serverId: string): boolean {
|
||||||
const store = getHostRuntimeStore();
|
const store = useHostRuntimeStore();
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||||
() => isHostRuntimeConnected(store.getSnapshot(serverId)),
|
() => isHostRuntimeConnected(store.getSnapshot(serverId)),
|
||||||
@@ -2734,7 +3201,7 @@ export function useHostRuntimeIsConnected(serverId: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeConnectionStatus {
|
export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeConnectionStatus {
|
||||||
const store = getHostRuntimeStore();
|
const store = useHostRuntimeStore();
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||||
() => store.getSnapshot(serverId)?.connectionStatus ?? "connecting",
|
() => store.getSnapshot(serverId)?.connectionStatus ?? "connecting",
|
||||||
@@ -2745,7 +3212,7 @@ export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeCon
|
|||||||
export function useHostRuntimeConnectionStatuses(
|
export function useHostRuntimeConnectionStatuses(
|
||||||
serverIds: readonly string[],
|
serverIds: readonly string[],
|
||||||
): ReadonlyMap<string, HostRuntimeConnectionStatus> {
|
): ReadonlyMap<string, HostRuntimeConnectionStatus> {
|
||||||
const store = getHostRuntimeStore();
|
const store = useHostRuntimeStore();
|
||||||
// The snapshot is the statuses themselves, joined into a string so React compares by
|
// The snapshot is the statuses themselves, joined into a string so React compares by
|
||||||
// value. A version counter read only for reactivity is dropped by the React Compiler.
|
// value. A version counter read only for reactivity is dropped by the React Compiler.
|
||||||
const readStatuses = () =>
|
const readStatuses = () =>
|
||||||
@@ -2765,7 +3232,7 @@ export function useHostRuntimeConnectionStatuses(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useHostRuntimeLastError(serverId: string): string | null {
|
export function useHostRuntimeLastError(serverId: string): string | null {
|
||||||
const store = getHostRuntimeStore();
|
const store = useHostRuntimeStore();
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||||
() => store.getSnapshot(serverId)?.lastError ?? null,
|
() => store.getSnapshot(serverId)?.lastError ?? null,
|
||||||
@@ -2776,7 +3243,7 @@ export function useHostRuntimeLastError(serverId: string): string | null {
|
|||||||
export function useHostRuntimeAgentDirectoryStatus(
|
export function useHostRuntimeAgentDirectoryStatus(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
): HostRuntimeAgentDirectoryStatus {
|
): HostRuntimeAgentDirectoryStatus {
|
||||||
const store = getHostRuntimeStore();
|
const store = useHostRuntimeStore();
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||||
() => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle",
|
() => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle",
|
||||||
@@ -2785,7 +3252,7 @@ export function useHostRuntimeAgentDirectoryStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useHostRuntimeIsDirectoryLoading(serverId: string): boolean {
|
export function useHostRuntimeIsDirectoryLoading(serverId: string): boolean {
|
||||||
const store = getHostRuntimeStore();
|
const store = useHostRuntimeStore();
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||||
() => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)),
|
() => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)),
|
||||||
|
|||||||
@@ -1124,3 +1124,28 @@ it("retains semantic agent messages across disk-cache reconstruction", async ()
|
|||||||
await writer.flush();
|
await writer.flush();
|
||||||
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toEqual(value);
|
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toEqual(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retains owner membership and explicit clearing across disk-cache reconstruction", async () => {
|
||||||
|
const storage = new MemoryStorage();
|
||||||
|
const writer = createCache(storage);
|
||||||
|
const value = directory();
|
||||||
|
const original = agent();
|
||||||
|
const parent = { serverId: SERVER_ID, agentId: "parent" };
|
||||||
|
const managedRelationship = {
|
||||||
|
id: "relationship",
|
||||||
|
parent,
|
||||||
|
parentWorkspace: { serverId: SERVER_ID, workspaceId: "workspace-1" },
|
||||||
|
};
|
||||||
|
value.agents.set(original.id, { ...original, parent, managedRelationship });
|
||||||
|
commitDirectory(writer, SERVER_ID, value);
|
||||||
|
await writer.flush();
|
||||||
|
expect(
|
||||||
|
(await createCache(storage).readDirectory(SERVER_ID)).agents.get(original.id),
|
||||||
|
).toMatchObject({ parent, managedRelationship });
|
||||||
|
value.agents.set(original.id, { ...original, parent, managedRelationship: null });
|
||||||
|
commitDirectory(writer, SERVER_ID, value);
|
||||||
|
await writer.flush();
|
||||||
|
expect(
|
||||||
|
(await createCache(storage).readDirectory(SERVER_ID)).agents.get(original.id),
|
||||||
|
).toMatchObject({ parent, managedRelationship: null });
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { AgentAddressSchema } from "@getpaseo/protocol/addresses";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
AgentStatusSchema,
|
AgentStatusSchema,
|
||||||
|
ManagedAgentRelationshipSchema,
|
||||||
AgentTimelineItemPayloadSchema,
|
AgentTimelineItemPayloadSchema,
|
||||||
WorkspaceGitHubRuntimePayloadSchema,
|
WorkspaceGitHubRuntimePayloadSchema,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
@@ -221,6 +223,8 @@ const StoredProjectPlacementSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const StoredAgentSnapshotSchema = z.strictObject({
|
const StoredAgentSnapshotSchema = z.strictObject({
|
||||||
|
parent: AgentAddressSchema.nullable().optional(),
|
||||||
|
managedRelationship: ManagedAgentRelationshipSchema.nullable().optional(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
provider: AgentProviderSchema,
|
provider: AgentProviderSchema,
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -611,6 +615,8 @@ function serializeAgentTurn(agent: Agent): NonNullable<StoredAgent["turn"]> {
|
|||||||
function serializeAgent(agent: Agent): StoredAgent {
|
function serializeAgent(agent: Agent): StoredAgent {
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
id: agent.id,
|
id: agent.id,
|
||||||
|
parent: agent.parent,
|
||||||
|
managedRelationship: agent.managedRelationship,
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}),
|
...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}),
|
||||||
|
|||||||
@@ -38,12 +38,14 @@ function createConnection(database: SQLiteDatabase): ReplicaSqliteConnection {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const expoSqliteDriver: ReplicaSqliteDriver = {
|
export function createReplicaRowStore(authorityNamespace?: string): ReplicaRowStore {
|
||||||
async open() {
|
const databaseName = authorityNamespace
|
||||||
return createConnection(await openDatabaseAsync(DATABASE_NAME));
|
? `paseo-replica-${encodeURIComponent(authorityNamespace)}.db`
|
||||||
},
|
: DATABASE_NAME;
|
||||||
};
|
const driver: ReplicaSqliteDriver = {
|
||||||
|
async open() {
|
||||||
export function createReplicaRowStore(): ReplicaRowStore {
|
return createConnection(await openDatabaseAsync(databaseName));
|
||||||
return createSqliteReplicaRowStore(expoSqliteDriver, REPLICA_ROW_STORE_SCHEMA_VERSION);
|
},
|
||||||
|
};
|
||||||
|
return createSqliteReplicaRowStore(driver, REPLICA_ROW_STORE_SCHEMA_VERSION);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from "fake-indexeddb";
|
} from "fake-indexeddb";
|
||||||
import { expect, it, vi } from "vitest";
|
import { expect, it, vi } from "vitest";
|
||||||
import { runReplicaRowStoreContract } from "./row-store.contract";
|
import { runReplicaRowStoreContract } from "./row-store.contract";
|
||||||
import { createIndexedDbReplicaRowStore } from "./row-store.web";
|
import { createReplicaRowStore, createIndexedDbReplicaRowStore } from "./row-store.web";
|
||||||
|
|
||||||
globalThis.indexedDB = fakeIndexedDb;
|
globalThis.indexedDB = fakeIndexedDb;
|
||||||
globalThis.IDBKeyRange = FakeIDBKeyRange;
|
globalThis.IDBKeyRange = FakeIDBKeyRange;
|
||||||
@@ -49,3 +49,27 @@ it("uses exact IndexedDB keys for targeted rows instead of scanning the host", a
|
|||||||
get.mockRestore();
|
get.mockRestore();
|
||||||
getAll.mockRestore();
|
getAll.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores only rows in the selected authority namespace while retaining the destination server ID", async () => {
|
||||||
|
const serverId = `B-${crypto.randomUUID()}`;
|
||||||
|
const origin = `A-${crypto.randomUUID()}`;
|
||||||
|
const direct = createReplicaRowStore();
|
||||||
|
const selected = createReplicaRowStore(origin);
|
||||||
|
await Promise.all([direct.open(), selected.open()]);
|
||||||
|
const row = {
|
||||||
|
serverId,
|
||||||
|
kind: "agent" as const,
|
||||||
|
id: "colliding-agent",
|
||||||
|
payload: "broader-direct",
|
||||||
|
};
|
||||||
|
await direct.apply({ upserts: [row], deletes: [] });
|
||||||
|
expect(await selected.read(serverId, ["agent"])).toEqual([]);
|
||||||
|
await selected.apply({ upserts: [{ ...row, payload: "selected-authority" }], deletes: [] });
|
||||||
|
const restored = createReplicaRowStore(origin);
|
||||||
|
await restored.open();
|
||||||
|
expect(await restored.read(serverId, ["agent"])).toEqual([
|
||||||
|
{ ...row, payload: "selected-authority" },
|
||||||
|
]);
|
||||||
|
expect(await direct.read(serverId, ["agent"])).toEqual([row]);
|
||||||
|
await Promise.all([selected.clear(), direct.deleteHost(serverId)]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -210,9 +210,11 @@ export function createIndexedDbReplicaRowStore(
|
|||||||
return { open, read, readAll, apply, deleteHost, renameHost, clear };
|
return { open, read, readAll, apply, deleteHost, renameHost, clear };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createReplicaRowStore(): ReplicaRowStore {
|
export function createReplicaRowStore(authorityNamespace?: string): ReplicaRowStore {
|
||||||
return createIndexedDbReplicaRowStore({
|
return createIndexedDbReplicaRowStore({
|
||||||
databaseName: DATABASE_NAME,
|
databaseName: authorityNamespace
|
||||||
|
? `${DATABASE_NAME}:${encodeURIComponent(authorityNamespace)}`
|
||||||
|
: DATABASE_NAME,
|
||||||
schemaVersion: REPLICA_ROW_STORE_SCHEMA_VERSION,
|
schemaVersion: REPLICA_ROW_STORE_SCHEMA_VERSION,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ import {
|
|||||||
WorkspacePaneContent,
|
WorkspacePaneContent,
|
||||||
type WorkspacePaneContentModel,
|
type WorkspacePaneContentModel,
|
||||||
} from "@/screens/workspace/workspace-pane-content";
|
} from "@/screens/workspace/workspace-pane-content";
|
||||||
import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store";
|
import {
|
||||||
|
useWorkspaceLayoutStore,
|
||||||
|
useWorkspaceLayoutStoreApi,
|
||||||
|
type SheetTab,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
|
|
||||||
const SNAP_POINTS = ["92%"];
|
const SNAP_POINTS = ["92%"];
|
||||||
const NO_SHEETS: readonly SheetTab[] = [];
|
const NO_SHEETS: readonly SheetTab[] = [];
|
||||||
@@ -108,6 +112,7 @@ interface SheetTabSheetProps extends Omit<SheetTabsProps, "workspaceKey" | "onCl
|
|||||||
* `SheetHeaderView`) keep them looking and dragging like every other sheet.
|
* `SheetHeaderView`) keep them looking and dragging like every other sheet.
|
||||||
*/
|
*/
|
||||||
export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) {
|
export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) {
|
||||||
|
const layout = useWorkspaceLayoutStoreApi();
|
||||||
const sheets = useWorkspaceLayoutStore((state) =>
|
const sheets = useWorkspaceLayoutStore((state) =>
|
||||||
workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS,
|
workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS,
|
||||||
);
|
);
|
||||||
@@ -122,12 +127,10 @@ export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps)
|
|||||||
async (tabId: string) => {
|
async (tabId: string) => {
|
||||||
await onCloseTab(tabId);
|
await onCloseTab(tabId);
|
||||||
// A declined unsaved-changes prompt leaves the tab in its sheet.
|
// A declined unsaved-changes prompt leaves the tab in its sheet.
|
||||||
const open = workspaceKey
|
const open = workspaceKey ? layout.getState().sheetsByWorkspace[workspaceKey] : undefined;
|
||||||
? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]
|
|
||||||
: undefined;
|
|
||||||
return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false;
|
return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false;
|
||||||
},
|
},
|
||||||
[onCloseTab, workspaceKey],
|
[layout, onCloseTab, workspaceKey],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -219,3 +219,15 @@ describe("shouldKeepWorkspaceDeckEntryMounted", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retains separate screens for the same destination workspace under distinct origins", () => {
|
||||||
|
const direct = { serverId: "B", workspaceId: "workspace", authority: { originServerId: "B" } };
|
||||||
|
const throughA = { serverId: "B", workspaceId: "workspace", authority: { originServerId: "A" } };
|
||||||
|
const entries = reconcileRetainedWorkspaceSelections({
|
||||||
|
currentEntries: [{ selection: direct, inactiveSince: null }],
|
||||||
|
activeSelection: throughA,
|
||||||
|
now: 10,
|
||||||
|
});
|
||||||
|
expect(entries).toHaveLength(2);
|
||||||
|
expect(entries.map((entry) => entry.selection)).toEqual([throughA, direct]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { workspaceSelectionAuthority } from "@/stores/last-workspace-selection";
|
||||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
|
|
||||||
export const WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES = 10;
|
export const WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES = 10;
|
||||||
@@ -39,14 +40,19 @@ interface ResolveWorkspaceDeckEntriesInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getWorkspaceSelectionKey(selection: ActiveWorkspaceSelection): string {
|
export function getWorkspaceSelectionKey(selection: ActiveWorkspaceSelection): string {
|
||||||
return `${selection.serverId}:${selection.workspaceId}`;
|
return JSON.stringify([
|
||||||
|
workspaceSelectionAuthority(selection).originServerId,
|
||||||
|
selection.serverId,
|
||||||
|
selection.workspaceId,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function areWorkspaceSelectionsEqual(
|
export function areWorkspaceSelectionsEqual(
|
||||||
left: ActiveWorkspaceSelection | null,
|
left: ActiveWorkspaceSelection | null,
|
||||||
right: ActiveWorkspaceSelection | null,
|
right: ActiveWorkspaceSelection | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
if (!left || !right) return left === right;
|
||||||
|
return getWorkspaceSelectionKey(left) === getWorkspaceSelectionKey(right);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function areRetainedWorkspaceSelectionListsEqual(
|
export function areRetainedWorkspaceSelectionListsEqual(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
} from "@/assistant-file-links";
|
} from "@/assistant-file-links";
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
import { revealWorkspaceTab } from "@/workspace-tabs/navigation";
|
import { useWorkspaceTabNavigation } from "@/workspace-tabs/navigation";
|
||||||
import type { LayoutChangeEvent } from "react-native";
|
import type { LayoutChangeEvent } from "react-native";
|
||||||
import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar";
|
import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar";
|
||||||
import { ExplorerFrame } from "./explorer-sidebar/frame";
|
import { ExplorerFrame } from "./explorer-sidebar/frame";
|
||||||
@@ -67,19 +67,17 @@ import { getOrCreateClientId } from "@/utils/client-id";
|
|||||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||||
import {
|
import {
|
||||||
isExplorerSidebarOpen,
|
useExplorerSidebarActions,
|
||||||
openExplorerSidebarView,
|
|
||||||
toggleExplorerSidebar,
|
|
||||||
useIsExplorerSidebarOpen,
|
useIsExplorerSidebarOpen,
|
||||||
} from "@/workspace-tabs/explorer-sidebar";
|
} from "@/workspace-tabs/explorer-sidebar";
|
||||||
import {
|
import { useWorkspaceTargetActions } from "@/workspace-tabs/open-beside";
|
||||||
openPreferredWorkspacePreview,
|
import { useSupportingViewActions } from "@/workspace-tabs/open-supporting-view";
|
||||||
openPreferredWorkspaceTarget,
|
|
||||||
openWorkspaceTargetBeside,
|
|
||||||
} from "@/workspace-tabs/open-beside";
|
|
||||||
import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view";
|
|
||||||
import { traceInstant } from "@/performance/native-trace";
|
import { traceInstant } from "@/performance/native-trace";
|
||||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
import {
|
||||||
|
useSessionStore,
|
||||||
|
useSessionStoreApi,
|
||||||
|
type WorkspaceDescriptor,
|
||||||
|
} from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
canDismissPaneInLayout,
|
canDismissPaneInLayout,
|
||||||
collectAllTabs,
|
collectAllTabs,
|
||||||
@@ -111,7 +109,7 @@ import { useCreateFlowStore } from "@/stores/create-flow-store";
|
|||||||
import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||||
import { useVisibleAgentIds } from "./visible-agent-ids";
|
import { useVisibleAgentIds } from "./visible-agent-ids";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
useHostRuntimeStore,
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
useHostRuntimeIsConnected,
|
useHostRuntimeIsConnected,
|
||||||
useHostRuntimeSnapshot,
|
useHostRuntimeSnapshot,
|
||||||
@@ -680,12 +678,13 @@ function useWorkspaceRouteActions(normalizedServerId: string): {
|
|||||||
handleDismissMissingWorkspace: () => void;
|
handleDismissMissingWorkspace: () => void;
|
||||||
} {
|
} {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const runtime = useHostRuntimeStore();
|
||||||
const handleRetryHost = useCallback(() => {
|
const handleRetryHost = useCallback(() => {
|
||||||
if (!normalizedServerId) {
|
if (!normalizedServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void getHostRuntimeStore().runProbeCycleNow(normalizedServerId);
|
void runtime.runProbeCycleNow(normalizedServerId);
|
||||||
}, [normalizedServerId]);
|
}, [normalizedServerId, runtime]);
|
||||||
const handleManageHost = useCallback(() => {
|
const handleManageHost = useCallback(() => {
|
||||||
if (!normalizedServerId) {
|
if (!normalizedServerId) {
|
||||||
return;
|
return;
|
||||||
@@ -1018,6 +1017,14 @@ function WorkspaceScreenContent({
|
|||||||
recoveryRequested,
|
recoveryRequested,
|
||||||
}: WorkspaceScreenContentProps) {
|
}: WorkspaceScreenContentProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const runtime = useHostRuntimeStore();
|
||||||
|
const sessionStore = useSessionStoreApi();
|
||||||
|
const { revealWorkspaceTab } = useWorkspaceTabNavigation();
|
||||||
|
const { openPreferredWorkspacePreview, openPreferredWorkspaceTarget, openWorkspaceTargetBeside } =
|
||||||
|
useWorkspaceTargetActions();
|
||||||
|
const { openWorkspacePullRequest } = useSupportingViewActions();
|
||||||
|
const { isExplorerSidebarOpen, openExplorerSidebarView, toggleExplorerSidebar } =
|
||||||
|
useExplorerSidebarActions();
|
||||||
const _insets = useSafeAreaInsets();
|
const _insets = useSafeAreaInsets();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
@@ -1040,19 +1047,16 @@ function WorkspaceScreenContent({
|
|||||||
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
|
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return;
|
if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return;
|
||||||
void getHostRuntimeStore()
|
void runtime
|
||||||
.prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId)
|
.prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]);
|
}, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor, runtime]);
|
||||||
// The focused workspace keeps its script status live even when no sidebar or viewed agent
|
// The focused workspace keeps its script status live even when no sidebar or viewed agent
|
||||||
// timeline demands the directory. Retained, hidden workspaces release it.
|
// timeline demands the directory. Retained, hidden workspaces release it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRouteFocused || !normalizedServerId || !normalizedWorkspaceId) return;
|
if (!isRouteFocused || !normalizedServerId || !normalizedWorkspaceId) return;
|
||||||
return getHostRuntimeStore().acquireScriptStatusDemand(
|
return runtime.acquireScriptStatusDemand(normalizedServerId, normalizedWorkspaceId);
|
||||||
normalizedServerId,
|
}, [isRouteFocused, normalizedServerId, normalizedWorkspaceId, runtime]);
|
||||||
normalizedWorkspaceId,
|
|
||||||
);
|
|
||||||
}, [isRouteFocused, normalizedServerId, normalizedWorkspaceId]);
|
|
||||||
const workspaceScripts = getWorkspaceScripts(workspaceDescriptor);
|
const workspaceScripts = getWorkspaceScripts(workspaceDescriptor);
|
||||||
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
|
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
|
||||||
useWorkspaceRouteActions(normalizedServerId);
|
useWorkspaceRouteActions(normalizedServerId);
|
||||||
@@ -1362,7 +1366,7 @@ function WorkspaceScreenContent({
|
|||||||
isCompact: isMobile,
|
isCompact: isMobile,
|
||||||
workspaceKey: persistenceKey,
|
workspaceKey: persistenceKey,
|
||||||
});
|
});
|
||||||
}, [isMobile, persistenceKey]);
|
}, [isMobile, persistenceKey, toggleExplorerSidebar]);
|
||||||
const paneFocusSuppressedRef = useRef(false);
|
const paneFocusSuppressedRef = useRef(false);
|
||||||
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
||||||
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
||||||
@@ -1428,11 +1432,9 @@ function WorkspaceScreenContent({
|
|||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const agentId of visibleAgentIds) {
|
for (const agentId of visibleAgentIds) {
|
||||||
void getHostRuntimeStore()
|
void runtime.prepareAgentTimeline(normalizedServerId, agentId).catch(() => undefined);
|
||||||
.prepareAgentTimeline(normalizedServerId, agentId)
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
}
|
||||||
}, [normalizedServerId, visibleAgentIds]);
|
}, [normalizedServerId, visibleAgentIds, runtime]);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!persistenceKey || !viewedTimelineSync) {
|
if (!persistenceKey || !viewedTimelineSync) {
|
||||||
return;
|
return;
|
||||||
@@ -1586,7 +1588,7 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
revealWorkspaceTab(persistenceKey, tabId);
|
revealWorkspaceTab(persistenceKey, tabId);
|
||||||
},
|
},
|
||||||
[persistenceKey],
|
[persistenceKey, revealWorkspaceTab],
|
||||||
);
|
);
|
||||||
const selectTabInPane = useCallback(
|
const selectTabInPane = useCallback(
|
||||||
(paneId: string, tabId: string) => {
|
(paneId: string, tabId: string) => {
|
||||||
@@ -1715,7 +1717,7 @@ function WorkspaceScreenContent({
|
|||||||
requestFileNavigation(tabId);
|
requestFileNavigation(tabId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[openInSidePane, persistenceKey, requestFileNavigation],
|
[openInSidePane, persistenceKey, requestFileNavigation, openPreferredWorkspaceTarget],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({
|
const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({
|
||||||
@@ -1980,7 +1982,7 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agent =
|
const agent =
|
||||||
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||||
let closePolicy = resolveCloseAgentTabPolicy(agent);
|
let closePolicy = resolveCloseAgentTabPolicy(agent);
|
||||||
const isRunning = agent?.status === "running";
|
const isRunning = agent?.status === "running";
|
||||||
|
|
||||||
@@ -1998,7 +2000,7 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (closePolicy.kind === "layout-only") {
|
if (closePolicy.kind === "layout-only") {
|
||||||
const sessionClient = useSessionStore.getState().sessions[normalizedServerId]?.client;
|
const sessionClient = sessionStore.getState().sessions[normalizedServerId]?.client;
|
||||||
if (!sessionClient) {
|
if (!sessionClient) {
|
||||||
toast.error(t("common.errors.daemonClientUnavailable"));
|
toast.error(t("common.errors.daemonClientUnavailable"));
|
||||||
return;
|
return;
|
||||||
@@ -2009,7 +2011,7 @@ function WorkspaceScreenContent({
|
|||||||
labels: { [getOpenAgentTabLabel(clientId)]: "false" },
|
labels: { [getOpenAgentTabLabel(clientId)]: "false" },
|
||||||
});
|
});
|
||||||
const latestAgent =
|
const latestAgent =
|
||||||
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||||
closePolicy = resolveCloseAgentTabPolicy(latestAgent);
|
closePolicy = resolveCloseAgentTabPolicy(latestAgent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[WorkspaceScreen] Failed to close subagent tab", { error, agentId });
|
console.error("[WorkspaceScreen] Failed to close subagent tab", { error, agentId });
|
||||||
@@ -2042,6 +2044,7 @@ function WorkspaceScreenContent({
|
|||||||
persistenceKey,
|
persistenceKey,
|
||||||
t,
|
t,
|
||||||
toast,
|
toast,
|
||||||
|
sessionStore,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2157,7 +2160,7 @@ function WorkspaceScreenContent({
|
|||||||
async (agentId: string) => {
|
async (agentId: string) => {
|
||||||
if (!agentId) return;
|
if (!agentId) return;
|
||||||
const agent =
|
const agent =
|
||||||
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||||
const providerSessionId =
|
const providerSessionId =
|
||||||
agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null;
|
agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null;
|
||||||
if (!agent || !providerSessionId) {
|
if (!agent || !providerSessionId) {
|
||||||
@@ -2182,7 +2185,7 @@ function WorkspaceScreenContent({
|
|||||||
toast.error(t("workspace.tabs.toasts.copyFailed"));
|
toast.error(t("workspace.tabs.toasts.copyFailed"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[normalizedServerId, toast, t],
|
[normalizedServerId, toast, t, sessionStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReloadAgent = useCallback(
|
const handleReloadAgent = useCallback(
|
||||||
@@ -2199,9 +2202,9 @@ function WorkspaceScreenContent({
|
|||||||
// returns reset:true. Without a cursor, the server returns reset:false
|
// returns reset:true. Without a cursor, the server returns reset:false
|
||||||
// and the client takes the incremental path, where new-epoch rows are
|
// and the client takes the incremental path, where new-epoch rows are
|
||||||
// dropped against the stale cursor.
|
// dropped against the stale cursor.
|
||||||
const sessionState = useSessionStore.getState().sessions[normalizedServerId];
|
const sessionState = sessionStore.getState().sessions[normalizedServerId];
|
||||||
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
|
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
|
||||||
await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, {
|
await runtime.fetchAgentTimeline(normalizedServerId, agentId, {
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
projection: "projected",
|
projection: "projected",
|
||||||
...(currentCursor
|
...(currentCursor
|
||||||
@@ -2215,7 +2218,7 @@ function WorkspaceScreenContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[client, isConnected, normalizedServerId, toast, t],
|
[client, isConnected, normalizedServerId, toast, t, runtime, sessionStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCopyWorkspacePath = useCallback(async () => {
|
const handleCopyWorkspacePath = useCallback(async () => {
|
||||||
@@ -2272,7 +2275,7 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => {
|
const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => {
|
||||||
const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId);
|
const agent = sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId);
|
||||||
return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive";
|
return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive";
|
||||||
});
|
});
|
||||||
const modifiedCount = tabsToClose.filter(
|
const modifiedCount = tabsToClose.filter(
|
||||||
@@ -2311,7 +2314,7 @@ function WorkspaceScreenContent({
|
|||||||
labels: { [getOpenAgentTabLabel(clientId)]: "false" },
|
labels: { [getOpenAgentTabLabel(clientId)]: "false" },
|
||||||
});
|
});
|
||||||
const latestAgent =
|
const latestAgent =
|
||||||
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||||
if (resolveCloseAgentTabPolicy(latestAgent).kind === "archive-on-close") {
|
if (resolveCloseAgentTabPolicy(latestAgent).kind === "archive-on-close") {
|
||||||
await archiveAgent({ serverId: normalizedServerId, agentId });
|
await archiveAgent({ serverId: normalizedServerId, agentId });
|
||||||
}
|
}
|
||||||
@@ -2342,6 +2345,7 @@ function WorkspaceScreenContent({
|
|||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
t,
|
t,
|
||||||
|
sessionStore,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2492,6 +2496,9 @@ function WorkspaceScreenContent({
|
|||||||
openWorkspaceTabFocused,
|
openWorkspaceTabFocused,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
pullRequestOpenLocation,
|
pullRequestOpenLocation,
|
||||||
|
openWorkspaceTargetBeside,
|
||||||
|
openWorkspacePullRequest,
|
||||||
|
openExplorerSidebarView,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2651,6 +2658,7 @@ function WorkspaceScreenContent({
|
|||||||
isGitCheckout,
|
isGitCheckout,
|
||||||
isMobile,
|
isMobile,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
|
openExplorerSidebarView,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2678,7 +2686,7 @@ function WorkspaceScreenContent({
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[handleToggleExplorerSidebar, isMobile, persistenceKey],
|
[handleToggleExplorerSidebar, isMobile, persistenceKey, isExplorerSidebarOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWorkspacePaneAction = useCallback(
|
const handleWorkspacePaneAction = useCallback(
|
||||||
@@ -3030,6 +3038,8 @@ function WorkspaceScreenContent({
|
|||||||
setWorkspaceTabState,
|
setWorkspaceTabState,
|
||||||
explorerSidebarPaneId,
|
explorerSidebarPaneId,
|
||||||
lastMainPaneId,
|
lastMainPaneId,
|
||||||
|
openPreferredWorkspacePreview,
|
||||||
|
openWorkspaceTargetBeside,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const focusedPaneId = useMemo(
|
const focusedPaneId = useMemo(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create, useStore } from "zustand";
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||||
|
|
||||||
@@ -48,89 +49,105 @@ interface CreateFlowState {
|
|||||||
clearAll: () => void;
|
clearAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCreateFlowStore = create<CreateFlowState>((set, get) => ({
|
export const createCreateFlowStore = () =>
|
||||||
pendingByDraftId: {},
|
create<CreateFlowState>((set, get) => ({
|
||||||
setPending: (pending) =>
|
pendingByDraftId: {},
|
||||||
set((state) => ({
|
setPending: (pending) =>
|
||||||
pendingByDraftId: {
|
set((state) => ({
|
||||||
...state.pendingByDraftId,
|
|
||||||
[pending.draftId]: {
|
|
||||||
...pending,
|
|
||||||
lifecycle: "active",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
trySetPending: (pending) => {
|
|
||||||
const existing = get().pendingByDraftId[pending.draftId];
|
|
||||||
if (existing?.serverId === pending.serverId && existing.lifecycle !== "abandoned") return false;
|
|
||||||
get().setPending(pending);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
updateAgentId: ({ draftId, agentId }) =>
|
|
||||||
set((state) => {
|
|
||||||
const current = state.pendingByDraftId[draftId];
|
|
||||||
if (!current || current.agentId === agentId) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
pendingByDraftId: {
|
pendingByDraftId: {
|
||||||
...state.pendingByDraftId,
|
...state.pendingByDraftId,
|
||||||
[draftId]: { ...current, agentId },
|
[pending.draftId]: {
|
||||||
|
...pending,
|
||||||
|
lifecycle: "active",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
})),
|
||||||
}),
|
trySetPending: (pending) => {
|
||||||
markLifecycle: ({ draftId, lifecycle, errorMessage }) =>
|
const existing = get().pendingByDraftId[pending.draftId];
|
||||||
set((state) => {
|
if (existing?.serverId === pending.serverId && existing.lifecycle !== "abandoned")
|
||||||
const current = state.pendingByDraftId[draftId];
|
return false;
|
||||||
if (!current || current.lifecycle === lifecycle) {
|
get().setPending(pending);
|
||||||
return state;
|
return true;
|
||||||
}
|
},
|
||||||
return {
|
updateAgentId: ({ draftId, agentId }) =>
|
||||||
pendingByDraftId: {
|
set((state) => {
|
||||||
...state.pendingByDraftId,
|
const current = state.pendingByDraftId[draftId];
|
||||||
[draftId]: { ...current, lifecycle, errorMessage },
|
if (!current || current.agentId === agentId) {
|
||||||
},
|
return state;
|
||||||
};
|
}
|
||||||
}),
|
return {
|
||||||
rekeyDraft: ({ fromDraftId, toDraftId }) =>
|
pendingByDraftId: {
|
||||||
set((state) => {
|
...state.pendingByDraftId,
|
||||||
const current = state.pendingByDraftId[fromDraftId];
|
[draftId]: { ...current, agentId },
|
||||||
if (!current) {
|
},
|
||||||
return state;
|
};
|
||||||
}
|
}),
|
||||||
if (fromDraftId === toDraftId) {
|
markLifecycle: ({ draftId, lifecycle, errorMessage }) =>
|
||||||
return state;
|
set((state) => {
|
||||||
}
|
const current = state.pendingByDraftId[draftId];
|
||||||
const { [fromDraftId]: _removed, ...rest } = state.pendingByDraftId;
|
if (!current || current.lifecycle === lifecycle) {
|
||||||
return {
|
return state;
|
||||||
pendingByDraftId: {
|
}
|
||||||
...rest,
|
return {
|
||||||
[toDraftId]: { ...current, draftId: toDraftId },
|
pendingByDraftId: {
|
||||||
},
|
...state.pendingByDraftId,
|
||||||
};
|
[draftId]: { ...current, lifecycle, errorMessage },
|
||||||
}),
|
},
|
||||||
clear: ({ draftId }) =>
|
};
|
||||||
set((state) => {
|
}),
|
||||||
if (!state.pendingByDraftId[draftId]) {
|
rekeyDraft: ({ fromDraftId, toDraftId }) =>
|
||||||
return state;
|
set((state) => {
|
||||||
}
|
const current = state.pendingByDraftId[fromDraftId];
|
||||||
const { [draftId]: _removed, ...rest } = state.pendingByDraftId;
|
if (!current) {
|
||||||
return { pendingByDraftId: rest };
|
return state;
|
||||||
}),
|
}
|
||||||
clearByAgent: ({ serverId, agentId }) =>
|
if (fromDraftId === toDraftId) {
|
||||||
set((state) => {
|
return state;
|
||||||
const next = Object.fromEntries(
|
}
|
||||||
Object.entries(state.pendingByDraftId).filter(
|
const { [fromDraftId]: _removed, ...rest } = state.pendingByDraftId;
|
||||||
([, pending]) =>
|
return {
|
||||||
pending.lifecycle !== "sent" ||
|
pendingByDraftId: {
|
||||||
pending.serverId !== serverId ||
|
...rest,
|
||||||
pending.agentId !== agentId,
|
[toDraftId]: { ...current, draftId: toDraftId },
|
||||||
),
|
},
|
||||||
);
|
};
|
||||||
if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) {
|
}),
|
||||||
return state;
|
clear: ({ draftId }) =>
|
||||||
}
|
set((state) => {
|
||||||
return { pendingByDraftId: next };
|
if (!state.pendingByDraftId[draftId]) {
|
||||||
}),
|
return state;
|
||||||
clearAll: () => set({ pendingByDraftId: {} }),
|
}
|
||||||
}));
|
const { [draftId]: _removed, ...rest } = state.pendingByDraftId;
|
||||||
|
return { pendingByDraftId: rest };
|
||||||
|
}),
|
||||||
|
clearByAgent: ({ serverId, agentId }) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = Object.fromEntries(
|
||||||
|
Object.entries(state.pendingByDraftId).filter(
|
||||||
|
([, pending]) =>
|
||||||
|
pending.lifecycle !== "sent" ||
|
||||||
|
pending.serverId !== serverId ||
|
||||||
|
pending.agentId !== agentId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return { pendingByDraftId: next };
|
||||||
|
}),
|
||||||
|
clearAll: () => set({ pendingByDraftId: {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export type CreateFlowStoreApi = ReturnType<typeof createCreateFlowStore>;
|
||||||
|
export const defaultCreateFlowStore = createCreateFlowStore();
|
||||||
|
export const CreateFlowStoreContext = createContext<CreateFlowStoreApi | null>(null);
|
||||||
|
export function useCreateFlowStoreApi(): CreateFlowStoreApi {
|
||||||
|
return useContext(CreateFlowStoreContext) ?? defaultCreateFlowStore;
|
||||||
|
}
|
||||||
|
export const useCreateFlowStore: CreateFlowStoreApi = Object.assign(function useCreateFlowStore<T>(
|
||||||
|
selector: (state: CreateFlowState) => T = ((state: CreateFlowState) => state) as (
|
||||||
|
state: CreateFlowState,
|
||||||
|
) => T,
|
||||||
|
): T {
|
||||||
|
return useStore(useCreateFlowStoreApi(), selector);
|
||||||
|
}, defaultCreateFlowStore);
|
||||||
|
|||||||
@@ -71,3 +71,20 @@ describe("last workspace selection", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists and restores selected origin independently of a destination and distinguishes authority changes", async () => {
|
||||||
|
const storage = new DelayedWorkspaceSelectionStorage();
|
||||||
|
const store = createLastWorkspaceSelectionStore(storage);
|
||||||
|
store.remember({ serverId: "B", workspaceId: "workspace", authority: { originServerId: "B" } });
|
||||||
|
store.remember({ serverId: "B", workspaceId: "workspace", authority: { originServerId: "A" } });
|
||||||
|
expect(store.getSelection()).toEqual({
|
||||||
|
serverId: "B",
|
||||||
|
workspaceId: "workspace",
|
||||||
|
authority: { originServerId: "A" },
|
||||||
|
});
|
||||||
|
const restored = createLastWorkspaceSelectionStore(storage);
|
||||||
|
const hydration = restored.hydrate();
|
||||||
|
storage.finishHydrationWith(storage.getSavedSelection());
|
||||||
|
await hydration;
|
||||||
|
expect(restored.getSelection()).toEqual(store.getSelection());
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ import { z } from "zod";
|
|||||||
export interface ActiveWorkspaceSelection {
|
export interface ActiveWorkspaceSelection {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
authority?: { originServerId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** COMPAT(workspaceRouteOrigin): added in v0.12, remove after 2027-04-20.
|
||||||
|
* Pre-fleet routes and stored selections name the destination's own authority.
|
||||||
|
*/
|
||||||
|
export function workspaceSelectionAuthority(selection: ActiveWorkspaceSelection): {
|
||||||
|
originServerId: string;
|
||||||
|
serverId: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
originServerId: selection.authority?.originServerId ?? selection.serverId,
|
||||||
|
serverId: selection.serverId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
|
export const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
|
||||||
@@ -16,6 +30,7 @@ export interface LastWorkspaceSelectionStorage {
|
|||||||
const ActiveWorkspaceSelectionSchema: z.ZodType<ActiveWorkspaceSelection> = z.strictObject({
|
const ActiveWorkspaceSelectionSchema: z.ZodType<ActiveWorkspaceSelection> = z.strictObject({
|
||||||
serverId: z.string().trim().min(1),
|
serverId: z.string().trim().min(1),
|
||||||
workspaceId: z.string().trim().min(1),
|
workspaceId: z.string().trim().min(1),
|
||||||
|
authority: z.strictObject({ originServerId: z.string().trim().min(1) }).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
|
function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
|
||||||
@@ -54,7 +69,9 @@ export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectio
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
selection?.serverId === normalized.serverId &&
|
selection?.serverId === normalized.serverId &&
|
||||||
selection.workspaceId === normalized.workspaceId
|
selection.workspaceId === normalized.workspaceId &&
|
||||||
|
workspaceSelectionAuthority(selection).originServerId ===
|
||||||
|
workspaceSelectionAuthority(normalized).originServerId
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useLocalSearchParams, usePathname } from "expo-router";
|
import { useGlobalSearchParams, useLocalSearchParams, usePathname } from "expo-router";
|
||||||
import { useEffect, useSyncExternalStore } from "react";
|
import { useEffect, useSyncExternalStore } from "react";
|
||||||
import {
|
import {
|
||||||
createLastWorkspaceSelectionStore,
|
createLastWorkspaceSelectionStore,
|
||||||
@@ -75,15 +75,24 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
|||||||
serverId?: string | string[];
|
serverId?: string | string[];
|
||||||
workspaceId?: string | string[];
|
workspaceId?: string | string[];
|
||||||
}>();
|
}>();
|
||||||
const selection = parseActiveWorkspaceSelection({ pathname: usePathname(), params });
|
const { origin } = useGlobalSearchParams<{ origin?: string | string[] }>();
|
||||||
|
const selection = parseActiveWorkspaceSelection({
|
||||||
|
pathname: usePathname(),
|
||||||
|
params: { ...params, origin },
|
||||||
|
});
|
||||||
const serverId = selection?.serverId ?? null;
|
const serverId = selection?.serverId ?? null;
|
||||||
const workspaceId = selection?.workspaceId ?? null;
|
const workspaceId = selection?.workspaceId ?? null;
|
||||||
|
const originServerId = selection?.authority?.originServerId;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverId || !workspaceId) {
|
if (!serverId || !workspaceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastWorkspaceSelectionStore.remember({ serverId, workspaceId });
|
lastWorkspaceSelectionStore.remember({
|
||||||
}, [serverId, workspaceId]);
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
...(originServerId ? { authority: { originServerId } } : {}),
|
||||||
|
});
|
||||||
|
}, [serverId, workspaceId, originServerId]);
|
||||||
return selection;
|
return selection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
|
withHostRouteAuthority,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import {
|
import {
|
||||||
normalizeWorkspaceOpaqueId,
|
normalizeWorkspaceOpaqueId,
|
||||||
@@ -20,6 +21,7 @@ export interface RouteSelectionInput {
|
|||||||
params: {
|
params: {
|
||||||
serverId?: string | string[];
|
serverId?: string | string[];
|
||||||
workspaceId?: string | string[];
|
workspaceId?: string | string[];
|
||||||
|
origin?: string | string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ export interface NavigateToWorkspaceInput {
|
|||||||
target?: WorkspaceTabTarget;
|
target?: WorkspaceTabTarget;
|
||||||
pin?: boolean;
|
pin?: boolean;
|
||||||
placement?: WorkspaceTabPlacement;
|
placement?: WorkspaceTabPlacement;
|
||||||
|
authority?: { originServerId: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
|
export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
|
||||||
@@ -57,6 +60,7 @@ function getParamValue(value: string | string[] | undefined): string {
|
|||||||
function parseWorkspaceSelectionFromRouteParams(params: {
|
function parseWorkspaceSelectionFromRouteParams(params: {
|
||||||
serverId?: string | string[];
|
serverId?: string | string[];
|
||||||
workspaceId?: string | string[];
|
workspaceId?: string | string[];
|
||||||
|
origin?: string | string[];
|
||||||
}): ActiveWorkspaceSelection | null {
|
}): ActiveWorkspaceSelection | null {
|
||||||
const serverId = getParamValue(params.serverId);
|
const serverId = getParamValue(params.serverId);
|
||||||
const workspaceValue = getParamValue(params.workspaceId);
|
const workspaceValue = getParamValue(params.workspaceId);
|
||||||
@@ -64,7 +68,8 @@ function parseWorkspaceSelectionFromRouteParams(params: {
|
|||||||
if (!serverId || !workspaceId) {
|
if (!serverId || !workspaceId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { serverId, workspaceId };
|
const originServerId = getParamValue(params.origin);
|
||||||
|
return { serverId, workspaceId, ...(originServerId ? { authority: { originServerId } } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseActiveWorkspaceSelection(
|
export function parseActiveWorkspaceSelection(
|
||||||
@@ -72,7 +77,10 @@ export function parseActiveWorkspaceSelection(
|
|||||||
): ActiveWorkspaceSelection | null {
|
): ActiveWorkspaceSelection | null {
|
||||||
const routeSelection = parseHostWorkspaceRouteFromPathname(input.pathname);
|
const routeSelection = parseHostWorkspaceRouteFromPathname(input.pathname);
|
||||||
if (routeSelection) {
|
if (routeSelection) {
|
||||||
return routeSelection;
|
const originServerId = getParamValue(input.params.origin);
|
||||||
|
return routeSelection.authority || !originServerId
|
||||||
|
? routeSelection
|
||||||
|
: { ...routeSelection, authority: { originServerId } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.pathname !== "/" && input.pathname !== "") {
|
if (input.pathname !== "/" && input.pathname !== "") {
|
||||||
@@ -86,13 +94,16 @@ export function navigateToWorkspace(
|
|||||||
input: NavigateToWorkspaceInput,
|
input: NavigateToWorkspaceInput,
|
||||||
deps: NavigateToWorkspaceDeps,
|
deps: NavigateToWorkspaceDeps,
|
||||||
): string {
|
): string {
|
||||||
const workspaces = deps.getSessionWorkspaces(input.serverId);
|
// COMPAT(unboundWorkspaceNavigation): added in v0.12, remove after 2027-04-20.
|
||||||
|
// Bound routes prepare their intent only after reacquiring the selected authority.
|
||||||
|
const workspaces = input.authority ? null : deps.getSessionWorkspaces(input.serverId);
|
||||||
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
|
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
|
||||||
workspaces,
|
workspaces,
|
||||||
workspaceId: input.workspaceId,
|
workspaceId: input.workspaceId,
|
||||||
});
|
});
|
||||||
const shouldDeferAgentOpen = Boolean(
|
const shouldDeferAgentOpen = Boolean(
|
||||||
input.target?.kind === "agent" && (!resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()),
|
input.target?.kind === "agent" &&
|
||||||
|
(input.authority || !resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()),
|
||||||
);
|
);
|
||||||
if (input.target) {
|
if (input.target) {
|
||||||
if (!shouldDeferAgentOpen) {
|
if (!shouldDeferAgentOpen) {
|
||||||
@@ -114,7 +125,7 @@ export function navigateToWorkspace(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const route =
|
const baseRoute =
|
||||||
input.target?.kind === "agent" && shouldDeferAgentOpen
|
input.target?.kind === "agent" && shouldDeferAgentOpen
|
||||||
? buildHostWorkspaceOpenRoute(
|
? buildHostWorkspaceOpenRoute(
|
||||||
input.serverId,
|
input.serverId,
|
||||||
@@ -122,7 +133,12 @@ export function navigateToWorkspace(
|
|||||||
`agent:${input.target.agentId}`,
|
`agent:${input.target.agentId}`,
|
||||||
)
|
)
|
||||||
: buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
: buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||||
deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId });
|
const route = input.authority ? withHostRouteAuthority(baseRoute, input.authority) : baseRoute;
|
||||||
|
deps.rememberLastWorkspace({
|
||||||
|
serverId: input.serverId,
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
...(input.authority ? { authority: input.authority } : {}),
|
||||||
|
});
|
||||||
deps.navigateToRoute(route);
|
deps.navigateToRoute(route);
|
||||||
return route;
|
return route;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
workspaceEqualityFns,
|
workspaceEqualityFns,
|
||||||
type WorkspaceStructure,
|
type WorkspaceStructure,
|
||||||
} from "./selectors";
|
} from "./selectors";
|
||||||
import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
|
import { useSessionStoreApi, type WorkspaceDescriptor } from "../session-store";
|
||||||
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||||
|
|
||||||
// These are the ONLY supported ways to read workspaces from the session store.
|
// These are the ONLY supported ways to read workspaces from the session store.
|
||||||
@@ -38,7 +38,7 @@ export function useWorkspace(
|
|||||||
workspaceId: string | null,
|
workspaceId: string | null,
|
||||||
): WorkspaceDescriptor | null {
|
): WorkspaceDescriptor | null {
|
||||||
return useStoreWithEqualityFn(
|
return useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
(state) => selectWorkspace(state, serverId, workspaceId),
|
(state) => selectWorkspace(state, serverId, workspaceId),
|
||||||
workspaceEqualityFns.identity,
|
workspaceEqualityFns.identity,
|
||||||
);
|
);
|
||||||
@@ -50,7 +50,7 @@ export function useWorkspaceFields<T>(
|
|||||||
project: (w: WorkspaceDescriptor) => T,
|
project: (w: WorkspaceDescriptor) => T,
|
||||||
): T | null {
|
): T | null {
|
||||||
return useStoreWithEqualityFn(
|
return useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
(state) => selectWorkspaceFields(state, serverId, workspaceId, project),
|
(state) => selectWorkspaceFields(state, serverId, workspaceId, project),
|
||||||
workspaceEqualityFns.deep,
|
workspaceEqualityFns.deep,
|
||||||
);
|
);
|
||||||
@@ -58,7 +58,7 @@ export function useWorkspaceFields<T>(
|
|||||||
|
|
||||||
export function useWorkspaceExists(serverId: string | null, workspaceId: string | null): boolean {
|
export function useWorkspaceExists(serverId: string | null, workspaceId: string | null): boolean {
|
||||||
return useStoreWithEqualityFn(
|
return useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
(state) => selectWorkspaceExists(state, serverId, workspaceId),
|
(state) => selectWorkspaceExists(state, serverId, workspaceId),
|
||||||
workspaceEqualityFns.identity,
|
workspaceEqualityFns.identity,
|
||||||
);
|
);
|
||||||
@@ -66,7 +66,7 @@ export function useWorkspaceExists(serverId: string | null, workspaceId: string
|
|||||||
|
|
||||||
export function useHasHydratedWorkspaces(serverId: string | null): boolean {
|
export function useHasHydratedWorkspaces(serverId: string | null): boolean {
|
||||||
return useStoreWithEqualityFn(
|
return useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
(state) => selectHasHydratedWorkspaces(state, serverId),
|
(state) => selectHasHydratedWorkspaces(state, serverId),
|
||||||
workspaceEqualityFns.identity,
|
workspaceEqualityFns.identity,
|
||||||
);
|
);
|
||||||
@@ -74,7 +74,7 @@ export function useHasHydratedWorkspaces(serverId: string | null): boolean {
|
|||||||
|
|
||||||
export function useHydratedWorkspaceServerIds(serverIds: string[]): string[] {
|
export function useHydratedWorkspaceServerIds(serverIds: string[]): string[] {
|
||||||
return useStoreWithEqualityFn(
|
return useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
(state) => selectHydratedWorkspaceServerIds(state, serverIds),
|
(state) => selectHydratedWorkspaceServerIds(state, serverIds),
|
||||||
workspaceEqualityFns.deep,
|
workspaceEqualityFns.deep,
|
||||||
);
|
);
|
||||||
@@ -82,7 +82,7 @@ export function useHydratedWorkspaceServerIds(serverIds: string[]): string[] {
|
|||||||
|
|
||||||
export function useWorkspaceDirectoryServerIds(serverIds: string[]): string[] {
|
export function useWorkspaceDirectoryServerIds(serverIds: string[]): string[] {
|
||||||
return useStoreWithEqualityFn(
|
return useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
(state) => selectWorkspaceDirectoryServerIds(state, serverIds),
|
(state) => selectWorkspaceDirectoryServerIds(state, serverIds),
|
||||||
workspaceEqualityFns.deep,
|
workspaceEqualityFns.deep,
|
||||||
);
|
);
|
||||||
@@ -93,7 +93,7 @@ export function useWorkspaceDirectory(
|
|||||||
workspaceId: string | null,
|
workspaceId: string | null,
|
||||||
): string | null {
|
): string | null {
|
||||||
return useStoreWithEqualityFn(
|
return useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
(state) => selectWorkspaceDirectory(state, serverId, workspaceId),
|
(state) => selectWorkspaceDirectory(state, serverId, workspaceId),
|
||||||
workspaceEqualityFns.identity,
|
workspaceEqualityFns.identity,
|
||||||
);
|
);
|
||||||
@@ -105,7 +105,7 @@ export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure {
|
|||||||
[serverIds],
|
[serverIds],
|
||||||
);
|
);
|
||||||
const projects = useStoreWithEqualityFn(
|
const projects = useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
selectProjects,
|
selectProjects,
|
||||||
workspaceEqualityFns.deep,
|
workspaceEqualityFns.deep,
|
||||||
);
|
);
|
||||||
@@ -133,7 +133,7 @@ export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure {
|
|||||||
|
|
||||||
export function useWorkspaceKeys(serverId: string | null): string[] {
|
export function useWorkspaceKeys(serverId: string | null): string[] {
|
||||||
return useStoreWithEqualityFn(
|
return useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
(state) => selectWorkspaceKeys(state, serverId),
|
(state) => selectWorkspaceKeys(state, serverId),
|
||||||
workspaceEqualityFns.deep,
|
workspaceEqualityFns.deep,
|
||||||
);
|
);
|
||||||
@@ -141,7 +141,7 @@ export function useWorkspaceKeys(serverId: string | null): string[] {
|
|||||||
|
|
||||||
export function useRecommendedProjectPaths(serverId: string | null): string[] {
|
export function useRecommendedProjectPaths(serverId: string | null): string[] {
|
||||||
return useStoreWithEqualityFn(
|
return useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
(state) => selectRecommendedProjectPaths(state, serverId),
|
(state) => selectRecommendedProjectPaths(state, serverId),
|
||||||
workspaceEqualityFns.deep,
|
workspaceEqualityFns.deep,
|
||||||
);
|
);
|
||||||
@@ -149,7 +149,7 @@ export function useRecommendedProjectPaths(serverId: string | null): string[] {
|
|||||||
|
|
||||||
export function useHasWorkspaces(serverId: string | null): boolean {
|
export function useHasWorkspaces(serverId: string | null): boolean {
|
||||||
return useStoreWithEqualityFn(
|
return useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
(state) => selectHasWorkspaces(state, serverId),
|
(state) => selectHasWorkspaces(state, serverId),
|
||||||
workspaceEqualityFns.identity,
|
workspaceEqualityFns.identity,
|
||||||
);
|
);
|
||||||
@@ -157,7 +157,7 @@ export function useHasWorkspaces(serverId: string | null): boolean {
|
|||||||
|
|
||||||
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
|
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
|
||||||
return useStoreWithEqualityFn(
|
return useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStoreApi(),
|
||||||
(state) => selectWorkspaceStatusesForBadges(state),
|
(state) => selectWorkspaceStatusesForBadges(state),
|
||||||
workspaceEqualityFns.deep,
|
workspaceEqualityFns.deep,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ function createTestAgent(agentId: string): Agent {
|
|||||||
title: null,
|
title: null,
|
||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
model: null,
|
model: null,
|
||||||
parentAgentId: null,
|
parent: null,
|
||||||
|
managedRelationship: null,
|
||||||
labels: {},
|
labels: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1202
-1166
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
import type { StateStorage } from "zustand/middleware";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions";
|
import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions";
|
||||||
|
|
||||||
@@ -113,3 +114,42 @@ export const WorkspaceLayoutPersistedStateSchema = z.strictObject({
|
|||||||
// and ignored so upgrading does not discard the layout. Remove after 2027-08-20.
|
// and ignored so upgrading does not discard the layout. Remove after 2027-08-20.
|
||||||
acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(),
|
acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// COMPAT(preFleetDirectLayout): added in v0.12, remove after 2027-04-20.
|
||||||
|
// Only an existing self-origin layout may seed its new namespace. Once written, the namespace wins.
|
||||||
|
export function workspaceLayoutStorageWithLegacySeed(
|
||||||
|
storage: StateStorage,
|
||||||
|
serverId: string,
|
||||||
|
): StateStorage {
|
||||||
|
return {
|
||||||
|
setItem: (key, value) => storage.setItem(key, value),
|
||||||
|
removeItem: (key) => storage.removeItem(key),
|
||||||
|
async getItem(key) {
|
||||||
|
const current = await storage.getItem(key);
|
||||||
|
if (current !== null) return current;
|
||||||
|
const legacy = await storage.getItem("workspace-layout-state");
|
||||||
|
if (!legacy) return null;
|
||||||
|
try {
|
||||||
|
const parsed = z
|
||||||
|
.object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number() })
|
||||||
|
.safeParse(JSON.parse(legacy));
|
||||||
|
if (!parsed.success) return null;
|
||||||
|
const prefix = `${serverId}:`;
|
||||||
|
const state = Object.fromEntries(
|
||||||
|
Object.entries(parsed.data.state).map(([field, records]) => [
|
||||||
|
field,
|
||||||
|
records &&
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(records).filter(([workspaceKey]) => workspaceKey.startsWith(prefix)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const seeded = JSON.stringify({ state, version: parsed.data.version });
|
||||||
|
await storage.setItem(key, seeded);
|
||||||
|
return seeded;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { managedAgentFields } from "@/subagents/test-fixtures";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||||
@@ -17,7 +18,7 @@ vi.mock("@react-native-async-storage/async-storage", () => {
|
|||||||
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { createSessionStore, useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||||
import { createViewedTimelineSync } from "@/timeline/viewed-timeline-sync";
|
import { createViewedTimelineSync } from "@/timeline/viewed-timeline-sync";
|
||||||
import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
|
||||||
@@ -4477,7 +4478,15 @@ describe("compact workspace sheet", () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedAgents(agents: Array<Pick<Agent, "id" | "parentAgentId" | "workspaceId">>): void {
|
function seedAgents(
|
||||||
|
agents: Array<
|
||||||
|
Pick<Agent, "id" | "workspaceId"> & {
|
||||||
|
parentAgentId?: string;
|
||||||
|
parent?: null;
|
||||||
|
managedRelationship?: null;
|
||||||
|
}
|
||||||
|
>,
|
||||||
|
): void {
|
||||||
const timestamp = new Date("2026-04-21T10:00:00.000Z");
|
const timestamp = new Date("2026-04-21T10:00:00.000Z");
|
||||||
useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient);
|
useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient);
|
||||||
useSessionStore.getState().setAgents(
|
useSessionStore.getState().setAgents(
|
||||||
@@ -4521,6 +4530,9 @@ describe("compact workspace sheet", () => {
|
|||||||
labels: {},
|
labels: {},
|
||||||
projectPlacement: null,
|
projectPlacement: null,
|
||||||
...agent,
|
...agent,
|
||||||
|
...managedAgentFields(
|
||||||
|
agent.parentAgentId ? { serverId: "server-1", agentId: agent.parentAgentId } : null,
|
||||||
|
),
|
||||||
} satisfies Agent,
|
} satisfies Agent,
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
@@ -4719,7 +4731,7 @@ describe("compact workspace sheet", () => {
|
|||||||
|
|
||||||
it("opens subagents and provider subagents in the sheet but keeps root agents as tabs", () => {
|
it("opens subagents and provider subagents in the sheet but keeps root agents as tabs", () => {
|
||||||
seedAgents([
|
seedAgents([
|
||||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
|
||||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
{ id: "other-workspace-child", parentAgentId: "root", workspaceId: "other-ws" },
|
{ id: "other-workspace-child", parentAgentId: "root", workspaceId: "other-ws" },
|
||||||
]);
|
]);
|
||||||
@@ -4756,7 +4768,7 @@ describe("compact workspace sheet", () => {
|
|||||||
|
|
||||||
it("gives a sheet target opened from inside the sheet the sheet's own parent", () => {
|
it("gives a sheet target opened from inside the sheet the sheet's own parent", () => {
|
||||||
seedAgents([
|
seedAgents([
|
||||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
|
||||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
]);
|
]);
|
||||||
const store = createCompactStore();
|
const store = createCompactStore();
|
||||||
@@ -4785,9 +4797,9 @@ describe("compact workspace sheet", () => {
|
|||||||
|
|
||||||
it("closes the sheet and reveals the tab when a tab target is opened from inside the sheet", () => {
|
it("closes the sheet and reveals the tab when a tab target is opened from inside the sheet", () => {
|
||||||
seedAgents([
|
seedAgents([
|
||||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
|
||||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
{ id: "second-root", parentAgentId: null, workspaceId: "sheet-ws" },
|
{ id: "second-root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
|
||||||
]);
|
]);
|
||||||
const store = createCompactStore();
|
const store = createCompactStore();
|
||||||
const rootTabId = store
|
const rootTabId = store
|
||||||
@@ -4963,7 +4975,7 @@ describe("compact workspace sheet", () => {
|
|||||||
|
|
||||||
it("closes the sheet when its agent leaves the workspace", () => {
|
it("closes the sheet when its agent leaves the workspace", () => {
|
||||||
seedAgents([
|
seedAgents([
|
||||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
|
||||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
]);
|
]);
|
||||||
const store = createCompactStore();
|
const store = createCompactStore();
|
||||||
@@ -4990,7 +5002,7 @@ describe("compact workspace sheet", () => {
|
|||||||
|
|
||||||
it("drops a departed agent's sheet from the middle of the stack", () => {
|
it("drops a departed agent's sheet from the middle of the stack", () => {
|
||||||
seedAgents([
|
seedAgents([
|
||||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
|
||||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
]);
|
]);
|
||||||
const store = createCompactStore();
|
const store = createCompactStore();
|
||||||
@@ -5018,7 +5030,7 @@ describe("compact workspace sheet", () => {
|
|||||||
|
|
||||||
it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => {
|
it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => {
|
||||||
seedAgents([
|
seedAgents([
|
||||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
|
||||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
]);
|
]);
|
||||||
const store = createCompactStore();
|
const store = createCompactStore();
|
||||||
@@ -5041,8 +5053,8 @@ describe("compact workspace sheet", () => {
|
|||||||
expect(topSheet(store)?.tab.tabId).toBe(sheetTabId);
|
expect(topSheet(store)?.tab.tabId).toBe(sheetTabId);
|
||||||
|
|
||||||
seedAgents([
|
seedAgents([
|
||||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
|
||||||
{ id: "child", parentAgentId: null, workspaceId: "sheet-ws" },
|
{ id: "child", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
|
||||||
]);
|
]);
|
||||||
store.getState().reconcileTabs(workspaceKey, snapshot);
|
store.getState().reconcileTabs(workspaceKey, snapshot);
|
||||||
|
|
||||||
@@ -5057,7 +5069,7 @@ describe("compact workspace sheet", () => {
|
|||||||
|
|
||||||
it("re-resolves the sheet policy when the sheet tab gets another target", () => {
|
it("re-resolves the sheet policy when the sheet tab gets another target", () => {
|
||||||
seedAgents([
|
seedAgents([
|
||||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
|
||||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
]);
|
]);
|
||||||
const store = createCompactStore();
|
const store = createCompactStore();
|
||||||
@@ -5111,10 +5123,35 @@ describe("compact workspace sheet", () => {
|
|||||||
).toBe(tabId);
|
).toBe(tabId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses its authority's membership for compact placement beside an equal direct address", () => {
|
||||||
|
seedAgents([
|
||||||
|
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
|
||||||
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
|
]);
|
||||||
|
const boundStore = createSessionStore();
|
||||||
|
boundStore.getState().initializeSession("server-1", null as unknown as DaemonClient);
|
||||||
|
boundStore
|
||||||
|
.getState()
|
||||||
|
.setAgents("server-1", new Map(useSessionStore.getState().sessions["server-1"].agents));
|
||||||
|
const directChild = useSessionStore.getState().sessions["server-1"].agents.get("child")!;
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.setAgents("server-1", new Map([["child", { ...directChild, managedRelationship: null }]]));
|
||||||
|
const layout = createWorkspaceLayoutStore(undefined, undefined, { sessionStore: boundStore });
|
||||||
|
layout.getState().setLayoutMode("compact");
|
||||||
|
const input = {
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "agent" as const, agentId: "child" },
|
||||||
|
intent: "reveal" as const,
|
||||||
|
};
|
||||||
|
expect(layout.getState().opensInSheet(input)).toBe(true);
|
||||||
|
expect(createCompactStore().getState().opensInSheet(input)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a chat shown in the sheet open for timeline sync until the sheet closes", () => {
|
it("keeps a chat shown in the sheet open for timeline sync until the sheet closes", () => {
|
||||||
const store = createCompactStore();
|
const store = createCompactStore();
|
||||||
seedAgents([
|
seedAgents([
|
||||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
|
||||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
]);
|
]);
|
||||||
const received: string[][] = [];
|
const received: string[][] = [];
|
||||||
@@ -5131,3 +5168,105 @@ describe("compact workspace sheet", () => {
|
|||||||
expect(received).toEqual([[], ["child"], []]);
|
expect(received).toEqual([[], ["child"], []]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists independent layouts for equal destination workspaces under separate authority namespaces", async () => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const storage = {
|
||||||
|
getItem: async (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: async (key: string, value: string) => {
|
||||||
|
values.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: async (key: string) => {
|
||||||
|
values.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const direct = createWorkspaceLayoutStore(undefined, storage, { namespace: "B-B" });
|
||||||
|
const routed = createWorkspaceLayoutStore(undefined, storage, { namespace: "A-B" });
|
||||||
|
await Promise.all([direct.persist.rehydrate(), routed.persist.rehydrate()]);
|
||||||
|
direct.getState().openTab({
|
||||||
|
workspaceKey: "B:workspace",
|
||||||
|
target: { kind: "agent", agentId: "direct" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
routed.getState().openTab({
|
||||||
|
workspaceKey: "B:workspace",
|
||||||
|
target: { kind: "agent", agentId: "routed" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
const reopenedDirect = createWorkspaceLayoutStore(undefined, storage, { namespace: "B-B" });
|
||||||
|
await reopenedDirect.persist.rehydrate();
|
||||||
|
const reopened = createWorkspaceLayoutStore(undefined, storage, { namespace: "A-B" });
|
||||||
|
await reopened.persist.rehydrate();
|
||||||
|
expect(
|
||||||
|
reopenedDirect
|
||||||
|
.getState()
|
||||||
|
.getWorkspaceTabs("B:workspace")
|
||||||
|
.filter((tab) => tab.target.kind === "agent")
|
||||||
|
.map((tab) => tab.target),
|
||||||
|
).toEqual([{ kind: "agent", agentId: "direct" }]);
|
||||||
|
expect(
|
||||||
|
reopened
|
||||||
|
.getState()
|
||||||
|
.getWorkspaceTabs("B:workspace")
|
||||||
|
.filter((tab) => tab.target.kind === "agent")
|
||||||
|
.map((tab) => tab.target),
|
||||||
|
).toEqual([{ kind: "agent", agentId: "routed" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upgrades pre-feature direct layout once without importing another server's tabs", async () => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const storage = {
|
||||||
|
getItem: async (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: async (key: string, value: string) => {
|
||||||
|
values.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: async (key: string) => {
|
||||||
|
values.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const legacy = createWorkspaceLayoutStore(undefined, storage);
|
||||||
|
await legacy.persist.rehydrate();
|
||||||
|
legacy.getState().openTab({
|
||||||
|
workspaceKey: "B:workspace",
|
||||||
|
target: { kind: "agent", agentId: "saved" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
legacy.getState().openTab({
|
||||||
|
workspaceKey: "C:workspace",
|
||||||
|
target: { kind: "agent", agentId: "unrelated" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
const upgraded = createWorkspaceLayoutStore(undefined, storage, {
|
||||||
|
namespace: "direct-B",
|
||||||
|
legacyServerId: "B",
|
||||||
|
});
|
||||||
|
await upgraded.persist.rehydrate();
|
||||||
|
expect(
|
||||||
|
upgraded
|
||||||
|
.getState()
|
||||||
|
.getWorkspaceTabs("B:workspace")
|
||||||
|
.some((tab) => tab.target.kind === "agent" && tab.target.agentId === "saved"),
|
||||||
|
).toBe(true);
|
||||||
|
expect(upgraded.getState().layoutByWorkspace["C:workspace"]).toBeUndefined();
|
||||||
|
upgraded.getState().openTab({
|
||||||
|
workspaceKey: "B:workspace",
|
||||||
|
target: { kind: "agent", agentId: "new" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
legacy.getState().openTab({
|
||||||
|
workspaceKey: "B:workspace",
|
||||||
|
target: { kind: "agent", agentId: "later-global" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
const restored = createWorkspaceLayoutStore(undefined, storage, {
|
||||||
|
namespace: "direct-B",
|
||||||
|
legacyServerId: "B",
|
||||||
|
});
|
||||||
|
await restored.persist.rehydrate();
|
||||||
|
expect(
|
||||||
|
restored
|
||||||
|
.getState()
|
||||||
|
.getWorkspaceTabs("B:workspace")
|
||||||
|
.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
|
||||||
|
).toEqual(["saved", "new"]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { useSessionStore, type SessionStoreApi } from "@/stores/session-store";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import { create } from "zustand";
|
import { create, useStore } from "zustand";
|
||||||
import { persist, type StateStorage } from "zustand/middleware";
|
import { persist, type StateStorage } from "zustand/middleware";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { WorkspaceLayoutPersistedStateSchema } from "./workspace-layout-storage";
|
import {
|
||||||
|
WorkspaceLayoutPersistedStateSchema,
|
||||||
|
workspaceLayoutStorageWithLegacySeed,
|
||||||
|
} from "./workspace-layout-storage";
|
||||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import {
|
import {
|
||||||
@@ -763,12 +767,13 @@ function resolveSheetPolicy(
|
|||||||
workspaceKey: string,
|
workspaceKey: string,
|
||||||
target: WorkspaceTabTarget,
|
target: WorkspaceTabTarget,
|
||||||
intent: WorkspaceTabOpenIntent,
|
intent: WorkspaceTabOpenIntent,
|
||||||
|
policy: typeof resolveCompactSheetPolicy,
|
||||||
): CompactSheetPolicy | null {
|
): CompactSheetPolicy | null {
|
||||||
if (state.layoutMode !== "compact" || intent === "background") return null;
|
if (state.layoutMode !== "compact" || intent === "background") return null;
|
||||||
if (intent === "reveal" && isTargetOpenAsTab(state.layoutByWorkspace[workspaceKey], target)) {
|
if (intent === "reveal" && isTargetOpenAsTab(state.layoutByWorkspace[workspaceKey], target)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return resolveCompactSheetPolicy(workspaceKey, target);
|
return policy(workspaceKey, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -880,8 +885,9 @@ function resolveSheetPlacement(
|
|||||||
workspaceKey: string,
|
workspaceKey: string,
|
||||||
target: WorkspaceTabTarget,
|
target: WorkspaceTabTarget,
|
||||||
intent: WorkspaceTabOpenIntent,
|
intent: WorkspaceTabOpenIntent,
|
||||||
|
resolvePolicy: typeof resolveCompactSheetPolicy,
|
||||||
): { policy: CompactSheetPolicy; keptIndex: number | null } | null {
|
): { policy: CompactSheetPolicy; keptIndex: number | null } | null {
|
||||||
const policy = resolveSheetPolicy(state, workspaceKey, target, intent);
|
const policy = resolveSheetPolicy(state, workspaceKey, target, intent, resolvePolicy);
|
||||||
if (!policy) return null;
|
if (!policy) return null;
|
||||||
if (intent !== "reveal") return { policy, keptIndex: null };
|
if (intent !== "reveal") return { policy, keptIndex: null };
|
||||||
const sheets = getSheets(state, workspaceKey);
|
const sheets = getSheets(state, workspaceKey);
|
||||||
@@ -926,10 +932,11 @@ function sheetAgentNoLongerFits(
|
|||||||
workspaceKey: string,
|
workspaceKey: string,
|
||||||
sheet: SheetTab,
|
sheet: SheetTab,
|
||||||
snapshot: WorkspaceTabSnapshot,
|
snapshot: WorkspaceTabSnapshot,
|
||||||
|
policy: typeof resolveCompactSheetPolicy,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false;
|
if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false;
|
||||||
if (!new Set(snapshot.activeAgentIds).has(sheet.tab.target.agentId)) return true;
|
if (!new Set(snapshot.activeAgentIds).has(sheet.tab.target.agentId)) return true;
|
||||||
return resolveCompactSheetPolicy(workspaceKey, sheet.tab.target) === null;
|
return policy(workspaceKey, sheet.tab.target) === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -997,7 +1004,10 @@ function createPersistedStateProjection() {
|
|||||||
export function createWorkspaceLayoutStore(
|
export function createWorkspaceLayoutStore(
|
||||||
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
|
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
|
||||||
storage: StateStorage = AsyncStorage,
|
storage: StateStorage = AsyncStorage,
|
||||||
|
persistence: { namespace?: string; legacyServerId?: string; sessionStore?: SessionStoreApi } = {},
|
||||||
) {
|
) {
|
||||||
|
const compactSheetPolicy: typeof resolveCompactSheetPolicy = (workspaceKey, target) =>
|
||||||
|
resolveCompactSheetPolicy(workspaceKey, target, persistence.sessionStore ?? useSessionStore);
|
||||||
return create<WorkspaceLayoutStore>()(
|
return create<WorkspaceLayoutStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
@@ -1050,6 +1060,7 @@ export function createWorkspaceLayoutStore(
|
|||||||
normalizedWorkspaceKey,
|
normalizedWorkspaceKey,
|
||||||
normalizedTarget,
|
normalizedTarget,
|
||||||
input.intent,
|
input.intent,
|
||||||
|
compactSheetPolicy,
|
||||||
);
|
);
|
||||||
if (sheetPlacement) {
|
if (sheetPlacement) {
|
||||||
const sheets = getSheets(get(), normalizedWorkspaceKey);
|
const sheets = getSheets(get(), normalizedWorkspaceKey);
|
||||||
@@ -1163,8 +1174,13 @@ export function createWorkspaceLayoutStore(
|
|||||||
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
||||||
if (!normalizedWorkspaceKey || !normalizedTarget) return false;
|
if (!normalizedWorkspaceKey || !normalizedTarget) return false;
|
||||||
return (
|
return (
|
||||||
resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !==
|
resolveSheetPlacement(
|
||||||
null
|
get(),
|
||||||
|
normalizedWorkspaceKey,
|
||||||
|
normalizedTarget,
|
||||||
|
input.intent,
|
||||||
|
compactSheetPolicy,
|
||||||
|
) !== null
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
setLayoutMode: (mode) => {
|
setLayoutMode: (mode) => {
|
||||||
@@ -1493,7 +1509,7 @@ export function createWorkspaceLayoutStore(
|
|||||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||||
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null;
|
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null;
|
||||||
if (findSheet(get(), normalizedWorkspaceKey, normalizedTabId)) {
|
if (findSheet(get(), normalizedWorkspaceKey, normalizedTabId)) {
|
||||||
const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget);
|
const policy = compactSheetPolicy(normalizedWorkspaceKey, normalizedTarget);
|
||||||
const next: WorkspaceLayoutStore = {
|
const next: WorkspaceLayoutStore = {
|
||||||
...get(),
|
...get(),
|
||||||
...withSheetTab(get(), normalizedWorkspaceKey, normalizedTabId, (sheet) => ({
|
...withSheetTab(get(), normalizedWorkspaceKey, normalizedTabId, (sheet) => ({
|
||||||
@@ -1650,7 +1666,13 @@ export function createWorkspaceLayoutStore(
|
|||||||
}
|
}
|
||||||
const sheets = getSheets(state, normalizedWorkspaceKey);
|
const sheets = getSheets(state, normalizedWorkspaceKey);
|
||||||
const fittingSheets = sheets.filter(
|
const fittingSheets = sheets.filter(
|
||||||
(sheet) => !sheetAgentNoLongerFits(normalizedWorkspaceKey, sheet, snapshot),
|
(sheet) =>
|
||||||
|
!sheetAgentNoLongerFits(
|
||||||
|
normalizedWorkspaceKey,
|
||||||
|
sheet,
|
||||||
|
snapshot,
|
||||||
|
compactSheetPolicy,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const sheetAgentGone = fittingSheets.length !== sheets.length;
|
const sheetAgentGone = fittingSheets.length !== sheets.length;
|
||||||
if (
|
if (
|
||||||
@@ -2213,11 +2235,19 @@ export function createWorkspaceLayoutStore(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "workspace-layout-state",
|
name: persistence.namespace
|
||||||
|
? `workspace-layout-state:${persistence.namespace}`
|
||||||
|
: "workspace-layout-state",
|
||||||
version: WORKSPACE_LAYOUT_PERSIST_VERSION,
|
version: WORKSPACE_LAYOUT_PERSIST_VERSION,
|
||||||
storage: createValidatedPersistStorage(storage, WorkspaceLayoutPersistedStateSchema, {
|
storage: createValidatedPersistStorage(
|
||||||
skipUnchanged: true,
|
persistence.legacyServerId
|
||||||
}),
|
? workspaceLayoutStorageWithLegacySeed(storage, persistence.legacyServerId)
|
||||||
|
: storage,
|
||||||
|
WorkspaceLayoutPersistedStateSchema,
|
||||||
|
{
|
||||||
|
skipUnchanged: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
migrate: (persistedState, version) =>
|
migrate: (persistedState, version) =>
|
||||||
migrateWorkspaceLayoutPersistedState(persistedState, version, ids),
|
migrateWorkspaceLayoutPersistedState(persistedState, version, ids),
|
||||||
partialize: createPersistedStateProjection(),
|
partialize: createPersistedStateProjection(),
|
||||||
@@ -2280,7 +2310,19 @@ export function createWorkspaceLayoutStore(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWorkspaceLayoutStore = createWorkspaceLayoutStore();
|
export type WorkspaceLayoutStoreApi = ReturnType<typeof createWorkspaceLayoutStore>;
|
||||||
|
export const defaultWorkspaceLayoutStore = createWorkspaceLayoutStore();
|
||||||
|
export const WorkspaceLayoutStoreContext = createContext<WorkspaceLayoutStoreApi | null>(null);
|
||||||
|
export function useWorkspaceLayoutStoreApi(): WorkspaceLayoutStoreApi {
|
||||||
|
return useContext(WorkspaceLayoutStoreContext) ?? defaultWorkspaceLayoutStore;
|
||||||
|
}
|
||||||
|
// COMPAT(unboundWorkspaceLayout): added in v0.12, remove after 2027-04-20.
|
||||||
|
// Existing app-shell callers retain the default store; bound consumers capture their owner.
|
||||||
|
export const useWorkspaceLayoutStore = Object.assign(function useWorkspaceLayoutStore<T>(
|
||||||
|
selector: (state: WorkspaceLayoutStore) => T,
|
||||||
|
): T {
|
||||||
|
return useStore(useWorkspaceLayoutStoreApi(), selector);
|
||||||
|
}, defaultWorkspaceLayoutStore);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The agent tabs that exist right now, including sheet tabs, across every workspace of this host,
|
* The agent tabs that exist right now, including sheet tabs, across every workspace of this host,
|
||||||
@@ -2331,20 +2373,19 @@ export function observeOpenWorkspaceAgentIds(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspaceLayoutStoreHydrated(): boolean {
|
export function useWorkspaceLayoutStoreHydrated(): boolean {
|
||||||
const [hasHydrated, setHasHydrated] = useState(() =>
|
const store = useWorkspaceLayoutStoreApi();
|
||||||
useWorkspaceLayoutStore.persist.hasHydrated(),
|
const [hasHydrated, setHasHydrated] = useState(() => store.persist.hasHydrated());
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (useWorkspaceLayoutStore.persist.hasHydrated()) {
|
if (store.persist.hasHydrated()) {
|
||||||
setHasHydrated(true);
|
setHasHydrated(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return useWorkspaceLayoutStore.persist.onFinishHydration(() => {
|
return store.persist.onFinishHydration(() => {
|
||||||
setHasHydrated(true);
|
setHasHydrated(true);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [store]);
|
||||||
|
|
||||||
return hasHydrated;
|
return hasHydrated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { managedAgentFields } from "@/subagents/test-fixtures";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
} from "@/workspace-tabs/agent-visibility";
|
} from "@/workspace-tabs/agent-visibility";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { selectSubagentsForParent } from "@/subagents/select";
|
import { selectSubagentsForParent } from "@/subagents/select";
|
||||||
|
import { membershipFixture, relationshipFixture } from "@/subagents/test-fixtures";
|
||||||
import { findPaneById, findPaneContainingTab } from "./workspace-layout-actions";
|
import { findPaneById, findPaneContainingTab } from "./workspace-layout-actions";
|
||||||
import { useWorkspaceLayoutStore } from "./workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||||
import { useSessionStore, type Agent } from "./session-store";
|
import { useSessionStore, type Agent } from "./session-store";
|
||||||
@@ -67,7 +69,8 @@ const AGENT_DEFAULTS: Agent = {
|
|||||||
attentionReason: null,
|
attentionReason: null,
|
||||||
attentionTimestamp: null,
|
attentionTimestamp: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
parentAgentId: null,
|
parent: null,
|
||||||
|
managedRelationship: null,
|
||||||
labels: {},
|
labels: {},
|
||||||
projectPlacement: null,
|
projectPlacement: null,
|
||||||
};
|
};
|
||||||
@@ -142,7 +145,7 @@ describe("workspace subagents integration", () => {
|
|||||||
|
|
||||||
const child = makeAgent({
|
const child = makeAgent({
|
||||||
id: "child-agent",
|
id: "child-agent",
|
||||||
parentAgentId: "parent-agent",
|
...managedAgentFields({ serverId: SERVER_ID, agentId: "parent-agent" }),
|
||||||
title: "Child agent",
|
title: "Child agent",
|
||||||
});
|
});
|
||||||
const parent = makeAgent({
|
const parent = makeAgent({
|
||||||
@@ -163,13 +166,12 @@ describe("workspace subagents integration", () => {
|
|||||||
expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
|
expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
|
||||||
expect(
|
expect(
|
||||||
selectSubagentsForParent(
|
selectSubagentsForParent(
|
||||||
useSessionStore.getState(),
|
membershipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, [
|
||||||
{
|
relationshipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, child),
|
||||||
serverId: SERVER_ID,
|
]),
|
||||||
parentAgentId: "parent-agent",
|
{ originServerId: SERVER_ID, serverId: SERVER_ID },
|
||||||
},
|
|
||||||
new Set(),
|
new Set(),
|
||||||
).map((row) => row.id),
|
).map((row) => row.address.agentId),
|
||||||
).toEqual(["child-agent"]);
|
).toEqual(["child-agent"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,7 +188,7 @@ describe("workspace subagents integration", () => {
|
|||||||
});
|
});
|
||||||
const child = makeAgent({
|
const child = makeAgent({
|
||||||
id: "child-agent",
|
id: "child-agent",
|
||||||
parentAgentId: "parent-agent",
|
...managedAgentFields({ serverId: SERVER_ID, agentId: "parent-agent" }),
|
||||||
title: "Child agent",
|
title: "Child agent",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,16 +198,15 @@ describe("workspace subagents integration", () => {
|
|||||||
expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
|
expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
|
||||||
expect(
|
expect(
|
||||||
selectSubagentsForParent(
|
selectSubagentsForParent(
|
||||||
useSessionStore.getState(),
|
membershipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, [
|
||||||
{
|
relationshipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, child),
|
||||||
serverId: SERVER_ID,
|
]),
|
||||||
parentAgentId: "parent-agent",
|
{ originServerId: SERVER_ID, serverId: SERVER_ID },
|
||||||
},
|
|
||||||
new Set(),
|
new Set(),
|
||||||
).map((row) => row.id),
|
).map((row) => row.address.agentId),
|
||||||
).toEqual(["child-agent"]);
|
).toEqual(["child-agent"]);
|
||||||
|
|
||||||
appendAgent({ ...child, parentAgentId: null, labels: {} });
|
appendAgent({ ...child, parent: null, managedRelationship: null, labels: {} });
|
||||||
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
|
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
|
||||||
|
|
||||||
expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual([
|
expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual([
|
||||||
@@ -214,11 +215,12 @@ describe("workspace subagents integration", () => {
|
|||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
selectSubagentsForParent(
|
selectSubagentsForParent(
|
||||||
useSessionStore.getState(),
|
membershipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, [
|
||||||
{
|
relationshipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, child, {
|
||||||
serverId: SERVER_ID,
|
phase: "detached",
|
||||||
parentAgentId: "parent-agent",
|
}),
|
||||||
},
|
]),
|
||||||
|
{ originServerId: SERVER_ID, serverId: SERVER_ID },
|
||||||
new Set(),
|
new Set(),
|
||||||
),
|
),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
@@ -238,7 +240,7 @@ describe("workspace subagents integration", () => {
|
|||||||
});
|
});
|
||||||
const child = makeAgent({
|
const child = makeAgent({
|
||||||
id: "child-agent",
|
id: "child-agent",
|
||||||
parentAgentId: parent.id,
|
...managedAgentFields({ serverId: SERVER_ID, agentId: parent.id }),
|
||||||
title: "Cross-workspace child",
|
title: "Cross-workspace child",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -248,13 +250,12 @@ describe("workspace subagents integration", () => {
|
|||||||
expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual(["agent_child-agent"]);
|
expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual(["agent_child-agent"]);
|
||||||
expect(
|
expect(
|
||||||
selectSubagentsForParent(
|
selectSubagentsForParent(
|
||||||
useSessionStore.getState(),
|
membershipFixture({ serverId: SERVER_ID, agentId: parent.id }, [
|
||||||
{
|
relationshipFixture({ serverId: SERVER_ID, agentId: parent.id }, child),
|
||||||
serverId: SERVER_ID,
|
]),
|
||||||
parentAgentId: parent.id,
|
{ originServerId: SERVER_ID, serverId: SERVER_ID },
|
||||||
},
|
|
||||||
new Set(),
|
new Set(),
|
||||||
).map((row) => row.id),
|
).map((row) => row.address.agentId),
|
||||||
).toEqual([child.id]);
|
).toEqual([child.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,7 +272,7 @@ describe("workspace subagents integration", () => {
|
|||||||
});
|
});
|
||||||
const child = makeAgent({
|
const child = makeAgent({
|
||||||
id: "child-agent",
|
id: "child-agent",
|
||||||
parentAgentId: parent.id,
|
...managedAgentFields({ serverId: SERVER_ID, agentId: parent.id }),
|
||||||
title: "Child agent",
|
title: "Child agent",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,20 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { createArchiveFinishedSubagents, type ManagedSubagentSnapshot } from "./archive-finished";
|
import { createArchiveFinishedSubagents, type ManagedSubagentSnapshot } from "./archive-finished";
|
||||||
import type { PaseoSubagentRow, ProviderSubagentRow } from "./select";
|
import type { PaseoSubagentRow, ProviderSubagentRow } from "./select";
|
||||||
|
|
||||||
|
const address = (agentId: string) => ({ serverId: "server-1", agentId });
|
||||||
|
const parent = address("parent");
|
||||||
|
|
||||||
function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSubagentRow {
|
function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSubagentRow {
|
||||||
return {
|
return {
|
||||||
kind: "paseo",
|
kind: "paseo",
|
||||||
id,
|
facts: null,
|
||||||
|
address: address(id),
|
||||||
|
authority: { originServerId: "server-1", serverId: "server-1" },
|
||||||
|
relationshipId: id,
|
||||||
|
workspace: null,
|
||||||
|
freshness: "current",
|
||||||
|
phase: "attached",
|
||||||
|
error: null,
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
title: id,
|
title: id,
|
||||||
description: null,
|
description: null,
|
||||||
@@ -44,9 +54,11 @@ function managed(
|
|||||||
overrides: Partial<ManagedSubagentSnapshot> = {},
|
overrides: Partial<ManagedSubagentSnapshot> = {},
|
||||||
): ManagedSubagentSnapshot {
|
): ManagedSubagentSnapshot {
|
||||||
return {
|
return {
|
||||||
id,
|
address: address(id),
|
||||||
status,
|
status,
|
||||||
parentAgentId: "parent",
|
freshness: "current",
|
||||||
|
phase: "attached",
|
||||||
|
parent,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -75,9 +87,9 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
const archive = createArchiveFinishedSubagents(
|
const archive = createArchiveFinishedSubagents(
|
||||||
[provider("native"), paseo("first"), paseo("second", "error"), paseo("resumed")],
|
[provider("native"), paseo("first"), paseo("second", "error"), paseo("resumed")],
|
||||||
{
|
{
|
||||||
parentAgentId: "parent",
|
parent,
|
||||||
getManagedSubagent: (id) => current.get(id),
|
getManagedSubagent: (child) => current.get(child.agentId),
|
||||||
archiveManagedSubagent: (id) => {
|
archiveManagedSubagent: ({ agentId: id }) => {
|
||||||
events.push(`archive:${id}`);
|
events.push(`archive:${id}`);
|
||||||
return id === "first" ? first.promise : second.promise;
|
return id === "first" ? first.promise : second.promise;
|
||||||
},
|
},
|
||||||
@@ -109,9 +121,9 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
|
|
||||||
second.resolve();
|
second.resolve();
|
||||||
await expect(operation).resolves.toEqual({
|
await expect(operation).resolves.toEqual({
|
||||||
archivedPaseoIds: ["first", "second"],
|
archivedPaseoAgents: [address("first"), address("second")],
|
||||||
dismissedProviderIds: ["native"],
|
dismissedProviderIds: ["native"],
|
||||||
skippedPaseoIds: ["resumed"],
|
skippedPaseoAgents: [address("resumed")],
|
||||||
failures: [],
|
failures: [],
|
||||||
});
|
});
|
||||||
expect(progress).toEqual(["0/4", "1/4", "2/4", "3/4", "4/4"]);
|
expect(progress).toEqual(["0/4", "1/4", "2/4", "3/4", "4/4"]);
|
||||||
@@ -129,9 +141,9 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
const responses = [first.promise, second.promise, retry.promise];
|
const responses = [first.promise, second.promise, retry.promise];
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
|
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
|
||||||
parentAgentId: "parent",
|
parent,
|
||||||
getManagedSubagent: (id) => current.get(id),
|
getManagedSubagent: (child) => current.get(child.agentId),
|
||||||
archiveManagedSubagent: (id) => {
|
archiveManagedSubagent: ({ agentId: id }) => {
|
||||||
calls.push(id);
|
calls.push(id);
|
||||||
const response = responses.shift();
|
const response = responses.shift();
|
||||||
if (!response) throw new Error("Unexpected archive");
|
if (!response) throw new Error("Unexpected archive");
|
||||||
@@ -148,10 +160,10 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
second.resolve();
|
second.resolve();
|
||||||
archive.setRows([]);
|
archive.setRows([]);
|
||||||
await expect(firstAttempt).resolves.toEqual({
|
await expect(firstAttempt).resolves.toEqual({
|
||||||
archivedPaseoIds: ["second"],
|
archivedPaseoAgents: [address("second")],
|
||||||
dismissedProviderIds: [],
|
dismissedProviderIds: [],
|
||||||
skippedPaseoIds: [],
|
skippedPaseoAgents: [],
|
||||||
failures: [{ id: "first", error: firstError }],
|
failures: [{ address: address("first"), error: firstError }],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(archive.getState()).toEqual({
|
expect(archive.getState()).toEqual({
|
||||||
@@ -181,10 +193,10 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
const retryError = new Error("retry failed");
|
const retryError = new Error("retry failed");
|
||||||
retry.reject(retryError);
|
retry.reject(retryError);
|
||||||
await expect(retryAttempt).resolves.toEqual({
|
await expect(retryAttempt).resolves.toEqual({
|
||||||
archivedPaseoIds: [],
|
archivedPaseoAgents: [],
|
||||||
dismissedProviderIds: [],
|
dismissedProviderIds: [],
|
||||||
skippedPaseoIds: [],
|
skippedPaseoAgents: [],
|
||||||
failures: [{ id: "first", error: retryError }],
|
failures: [{ address: address("first"), error: retryError }],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(archive.getState()).toEqual({
|
expect(archive.getState()).toEqual({
|
||||||
@@ -203,9 +215,9 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
["second", managed("second")],
|
["second", managed("second")],
|
||||||
]);
|
]);
|
||||||
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
|
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
|
||||||
parentAgentId: "parent",
|
parent,
|
||||||
getManagedSubagent: (id) => current.get(id),
|
getManagedSubagent: (child) => current.get(child.agentId),
|
||||||
archiveManagedSubagent: async (id) => {
|
archiveManagedSubagent: async ({ agentId: id }) => {
|
||||||
throw id === "first" ? firstError : secondError;
|
throw id === "first" ? firstError : secondError;
|
||||||
},
|
},
|
||||||
dismissProviderSubagents: () => undefined,
|
dismissProviderSubagents: () => undefined,
|
||||||
@@ -229,8 +241,8 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
it("clears retry state when the authoritative failed child is running", async () => {
|
it("clears retry state when the authoritative failed child is running", async () => {
|
||||||
const current = new Map([["failed", managed("failed")]]);
|
const current = new Map([["failed", managed("failed")]]);
|
||||||
const archive = createArchiveFinishedSubagents([paseo("failed")], {
|
const archive = createArchiveFinishedSubagents([paseo("failed")], {
|
||||||
parentAgentId: "parent",
|
parent,
|
||||||
getManagedSubagent: (id) => current.get(id),
|
getManagedSubagent: (child) => current.get(child.agentId),
|
||||||
archiveManagedSubagent: async () => {
|
archiveManagedSubagent: async () => {
|
||||||
current.set("failed", managed("failed", "running"));
|
current.set("failed", managed("failed", "running"));
|
||||||
throw new Error("archive failed");
|
throw new Error("archive failed");
|
||||||
@@ -245,9 +257,9 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
|
|
||||||
expect(archive.getState()).toEqual({ eligibleCount: 0, status: { kind: "idle" } });
|
expect(archive.getState()).toEqual({ eligibleCount: 0, status: { kind: "idle" } });
|
||||||
await expect(archive.archiveFinished()).resolves.toEqual({
|
await expect(archive.archiveFinished()).resolves.toEqual({
|
||||||
archivedPaseoIds: [],
|
archivedPaseoAgents: [],
|
||||||
dismissedProviderIds: [],
|
dismissedProviderIds: [],
|
||||||
skippedPaseoIds: [],
|
skippedPaseoAgents: [],
|
||||||
failures: [],
|
failures: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -255,8 +267,8 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
it("clears retry state when the authoritative failed child is initializing", async () => {
|
it("clears retry state when the authoritative failed child is initializing", async () => {
|
||||||
const current = new Map([["failed", managed("failed")]]);
|
const current = new Map([["failed", managed("failed")]]);
|
||||||
const archive = createArchiveFinishedSubagents([paseo("failed")], {
|
const archive = createArchiveFinishedSubagents([paseo("failed")], {
|
||||||
parentAgentId: "parent",
|
parent,
|
||||||
getManagedSubagent: (id) => current.get(id),
|
getManagedSubagent: (child) => current.get(child.agentId),
|
||||||
archiveManagedSubagent: async () => {
|
archiveManagedSubagent: async () => {
|
||||||
current.set("failed", managed("failed", "initializing"));
|
current.set("failed", managed("failed", "initializing"));
|
||||||
throw new Error("archive failed");
|
throw new Error("archive failed");
|
||||||
@@ -271,9 +283,9 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
|
|
||||||
expect(archive.getState()).toEqual({ eligibleCount: 0, status: { kind: "idle" } });
|
expect(archive.getState()).toEqual({ eligibleCount: 0, status: { kind: "idle" } });
|
||||||
await expect(archive.archiveFinished()).resolves.toEqual({
|
await expect(archive.archiveFinished()).resolves.toEqual({
|
||||||
archivedPaseoIds: [],
|
archivedPaseoAgents: [],
|
||||||
dismissedProviderIds: [],
|
dismissedProviderIds: [],
|
||||||
skippedPaseoIds: [],
|
skippedPaseoAgents: [],
|
||||||
failures: [],
|
failures: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -288,7 +300,7 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
[
|
[
|
||||||
"belongs to a different parent",
|
"belongs to a different parent",
|
||||||
(current: Map<string, ManagedSubagentSnapshot>) =>
|
(current: Map<string, ManagedSubagentSnapshot>) =>
|
||||||
current.set("failed", managed("failed", "idle", { parentAgentId: "other-parent" })),
|
current.set("failed", managed("failed", "idle", { parent: address("other-parent") })),
|
||||||
],
|
],
|
||||||
])(
|
])(
|
||||||
"clears retry state when the authoritative failed child %s",
|
"clears retry state when the authoritative failed child %s",
|
||||||
@@ -296,8 +308,8 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
const error = new Error("archive failed");
|
const error = new Error("archive failed");
|
||||||
const current = new Map([["failed", managed("failed")]]);
|
const current = new Map([["failed", managed("failed")]]);
|
||||||
const archive = createArchiveFinishedSubagents([paseo("failed")], {
|
const archive = createArchiveFinishedSubagents([paseo("failed")], {
|
||||||
parentAgentId: "parent",
|
parent,
|
||||||
getManagedSubagent: (id) => current.get(id),
|
getManagedSubagent: (child) => current.get(child.agentId),
|
||||||
archiveManagedSubagent: async () => {
|
archiveManagedSubagent: async () => {
|
||||||
updateSnapshot(current);
|
updateSnapshot(current);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -306,10 +318,10 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(archive.archiveFinished()).resolves.toEqual({
|
await expect(archive.archiveFinished()).resolves.toEqual({
|
||||||
archivedPaseoIds: [],
|
archivedPaseoAgents: [],
|
||||||
dismissedProviderIds: [],
|
dismissedProviderIds: [],
|
||||||
skippedPaseoIds: [],
|
skippedPaseoAgents: [],
|
||||||
failures: [{ id: "failed", error }],
|
failures: [{ address: address("failed"), error }],
|
||||||
});
|
});
|
||||||
expect(archive.getState()).toEqual({ eligibleCount: 1, status: { kind: "idle" } });
|
expect(archive.getState()).toEqual({ eligibleCount: 1, status: { kind: "idle" } });
|
||||||
},
|
},
|
||||||
@@ -324,9 +336,9 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
]);
|
]);
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
|
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
|
||||||
parentAgentId: "parent",
|
parent,
|
||||||
getManagedSubagent: (id) => current.get(id),
|
getManagedSubagent: (child) => current.get(child.agentId),
|
||||||
archiveManagedSubagent: async (id) => {
|
archiveManagedSubagent: async ({ agentId: id }) => {
|
||||||
calls.push(id);
|
calls.push(id);
|
||||||
if (calls.length === 1) throw firstError;
|
if (calls.length === 1) throw firstError;
|
||||||
if (calls.length === 2) {
|
if (calls.length === 2) {
|
||||||
@@ -340,12 +352,12 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
const operation = archive.archiveFinished();
|
const operation = archive.archiveFinished();
|
||||||
archive.setRows([]);
|
archive.setRows([]);
|
||||||
await expect(operation).resolves.toEqual({
|
await expect(operation).resolves.toEqual({
|
||||||
archivedPaseoIds: [],
|
archivedPaseoAgents: [],
|
||||||
dismissedProviderIds: [],
|
dismissedProviderIds: [],
|
||||||
skippedPaseoIds: [],
|
skippedPaseoAgents: [],
|
||||||
failures: [
|
failures: [
|
||||||
{ id: "first", error: firstError },
|
{ address: address("first"), error: firstError },
|
||||||
{ id: "second", error: secondError },
|
{ address: address("second"), error: secondError },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
archive.setRows([paseo("first"), paseo("second", "running")]);
|
archive.setRows([paseo("first"), paseo("second", "running")]);
|
||||||
@@ -355,9 +367,9 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
status: { kind: "failed", failedCount: 1, totalCount: 1 },
|
status: { kind: "failed", failedCount: 1, totalCount: 1 },
|
||||||
});
|
});
|
||||||
await expect(archive.archiveFinished()).resolves.toEqual({
|
await expect(archive.archiveFinished()).resolves.toEqual({
|
||||||
archivedPaseoIds: ["first"],
|
archivedPaseoAgents: [address("first")],
|
||||||
dismissedProviderIds: [],
|
dismissedProviderIds: [],
|
||||||
skippedPaseoIds: [],
|
skippedPaseoAgents: [],
|
||||||
failures: [],
|
failures: [],
|
||||||
});
|
});
|
||||||
expect(calls).toEqual(["first", "second", "first"]);
|
expect(calls).toEqual(["first", "second", "first"]);
|
||||||
@@ -377,15 +389,15 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
["first", managed("first")],
|
["first", managed("first")],
|
||||||
["resumed", managed("resumed")],
|
["resumed", managed("resumed")],
|
||||||
["archived", managed("archived", "idle", { archivedAt: new Date() })],
|
["archived", managed("archived", "idle", { archivedAt: new Date() })],
|
||||||
["reparented", managed("reparented", "idle", { parentAgentId: "other-parent" })],
|
["reparented", managed("reparented", "idle", { parent: address("other-parent") })],
|
||||||
["running", managed("running", "running")],
|
["running", managed("running", "running")],
|
||||||
["initializing", managed("initializing", "initializing")],
|
["initializing", managed("initializing", "initializing")],
|
||||||
]);
|
]);
|
||||||
const archived: string[] = [];
|
const archived: string[] = [];
|
||||||
const archive = createArchiveFinishedSubagents(rows, {
|
const archive = createArchiveFinishedSubagents(rows, {
|
||||||
parentAgentId: "parent",
|
parent,
|
||||||
getManagedSubagent: (id) => current.get(id),
|
getManagedSubagent: (child) => current.get(child.agentId),
|
||||||
archiveManagedSubagent: async (id) => {
|
archiveManagedSubagent: async ({ agentId: id }) => {
|
||||||
archived.push(id);
|
archived.push(id);
|
||||||
current.set("resumed", managed("resumed", "running"));
|
current.set("resumed", managed("resumed", "running"));
|
||||||
},
|
},
|
||||||
@@ -393,9 +405,16 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(archive.archiveFinished()).resolves.toEqual({
|
await expect(archive.archiveFinished()).resolves.toEqual({
|
||||||
archivedPaseoIds: ["first"],
|
archivedPaseoAgents: [address("first")],
|
||||||
dismissedProviderIds: [],
|
dismissedProviderIds: [],
|
||||||
skippedPaseoIds: ["resumed", "missing", "archived", "reparented", "running", "initializing"],
|
skippedPaseoAgents: [
|
||||||
|
address("resumed"),
|
||||||
|
address("missing"),
|
||||||
|
address("archived"),
|
||||||
|
address("reparented"),
|
||||||
|
address("running"),
|
||||||
|
address("initializing"),
|
||||||
|
],
|
||||||
failures: [],
|
failures: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -412,16 +431,16 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
]);
|
]);
|
||||||
const dismissed: string[][] = [];
|
const dismissed: string[][] = [];
|
||||||
const archive = createArchiveFinishedSubagents([...descriptors.values()], {
|
const archive = createArchiveFinishedSubagents([...descriptors.values()], {
|
||||||
parentAgentId: "parent",
|
parent,
|
||||||
getManagedSubagent: () => undefined,
|
getManagedSubagent: () => undefined,
|
||||||
archiveManagedSubagent: async () => undefined,
|
archiveManagedSubagent: async () => undefined,
|
||||||
dismissProviderSubagents: (ids) => dismissed.push(ids),
|
dismissProviderSubagents: (ids) => dismissed.push(ids),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(archive.archiveFinished()).resolves.toEqual({
|
await expect(archive.archiveFinished()).resolves.toEqual({
|
||||||
archivedPaseoIds: [],
|
archivedPaseoAgents: [],
|
||||||
dismissedProviderIds: ["finished"],
|
dismissedProviderIds: ["finished"],
|
||||||
skippedPaseoIds: [],
|
skippedPaseoAgents: [],
|
||||||
failures: [],
|
failures: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -430,3 +449,17 @@ describe("createArchiveFinishedSubagents", () => {
|
|||||||
expect(descriptors.get("running")).toBe(running);
|
expect(descriptors.get("running")).toBe(running);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not treat unavailable cached idle or unresolved lifecycle as current completion", () => {
|
||||||
|
const stale = { ...paseo("cached-idle"), freshness: "unavailable" as const };
|
||||||
|
const pending = { ...paseo("pending"), phase: "detach_pending" as const };
|
||||||
|
const archive = createArchiveFinishedSubagents([stale, pending], {
|
||||||
|
parent,
|
||||||
|
getManagedSubagent: () => undefined,
|
||||||
|
archiveManagedSubagent: async () => {
|
||||||
|
throw new Error("Must not dispatch");
|
||||||
|
},
|
||||||
|
dismissProviderSubagents: () => undefined,
|
||||||
|
});
|
||||||
|
expect(archive.getState().eligibleCount).toBe(0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Agent } from "@/stores/session-store";
|
import type { Agent } from "@/stores/session-store";
|
||||||
|
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||||
|
import type { SubagentRelationship } from "@getpaseo/protocol/messages";
|
||||||
import type { SubagentRow } from "./select";
|
import type { SubagentRow } from "./select";
|
||||||
|
|
||||||
export type ArchiveFinishedStatus =
|
export type ArchiveFinishedStatus =
|
||||||
@@ -12,18 +14,25 @@ export interface ArchiveFinishedState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ArchiveFinishedOutcome {
|
export interface ArchiveFinishedOutcome {
|
||||||
archivedPaseoIds: string[];
|
archivedPaseoAgents: AgentAddress[];
|
||||||
dismissedProviderIds: string[];
|
dismissedProviderIds: string[];
|
||||||
skippedPaseoIds: string[];
|
skippedPaseoAgents: AgentAddress[];
|
||||||
failures: Array<{ id: string; error: unknown }>;
|
failures: Array<{ address: AgentAddress; error: unknown }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ManagedSubagentSnapshot = Pick<Agent, "id" | "status" | "parentAgentId" | "archivedAt">;
|
export interface ManagedSubagentSnapshot {
|
||||||
|
address: AgentAddress;
|
||||||
|
parent: AgentAddress;
|
||||||
|
status: Agent["status"] | null;
|
||||||
|
freshness: SubagentRelationship["freshness"];
|
||||||
|
phase: SubagentRelationship["phase"];
|
||||||
|
archivedAt: Agent["archivedAt"];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ArchiveFinishedSubagentsDeps {
|
export interface ArchiveFinishedSubagentsDeps {
|
||||||
parentAgentId: string;
|
parent: AgentAddress;
|
||||||
getManagedSubagent: (id: string) => ManagedSubagentSnapshot | undefined;
|
getManagedSubagent: (address: AgentAddress) => ManagedSubagentSnapshot | undefined;
|
||||||
archiveManagedSubagent: (id: string) => Promise<void>;
|
archiveManagedSubagent: (address: AgentAddress) => Promise<void>;
|
||||||
dismissProviderSubagents: (ids: string[]) => void;
|
dismissProviderSubagents: (ids: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,18 +44,26 @@ export interface ArchiveFinishedSubagents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isFinishedSubagent(row: SubagentRow): boolean {
|
export function isFinishedSubagent(row: SubagentRow): boolean {
|
||||||
if (row.kind === "paseo") return row.status === "idle" || row.status === "error";
|
if (row.kind === "paseo")
|
||||||
|
return (
|
||||||
|
row.freshness === "current" &&
|
||||||
|
row.phase === "attached" &&
|
||||||
|
(row.status === "idle" || row.status === "error")
|
||||||
|
);
|
||||||
return row.status === "completed" || row.status === "failed" || row.status === "canceled";
|
return row.status === "completed" || row.status === "failed" || row.status === "canceled";
|
||||||
}
|
}
|
||||||
|
|
||||||
function canArchiveManagedSubagent(
|
function canArchiveManagedSubagent(
|
||||||
agent: ManagedSubagentSnapshot | undefined,
|
agent: ManagedSubagentSnapshot | undefined,
|
||||||
parentAgentId: string,
|
parent: AgentAddress,
|
||||||
): boolean {
|
): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
agent &&
|
agent &&
|
||||||
|
agent.freshness === "current" &&
|
||||||
|
agent.phase === "attached" &&
|
||||||
!agent.archivedAt &&
|
!agent.archivedAt &&
|
||||||
agent.parentAgentId === parentAgentId &&
|
agent.parent.serverId === parent.serverId &&
|
||||||
|
agent.parent.agentId === parent.agentId &&
|
||||||
(agent.status === "idle" || agent.status === "error"),
|
(agent.status === "idle" || agent.status === "error"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -55,12 +72,12 @@ function eligibleSignature(rows: readonly SubagentRow[]): string {
|
|||||||
return identitySignature(rowIdentities(rows));
|
return identitySignature(rowIdentities(rows));
|
||||||
}
|
}
|
||||||
|
|
||||||
function managedRowIdentity(id: string): string {
|
function managedRowIdentity(address: AgentAddress): string {
|
||||||
return `paseo:${id}`;
|
return JSON.stringify(["paseo", address.serverId, address.agentId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowIdentity(row: SubagentRow): string {
|
function rowIdentity(row: SubagentRow): string {
|
||||||
return row.kind === "paseo" ? managedRowIdentity(row.id) : `provider:${row.id}`;
|
return row.kind === "paseo" ? managedRowIdentity(row.address) : `provider:${row.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowIdentities(rows: readonly SubagentRow[]): Set<string> {
|
function rowIdentities(rows: readonly SubagentRow[]): Set<string> {
|
||||||
@@ -73,9 +90,9 @@ function identitySignature(ids: ReadonlySet<string>): string {
|
|||||||
|
|
||||||
function emptyOutcome(): ArchiveFinishedOutcome {
|
function emptyOutcome(): ArchiveFinishedOutcome {
|
||||||
return {
|
return {
|
||||||
archivedPaseoIds: [],
|
archivedPaseoAgents: [],
|
||||||
dismissedProviderIds: [],
|
dismissedProviderIds: [],
|
||||||
skippedPaseoIds: [],
|
skippedPaseoAgents: [],
|
||||||
failures: [],
|
failures: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -169,7 +186,7 @@ async function runArchiveFinished(
|
|||||||
reportProgress: (completedCount: number) => void,
|
reportProgress: (completedCount: number) => void,
|
||||||
): Promise<{ outcome: ArchiveFinishedOutcome; retryableFailureIds: Set<string> }> {
|
): Promise<{ outcome: ArchiveFinishedOutcome; retryableFailureIds: Set<string> }> {
|
||||||
const providerIds = rows.filter((row) => row.kind === "provider").map((row) => row.id);
|
const providerIds = rows.filter((row) => row.kind === "provider").map((row) => row.id);
|
||||||
const paseoIds = rows.filter((row) => row.kind === "paseo").map((row) => row.id);
|
const paseoAddresses = rows.filter((row) => row.kind === "paseo").map((row) => row.address);
|
||||||
let completedCount = 0;
|
let completedCount = 0;
|
||||||
const outcome = emptyOutcome();
|
const outcome = emptyOutcome();
|
||||||
const retryableFailureIds = new Set<string>();
|
const retryableFailureIds = new Set<string>();
|
||||||
@@ -181,19 +198,19 @@ async function runArchiveFinished(
|
|||||||
reportProgress(completedCount);
|
reportProgress(completedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const id of paseoIds) {
|
for (const address of paseoAddresses) {
|
||||||
if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) {
|
if (canArchiveManagedSubagent(deps.getManagedSubagent(address), deps.parent)) {
|
||||||
try {
|
try {
|
||||||
await deps.archiveManagedSubagent(id);
|
await deps.archiveManagedSubagent(address);
|
||||||
outcome.archivedPaseoIds.push(id);
|
outcome.archivedPaseoAgents.push(address);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
outcome.failures.push({ id, error });
|
outcome.failures.push({ address, error });
|
||||||
if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) {
|
if (canArchiveManagedSubagent(deps.getManagedSubagent(address), deps.parent)) {
|
||||||
retryableFailureIds.add(managedRowIdentity(id));
|
retryableFailureIds.add(managedRowIdentity(address));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
outcome.skippedPaseoIds.push(id);
|
outcome.skippedPaseoAgents.push(address);
|
||||||
}
|
}
|
||||||
completedCount += 1;
|
completedCount += 1;
|
||||||
reportProgress(completedCount);
|
reportProgress(completedCount);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function createFakeEnv(
|
|||||||
subagents.set(id, snapshot);
|
subagents.set(id, snapshot);
|
||||||
},
|
},
|
||||||
deps: {
|
deps: {
|
||||||
getSubagent: (id) => subagents.get(id),
|
getSubagent: (child) => subagents.get(child.agentId),
|
||||||
confirm: async (dialog) => {
|
confirm: async (dialog) => {
|
||||||
recordedConfirmInputs.push(dialog);
|
recordedConfirmInputs.push(dialog);
|
||||||
return options.confirmResult ?? false;
|
return options.confirmResult ?? false;
|
||||||
@@ -132,7 +132,10 @@ describe("requestArchiveSubagent", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
await requestArchiveSubagent(
|
||||||
|
{ child: { serverId: "server-1", agentId: "child-agent" } },
|
||||||
|
env.deps,
|
||||||
|
);
|
||||||
|
|
||||||
expect(env.recordedArchives).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
|
expect(env.recordedArchives).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
|
||||||
});
|
});
|
||||||
@@ -148,7 +151,10 @@ describe("requestArchiveSubagent", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
await requestArchiveSubagent(
|
||||||
|
{ child: { serverId: "server-1", agentId: "child-agent" } },
|
||||||
|
env.deps,
|
||||||
|
);
|
||||||
|
|
||||||
expect(env.recordedArchives).toEqual([]);
|
expect(env.recordedArchives).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -164,7 +170,10 @@ describe("requestArchiveSubagent", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
await requestArchiveSubagent(
|
||||||
|
{ child: { serverId: "server-1", agentId: "child-agent" } },
|
||||||
|
env.deps,
|
||||||
|
);
|
||||||
|
|
||||||
expect(env.recordedConfirmInputs).toEqual([
|
expect(env.recordedConfirmInputs).toEqual([
|
||||||
resolveArchiveSubagentDialog({ title: "Review branch", status: "running" }),
|
resolveArchiveSubagentDialog({ title: "Review branch", status: "running" }),
|
||||||
@@ -174,7 +183,7 @@ describe("requestArchiveSubagent", () => {
|
|||||||
it("asks with the missing-subagent dialog when the snapshot is unknown", async () => {
|
it("asks with the missing-subagent dialog when the snapshot is unknown", async () => {
|
||||||
const env = createFakeEnv({ confirmResult: false });
|
const env = createFakeEnv({ confirmResult: false });
|
||||||
|
|
||||||
await requestArchiveSubagent({ serverId: "server-1", subagentId: "missing" }, env.deps);
|
await requestArchiveSubagent({ child: { serverId: "server-1", agentId: "missing" } }, env.deps);
|
||||||
|
|
||||||
expect(env.recordedConfirmInputs).toEqual([
|
expect(env.recordedConfirmInputs).toEqual([
|
||||||
resolveArchiveSubagentDialog({ title: undefined, status: undefined }),
|
resolveArchiveSubagentDialog({ title: undefined, status: undefined }),
|
||||||
@@ -198,7 +207,7 @@ describe("requestArchiveSubagent", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps),
|
requestArchiveSubagent({ child: { serverId: "server-1", agentId: "child-agent" } }, env.deps),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
expect(env.recordedErrors).toEqual([error]);
|
expect(env.recordedErrors).toEqual([error]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||||
import type { Agent } from "@/stores/session-store";
|
import type { Agent } from "@/stores/session-store";
|
||||||
import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
|
import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
|
||||||
|
|
||||||
@@ -38,22 +39,21 @@ export function resolveArchiveSubagentDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ArchiveSubagentDeps {
|
export interface ArchiveSubagentDeps {
|
||||||
getSubagent: (subagentId: string) => ResolveArchiveSubagentDialogInput | undefined;
|
getSubagent: (child: AgentAddress) => ResolveArchiveSubagentDialogInput | undefined;
|
||||||
confirm: (input: ConfirmDialogInput) => Promise<boolean>;
|
confirm: (input: ConfirmDialogInput) => Promise<boolean>;
|
||||||
archiveAgent: (input: { serverId: string; agentId: string }) => Promise<void>;
|
archiveAgent: (input: AgentAddress) => Promise<void>;
|
||||||
reportError: (error: unknown) => void;
|
reportError: (error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequestArchiveSubagentInput {
|
export interface RequestArchiveSubagentInput {
|
||||||
serverId: string;
|
child: AgentAddress;
|
||||||
subagentId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestArchiveSubagent(
|
export async function requestArchiveSubagent(
|
||||||
input: RequestArchiveSubagentInput,
|
input: RequestArchiveSubagentInput,
|
||||||
deps: ArchiveSubagentDeps,
|
deps: ArchiveSubagentDeps,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const subagent = deps.getSubagent(input.subagentId);
|
const subagent = deps.getSubagent(input.child);
|
||||||
const confirmed = await deps.confirm(
|
const confirmed = await deps.confirm(
|
||||||
resolveArchiveSubagentDialog({
|
resolveArchiveSubagentDialog({
|
||||||
title: subagent?.title,
|
title: subagent?.title,
|
||||||
@@ -64,7 +64,7 @@ export async function requestArchiveSubagent(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await deps.archiveAgent({ serverId: input.serverId, agentId: input.subagentId });
|
await deps.archiveAgent(input.child);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.reportError(error);
|
deps.reportError(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
|
import { managedAgentFields } from "@/subagents/test-fixtures";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveCloseAgentTabPolicy } from "./close-tab-policy";
|
import { resolveCloseAgentTabPolicy } from "./close-tab-policy";
|
||||||
|
|
||||||
describe("resolveCloseAgentTabPolicy", () => {
|
describe("resolveCloseAgentTabPolicy", () => {
|
||||||
it("archives root agents when their tab closes", () => {
|
it("archives root agents when their tab closes", () => {
|
||||||
expect(resolveCloseAgentTabPolicy({ parentAgentId: null })).toEqual({
|
expect(resolveCloseAgentTabPolicy({ managedRelationship: null })).toEqual({
|
||||||
kind: "archive-on-close",
|
kind: "archive-on-close",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps subagent tab close layout-only", () => {
|
it("keeps subagent tab close layout-only", () => {
|
||||||
expect(resolveCloseAgentTabPolicy({ parentAgentId: "parent-agent" })).toEqual({
|
expect(
|
||||||
|
resolveCloseAgentTabPolicy(
|
||||||
|
managedAgentFields({ serverId: "server-1", agentId: "parent-agent" }),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
kind: "layout-only",
|
kind: "layout-only",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import type { Agent } from "@/stores/session-store";
|
|||||||
export type CloseAgentTabPolicy = { kind: "archive-on-close" } | { kind: "layout-only" };
|
export type CloseAgentTabPolicy = { kind: "archive-on-close" } | { kind: "layout-only" };
|
||||||
|
|
||||||
export function resolveCloseAgentTabPolicy(
|
export function resolveCloseAgentTabPolicy(
|
||||||
agent: Pick<Agent, "parentAgentId"> | null | undefined,
|
agent: Pick<Agent, "managedRelationship"> | null | undefined,
|
||||||
): CloseAgentTabPolicy {
|
): CloseAgentTabPolicy {
|
||||||
if (agent?.parentAgentId) {
|
if (agent?.managedRelationship) {
|
||||||
return { kind: "layout-only" };
|
return { kind: "layout-only" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,14 +46,16 @@ function createFakeEnv(
|
|||||||
recordedConfirmInputs,
|
recordedConfirmInputs,
|
||||||
recordedErrors,
|
recordedErrors,
|
||||||
deps: {
|
deps: {
|
||||||
getSubagent: (id) => subagents.get(id),
|
getSubagent: (child) => subagents.get(child.agentId),
|
||||||
confirm: async (dialog) => {
|
confirm: async (dialog) => {
|
||||||
recordedConfirmInputs.push(dialog);
|
recordedConfirmInputs.push(dialog);
|
||||||
return options.confirmResult ?? false;
|
return options.confirmResult ?? false;
|
||||||
},
|
},
|
||||||
detachAgent: async (input) => {
|
detachAgent: async (input) => {
|
||||||
recordedDetaches.push(input);
|
recordedDetaches.push(input);
|
||||||
|
return { phase: "detached" };
|
||||||
},
|
},
|
||||||
|
reportPending: () => undefined,
|
||||||
openDetachedAgent: (input) => {
|
openDetachedAgent: (input) => {
|
||||||
recordedOpens.push(input);
|
recordedOpens.push(input);
|
||||||
},
|
},
|
||||||
@@ -93,7 +95,10 @@ describe("requestDetachSubagent", () => {
|
|||||||
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
await requestDetachSubagent(
|
||||||
|
{ child: { serverId: "server-1", agentId: "child-agent" } },
|
||||||
|
env.deps,
|
||||||
|
);
|
||||||
|
|
||||||
expect(env.recordedDetaches).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
|
expect(env.recordedDetaches).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
|
||||||
});
|
});
|
||||||
@@ -104,7 +109,10 @@ describe("requestDetachSubagent", () => {
|
|||||||
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
await requestDetachSubagent(
|
||||||
|
{ child: { serverId: "server-1", agentId: "child-agent" } },
|
||||||
|
env.deps,
|
||||||
|
);
|
||||||
|
|
||||||
expect(env.recordedOpens).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
|
expect(env.recordedOpens).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
|
||||||
});
|
});
|
||||||
@@ -115,7 +123,10 @@ describe("requestDetachSubagent", () => {
|
|||||||
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
await requestDetachSubagent(
|
||||||
|
{ child: { serverId: "server-1", agentId: "child-agent" } },
|
||||||
|
env.deps,
|
||||||
|
);
|
||||||
|
|
||||||
expect(env.recordedDetaches).toEqual([]);
|
expect(env.recordedDetaches).toEqual([]);
|
||||||
expect(env.recordedOpens).toEqual([]);
|
expect(env.recordedOpens).toEqual([]);
|
||||||
@@ -127,7 +138,10 @@ describe("requestDetachSubagent", () => {
|
|||||||
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
await requestDetachSubagent(
|
||||||
|
{ child: { serverId: "server-1", agentId: "child-agent" } },
|
||||||
|
env.deps,
|
||||||
|
);
|
||||||
|
|
||||||
expect(env.recordedConfirmInputs).toEqual([
|
expect(env.recordedConfirmInputs).toEqual([
|
||||||
resolveDetachSubagentDialog({ title: "Review branch" }),
|
resolveDetachSubagentDialog({ title: "Review branch" }),
|
||||||
@@ -145,9 +159,25 @@ describe("requestDetachSubagent", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps),
|
requestDetachSubagent({ child: { serverId: "server-1", agentId: "child-agent" } }, env.deps),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
expect(env.recordedErrors).toEqual([error]);
|
expect(env.recordedErrors).toEqual([error]);
|
||||||
expect(env.recordedOpens).toEqual([]);
|
expect(env.recordedOpens).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retains pending detach without opening a standalone conversation", async () => {
|
||||||
|
const env = createFakeEnv({ confirmResult: true });
|
||||||
|
let pending = 0;
|
||||||
|
env.deps.detachAgent = async () => ({ phase: "detach-pending" });
|
||||||
|
env.deps.reportPending = () => {
|
||||||
|
pending += 1;
|
||||||
|
};
|
||||||
|
await requestDetachSubagent(
|
||||||
|
{ child: { serverId: "server-2", agentId: "child-agent" } },
|
||||||
|
env.deps,
|
||||||
|
);
|
||||||
|
expect(pending).toBe(1);
|
||||||
|
expect(env.recordedOpens).toEqual([]);
|
||||||
|
expect(env.recordedErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||||
import type { Agent } from "@/stores/session-store";
|
import type { Agent } from "@/stores/session-store";
|
||||||
import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
|
import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
|
||||||
|
|
||||||
@@ -34,23 +35,23 @@ export function resolveDetachSubagentDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DetachSubagentDeps {
|
export interface DetachSubagentDeps {
|
||||||
getSubagent: (subagentId: string) => ResolveDetachSubagentDialogInput | undefined;
|
getSubagent: (child: AgentAddress) => ResolveDetachSubagentDialogInput | undefined;
|
||||||
confirm: (input: ConfirmDialogInput) => Promise<boolean>;
|
confirm: (input: ConfirmDialogInput) => Promise<boolean>;
|
||||||
detachAgent: (input: { serverId: string; agentId: string }) => Promise<void>;
|
detachAgent: (input: AgentAddress) => Promise<{ phase: string }>;
|
||||||
openDetachedAgent: (input: { serverId: string; agentId: string }) => void;
|
reportPending: () => void;
|
||||||
|
openDetachedAgent: (input: AgentAddress) => void;
|
||||||
reportError: (error: unknown) => void;
|
reportError: (error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequestDetachSubagentInput {
|
export interface RequestDetachSubagentInput {
|
||||||
serverId: string;
|
child: AgentAddress;
|
||||||
subagentId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestDetachSubagent(
|
export async function requestDetachSubagent(
|
||||||
input: RequestDetachSubagentInput,
|
input: RequestDetachSubagentInput,
|
||||||
deps: DetachSubagentDeps,
|
deps: DetachSubagentDeps,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const subagent = deps.getSubagent(input.subagentId);
|
const subagent = deps.getSubagent(input.child);
|
||||||
const confirmed = await deps.confirm(
|
const confirmed = await deps.confirm(
|
||||||
resolveDetachSubagentDialog({
|
resolveDetachSubagentDialog({
|
||||||
title: subagent?.title,
|
title: subagent?.title,
|
||||||
@@ -60,8 +61,9 @@ export async function requestDetachSubagent(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await deps.detachAgent({ serverId: input.serverId, agentId: input.subagentId });
|
const result = await deps.detachAgent(input.child);
|
||||||
deps.openDetachedAgent({ serverId: input.serverId, agentId: input.subagentId });
|
if (result.phase === "detached") deps.openDetachedAgent(input.child);
|
||||||
|
else deps.reportPending();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.reportError(error);
|
deps.reportError(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { createPaseoApi, type PaseoApi, type PaseoAgentHandle } from "@getpaseo/client";
|
||||||
|
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||||
|
import type { SubagentDirectory, SubagentRelationship } from "@getpaseo/protocol/messages";
|
||||||
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
|
||||||
|
export interface ManagedSubagentState {
|
||||||
|
readonly parent: AgentAddress;
|
||||||
|
readonly rows: readonly SubagentRelationship[];
|
||||||
|
readonly status: "loading" | "current" | "unavailable";
|
||||||
|
readonly error: string | null;
|
||||||
|
readonly archiving: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedSubagentDirectory {
|
||||||
|
getSnapshot(): ManagedSubagentState;
|
||||||
|
subscribe(changed: () => void): () => void;
|
||||||
|
archive(child: AgentAddress): Promise<void>;
|
||||||
|
detach(child: AgentAddress): Promise<SubagentRelationship>;
|
||||||
|
forget(child: AgentAddress): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MembershipRuntime {
|
||||||
|
getClient(serverId: string): DaemonClient | null;
|
||||||
|
getSnapshot(serverId: string): { connectionStatus: string } | null;
|
||||||
|
subscribe(serverId: string, changed: () => void): () => void;
|
||||||
|
withAgent<T>(
|
||||||
|
address: AgentAddress,
|
||||||
|
operation: (agent: PaseoAgentHandle) => Promise<T>,
|
||||||
|
): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retains authoritative membership for the runtime, independently of view mounting. */
|
||||||
|
export class ManagedSubagents {
|
||||||
|
private readonly parents = new Map<string, ParentMembership>();
|
||||||
|
constructor(private readonly runtime: MembershipRuntime) {}
|
||||||
|
|
||||||
|
directory(parent: AgentAddress): ManagedSubagentDirectory {
|
||||||
|
const key = JSON.stringify([parent.serverId, parent.agentId]);
|
||||||
|
let directory = this.parents.get(key);
|
||||||
|
if (!directory) {
|
||||||
|
directory = new ParentMembership(parent, this.runtime);
|
||||||
|
this.parents.set(key, directory);
|
||||||
|
}
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseServer(serverId: string): Promise<void> {
|
||||||
|
const releasing: Promise<void>[] = [];
|
||||||
|
for (const [key, parent] of this.parents) {
|
||||||
|
if (parent.getSnapshot().parent.serverId !== serverId) continue;
|
||||||
|
this.parents.delete(key);
|
||||||
|
releasing.push(parent.close());
|
||||||
|
}
|
||||||
|
await Promise.allSettled(releasing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParentMembership implements ManagedSubagentDirectory {
|
||||||
|
private state: ManagedSubagentState;
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
private stopConnection: (() => void) | null = null;
|
||||||
|
private client: DaemonClient | null = null;
|
||||||
|
private api: PaseoApi | null = null;
|
||||||
|
private generation = 0;
|
||||||
|
private closed = false;
|
||||||
|
private readonly releasing = new Set<Promise<unknown>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly parent: AgentAddress,
|
||||||
|
private readonly runtime: MembershipRuntime,
|
||||||
|
) {
|
||||||
|
this.state = { parent, rows: [], status: "loading", error: null, archiving: new Set() };
|
||||||
|
}
|
||||||
|
getSnapshot = (): ManagedSubagentState => this.state;
|
||||||
|
subscribe = (changed: () => void): (() => void) => {
|
||||||
|
this.listeners.add(changed);
|
||||||
|
if (!this.stopConnection && !this.closed) {
|
||||||
|
this.stopConnection = this.runtime.subscribe(this.parent.serverId, this.syncConnection);
|
||||||
|
this.syncConnection();
|
||||||
|
}
|
||||||
|
// The runtime owns observation/recovery; unmounting a view only removes its listener.
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(changed);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
private publish(state: ManagedSubagentState): void {
|
||||||
|
this.state = state;
|
||||||
|
for (const changed of this.listeners) changed();
|
||||||
|
}
|
||||||
|
private unavailable(error: string): void {
|
||||||
|
this.publish({
|
||||||
|
...this.state,
|
||||||
|
status: "unavailable",
|
||||||
|
error,
|
||||||
|
rows: this.state.rows.map((row) => ({ ...row, freshness: "unavailable" })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private retireApi(): void {
|
||||||
|
if (!this.api) return;
|
||||||
|
const releasing = this.api.dispose();
|
||||||
|
this.api = null;
|
||||||
|
this.releasing.add(releasing);
|
||||||
|
void releasing.finally(() => this.releasing.delete(releasing)).catch(() => {});
|
||||||
|
}
|
||||||
|
private syncConnection = (): void => {
|
||||||
|
if (this.closed) return;
|
||||||
|
const client = this.runtime.getClient(this.parent.serverId);
|
||||||
|
if (client !== this.client) {
|
||||||
|
this.generation++;
|
||||||
|
this.retireApi();
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
if (!client || this.runtime.getSnapshot(this.parent.serverId)?.connectionStatus !== "online") {
|
||||||
|
this.unavailable("Parent authority is unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.api) return;
|
||||||
|
const generation = this.generation;
|
||||||
|
const accept = (directory: SubagentDirectory) => {
|
||||||
|
if (this.closed || generation !== this.generation) return;
|
||||||
|
if (
|
||||||
|
directory.parent.serverId !== this.parent.serverId ||
|
||||||
|
directory.parent.agentId !== this.parent.agentId
|
||||||
|
)
|
||||||
|
throw new Error("Subagent directory does not match its owning parent");
|
||||||
|
this.publish({
|
||||||
|
...this.state,
|
||||||
|
parent: this.parent,
|
||||||
|
rows: directory.rows,
|
||||||
|
status: "current",
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
this.api = createPaseoApi(client);
|
||||||
|
const observation = this.api.agents.ref(this.parent.agentId).subagents.observe();
|
||||||
|
observation.subscribe({
|
||||||
|
snapshot: accept,
|
||||||
|
update: (message) => {
|
||||||
|
if (message.type === "agent.subagents.changed") accept(message.payload.directory);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
if (!this.closed && generation === this.generation)
|
||||||
|
this.unavailable(
|
||||||
|
error instanceof Error ? error.message : "Subagent membership is unavailable",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
void observation.ready.catch((error) => {
|
||||||
|
if (!this.closed && generation === this.generation)
|
||||||
|
this.unavailable(
|
||||||
|
error instanceof Error ? error.message : "Subagent membership is unavailable",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.retireApi();
|
||||||
|
this.unavailable(
|
||||||
|
error instanceof Error ? error.message : "Subagent membership is unavailable",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
private requireMembership(child: AgentAddress): SubagentRelationship {
|
||||||
|
if (this.closed || !this.api || this.state.status !== "current")
|
||||||
|
throw new Error("Parent authority is unavailable");
|
||||||
|
const row = this.state.rows.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.child.serverId === child.serverId && candidate.child.agentId === child.agentId,
|
||||||
|
);
|
||||||
|
if (!row || ["forgotten", "archived", "detached", "canceled"].includes(row.phase))
|
||||||
|
throw new Error("Subagent membership is no longer attached");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
archive = async (child: AgentAddress): Promise<void> => {
|
||||||
|
this.requireMembership(child);
|
||||||
|
const key = JSON.stringify([child.serverId, child.agentId]);
|
||||||
|
if (this.state.archiving.has(key)) return;
|
||||||
|
this.publish({ ...this.state, archiving: new Set([...this.state.archiving, key]) });
|
||||||
|
try {
|
||||||
|
await this.runtime.withAgent(child, (agent) => agent.archive());
|
||||||
|
} finally {
|
||||||
|
const archiving = new Set(this.state.archiving);
|
||||||
|
archiving.delete(key);
|
||||||
|
this.publish({ ...this.state, archiving });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
detach = async (child: AgentAddress): Promise<SubagentRelationship> => {
|
||||||
|
this.requireMembership(child);
|
||||||
|
return this.api!.agents.ref(this.parent.agentId).subagents.detach(child);
|
||||||
|
};
|
||||||
|
forget = async (child: AgentAddress): Promise<void> => {
|
||||||
|
this.requireMembership(child);
|
||||||
|
await this.api!.agents.ref(this.parent.agentId).subagents.forget(child);
|
||||||
|
};
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
this.generation++;
|
||||||
|
this.stopConnection?.();
|
||||||
|
this.retireApi();
|
||||||
|
this.listeners.clear();
|
||||||
|
await Promise.allSettled(this.releasing);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { managedAgentFields } from "@/subagents/test-fixtures";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { Agent } from "@/stores/session-store";
|
import type { Agent } from "@/stores/session-store";
|
||||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||||
@@ -12,7 +13,9 @@ function tab(agentId: string, createdAt: number): WorkspaceTab {
|
|||||||
function agent(input: { id: string; parentAgentId?: string | null; open?: boolean }): Agent {
|
function agent(input: { id: string; parentAgentId?: string | null; open?: boolean }): Agent {
|
||||||
return {
|
return {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
parentAgentId: input.parentAgentId ?? null,
|
...managedAgentFields(
|
||||||
|
input.parentAgentId ? { serverId: "server-1", agentId: input.parentAgentId } : null,
|
||||||
|
),
|
||||||
labels: input.open ? { [label]: "true" } : {},
|
labels: input.open ? { [label]: "true" } : {},
|
||||||
} as Agent;
|
} as Agent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function getAgentTabsNeedingOpenLabel(input: {
|
|||||||
}
|
}
|
||||||
const agent = input.getAgent(tab.target.agentId);
|
const agent = input.getAgent(tab.target.agentId);
|
||||||
if (
|
if (
|
||||||
agent?.parentAgentId &&
|
agent?.managedRelationship &&
|
||||||
agent.labels[input.label] !== "true" &&
|
agent.labels[input.label] !== "true" &&
|
||||||
!input.pendingAgentIds.has(agent.id)
|
!input.pendingAgentIds.has(agent.id)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
|||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { useSubagentOpensInSheet } from "./opens-in-sheet";
|
import { useSubagentOpensInSheet } from "./opens-in-sheet";
|
||||||
import { selectSubagentsForParent, type SubagentRow } from "./select";
|
import { selectSubagentsForParent, type SubagentRow } from "./select";
|
||||||
|
import { managedAgentFields, membershipFixture, relationshipFixture } from "./test-fixtures";
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||||
const storage = new Map<string, string>();
|
const storage = new Map<string, string>();
|
||||||
@@ -64,7 +65,8 @@ const AGENT_DEFAULTS: Agent = {
|
|||||||
attentionReason: null,
|
attentionReason: null,
|
||||||
attentionTimestamp: null,
|
attentionTimestamp: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
parentAgentId: null,
|
parent: null,
|
||||||
|
managedRelationship: null,
|
||||||
labels: {},
|
labels: {},
|
||||||
projectPlacement: null,
|
projectPlacement: null,
|
||||||
};
|
};
|
||||||
@@ -77,7 +79,12 @@ if (!workspaceKey) throw new Error("workspace key");
|
|||||||
|
|
||||||
function seedParentWithChild(): SubagentRow {
|
function seedParentWithChild(): SubagentRow {
|
||||||
const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" };
|
const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" };
|
||||||
const child: Agent = { ...AGENT_DEFAULTS, id: "child", title: "Child", parentAgentId: "parent" };
|
const child: Agent = {
|
||||||
|
...AGENT_DEFAULTS,
|
||||||
|
id: "child",
|
||||||
|
title: "Child",
|
||||||
|
...managedAgentFields({ serverId: SERVER_ID, agentId: "parent" }),
|
||||||
|
};
|
||||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||||
useSessionStore.getState().setAgents(
|
useSessionStore.getState().setAgents(
|
||||||
SERVER_ID,
|
SERVER_ID,
|
||||||
@@ -87,8 +94,10 @@ function seedParentWithChild(): SubagentRow {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const [row] = selectSubagentsForParent(
|
const [row] = selectSubagentsForParent(
|
||||||
useSessionStore.getState(),
|
membershipFixture({ serverId: SERVER_ID, agentId: "parent" }, [
|
||||||
{ serverId: SERVER_ID, parentAgentId: "parent" },
|
relationshipFixture({ serverId: SERVER_ID, agentId: "parent" }, child),
|
||||||
|
]),
|
||||||
|
{ originServerId: SERVER_ID, serverId: SERVER_ID },
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
if (!row) throw new Error("child row");
|
if (!row) throw new Error("child row");
|
||||||
@@ -114,7 +123,11 @@ describe("useSubagentOpensInSheet", () => {
|
|||||||
const target = { kind: "agent", agentId: "child" } as const;
|
const target = { kind: "agent", agentId: "child" } as const;
|
||||||
|
|
||||||
// The row renders its answer, as the track does, so a stale render is a stale answer.
|
// The row renders its answer, as the track does, so a stale render is a stale answer.
|
||||||
const { result } = renderHook(() => useSubagentOpensInSheet(workspaceKey)(row));
|
const { result } = renderHook(() =>
|
||||||
|
useSubagentOpensInSheet(workspaceKey, { serverId: SERVER_ID, workspaceId: WORKSPACE_ID })(
|
||||||
|
row,
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(result.current).toBe(true);
|
expect(result.current).toBe(true);
|
||||||
|
|
||||||
let tabId: string | null = null;
|
let tabId: string | null = null;
|
||||||
|
|||||||
@@ -1,18 +1,36 @@
|
|||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import {
|
||||||
|
useWorkspaceLayoutStore,
|
||||||
|
useWorkspaceLayoutStoreApi,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import type { SubagentRow } from "./select";
|
import type { SubagentRow } from "./select";
|
||||||
|
import type { WorkspaceAddress } from "@getpaseo/protocol/addresses";
|
||||||
|
|
||||||
|
export function subagentRowBelongsToWorkspace(
|
||||||
|
row: SubagentRow,
|
||||||
|
workspace: WorkspaceAddress,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
row.kind === "provider" ||
|
||||||
|
(row.workspace !== null &&
|
||||||
|
row.workspace.serverId === workspace.serverId &&
|
||||||
|
row.workspace.workspaceId === workspace.workspaceId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The workspace target a subagent row opens. */
|
/** The workspace target a subagent row opens. */
|
||||||
export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget {
|
export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget {
|
||||||
return row.kind === "provider"
|
return row.kind === "provider"
|
||||||
? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id }
|
? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id }
|
||||||
: { kind: "agent", agentId: row.id };
|
: { kind: "agent", agentId: row.address.agentId };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */
|
/** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */
|
||||||
export function useSubagentOpensInSheet(
|
export function useSubagentOpensInSheet(
|
||||||
workspaceKey: string | null,
|
workspaceKey: string | null,
|
||||||
|
workspace: WorkspaceAddress,
|
||||||
): (row: SubagentRow) => boolean {
|
): (row: SubagentRow) => boolean {
|
||||||
|
const layout = useWorkspaceLayoutStoreApi();
|
||||||
// The rows render the answer, so this subscribes to what the answer reads: the layout mode and
|
// The rows render the answer, so this subscribes to what the answer reads: the layout mode and
|
||||||
// the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet,
|
// the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet,
|
||||||
// and a row rendered while that tab existed must re-render once it closes, or choosing the row
|
// and a row rendered while that tab existed must re-render once it closes, or choosing the row
|
||||||
@@ -22,8 +40,9 @@ export function useSubagentOpensInSheet(
|
|||||||
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
||||||
);
|
);
|
||||||
return (row) =>
|
return (row) =>
|
||||||
|
subagentRowBelongsToWorkspace(row, workspace) &&
|
||||||
workspaceKey !== null &&
|
workspaceKey !== null &&
|
||||||
useWorkspaceLayoutStore
|
layout
|
||||||
.getState()
|
.getState()
|
||||||
.opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" });
|
.opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
|||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
|
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||||
import {
|
import {
|
||||||
|
createProviderSubagentStore,
|
||||||
observeProviderSubagentTimeline,
|
observeProviderSubagentTimeline,
|
||||||
providerSubagentKey,
|
providerSubagentKey,
|
||||||
refreshProviderSubagents,
|
refreshProviderSubagents,
|
||||||
@@ -824,3 +825,59 @@ describe("resyncing after the update feed resubscribes", () => {
|
|||||||
expect(daemon.requested).toEqual([PARENT_ID]);
|
expect(daemon.requested).toEqual([PARENT_ID]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("isolates equal provider identities and in-flight list ownership between authority bindings", async () => {
|
||||||
|
const direct = createProviderSubagentStore();
|
||||||
|
const routed = createProviderSubagentStore();
|
||||||
|
const pending: Array<
|
||||||
|
(value: {
|
||||||
|
requestId: string;
|
||||||
|
parentAgentId: string;
|
||||||
|
error: null;
|
||||||
|
subagents: ProviderSubagentDescriptorPayload[];
|
||||||
|
}) => void
|
||||||
|
> = [];
|
||||||
|
const client = {
|
||||||
|
listProviderSubagents: () =>
|
||||||
|
new Promise<{
|
||||||
|
requestId: string;
|
||||||
|
parentAgentId: string;
|
||||||
|
error: null;
|
||||||
|
subagents: ProviderSubagentDescriptorPayload[];
|
||||||
|
}>((resolve) => pending.push(resolve)),
|
||||||
|
};
|
||||||
|
const descriptor: ProviderSubagentDescriptorPayload = {
|
||||||
|
id: SUBAGENT_ID,
|
||||||
|
parentAgentId: PARENT_ID,
|
||||||
|
provider: "codex",
|
||||||
|
title: "Scoped",
|
||||||
|
description: null,
|
||||||
|
status: "completed",
|
||||||
|
createdAt: "2026-10-20T10:00:00Z",
|
||||||
|
updatedAt: "2026-10-20T10:00:00Z",
|
||||||
|
toolCallId: null,
|
||||||
|
};
|
||||||
|
const directRequest = refreshProviderSubagents(client, SERVER_ID, PARENT_ID, direct);
|
||||||
|
const routedRequest = refreshProviderSubagents(client, SERVER_ID, PARENT_ID, routed);
|
||||||
|
expect(pending).toHaveLength(2);
|
||||||
|
pending[0]!({
|
||||||
|
requestId: "direct",
|
||||||
|
parentAgentId: PARENT_ID,
|
||||||
|
error: null,
|
||||||
|
subagents: [{ ...descriptor, title: "Direct private title" }],
|
||||||
|
});
|
||||||
|
await directRequest;
|
||||||
|
expect(routed.getState().descriptors.size).toBe(0);
|
||||||
|
pending[1]!({
|
||||||
|
requestId: "routed",
|
||||||
|
parentAgentId: PARENT_ID,
|
||||||
|
error: null,
|
||||||
|
subagents: [descriptor],
|
||||||
|
});
|
||||||
|
await routedRequest;
|
||||||
|
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
|
||||||
|
expect(routed.getState().descriptors.get(key)?.title).toBe("Scoped");
|
||||||
|
routed.getState().hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]);
|
||||||
|
expect(direct.getState().hiddenFromTrack.size).toBe(0);
|
||||||
|
expect(direct.getState().descriptors.get(key)?.title).toBe("Direct private title");
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type {
|
|||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
import { DaemonConnectionError, type DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import { DaemonConnectionError, type DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { create } from "zustand";
|
import { create, useStore } from "zustand";
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
import { applyStreamEvent } from "@/types/stream";
|
import { applyStreamEvent } from "@/types/stream";
|
||||||
import {
|
import {
|
||||||
processTimelineResponse,
|
processTimelineResponse,
|
||||||
@@ -71,27 +72,36 @@ export function providerSubagentLifecycleStatus(
|
|||||||
|
|
||||||
type ProviderSubagentListClient = Pick<DaemonClient, "listProviderSubagents">;
|
type ProviderSubagentListClient = Pick<DaemonClient, "listProviderSubagents">;
|
||||||
|
|
||||||
const pendingListRequests = new WeakMap<ProviderSubagentListClient, Map<string, Promise<void>>>();
|
const pendingListRequests = new WeakMap<
|
||||||
|
ProviderSubagentStoreApi,
|
||||||
|
WeakMap<ProviderSubagentListClient, Map<string, Promise<void>>>
|
||||||
|
>();
|
||||||
|
|
||||||
export function refreshProviderSubagents(
|
export function refreshProviderSubagents(
|
||||||
client: ProviderSubagentListClient,
|
client: ProviderSubagentListClient,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
parentAgentId: string,
|
parentAgentId: string,
|
||||||
|
store: ProviderSubagentStoreApi = defaultProviderSubagentStore,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const requestKey = `${serverId}\0${parentAgentId}`;
|
const requestKey = `${serverId}\0${parentAgentId}`;
|
||||||
let clientRequests = pendingListRequests.get(client);
|
let requests = pendingListRequests.get(store);
|
||||||
|
if (!requests) {
|
||||||
|
requests = new WeakMap();
|
||||||
|
pendingListRequests.set(store, requests);
|
||||||
|
}
|
||||||
|
let clientRequests = requests.get(client);
|
||||||
if (!clientRequests) {
|
if (!clientRequests) {
|
||||||
clientRequests = new Map();
|
clientRequests = new Map();
|
||||||
pendingListRequests.set(client, clientRequests);
|
requests.set(client, clientRequests);
|
||||||
}
|
}
|
||||||
const pending = clientRequests.get(requestKey);
|
const pending = clientRequests.get(requestKey);
|
||||||
if (pending) return pending;
|
if (pending) return pending;
|
||||||
|
|
||||||
useProviderSubagentStore.getState().trackParent(serverId, parentAgentId);
|
store.getState().trackParent(serverId, parentAgentId);
|
||||||
const request = client
|
const request = client
|
||||||
.listProviderSubagents(parentAgentId)
|
.listProviderSubagents(parentAgentId)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
useProviderSubagentStore.getState().replaceList(serverId, parentAgentId, payload.subagents);
|
store.getState().replaceList(serverId, parentAgentId, payload.subagents);
|
||||||
return undefined;
|
return undefined;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -113,15 +123,16 @@ function parentPrefix(serverId: string, parentAgentId: string): string {
|
|||||||
export async function resyncProviderSubagents(
|
export async function resyncProviderSubagents(
|
||||||
client: ProviderSubagentListClient,
|
client: ProviderSubagentListClient,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
|
store: ProviderSubagentStoreApi = defaultProviderSubagentStore,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const serverPrefix = `${serverId}\0`;
|
const serverPrefix = `${serverId}\0`;
|
||||||
const parentAgentIds = [...useProviderSubagentStore.getState().trackedParents]
|
const parentAgentIds = [...store.getState().trackedParents]
|
||||||
.filter((key) => key.startsWith(serverPrefix))
|
.filter((key) => key.startsWith(serverPrefix))
|
||||||
.map((key) => key.slice(serverPrefix.length, -1));
|
.map((key) => key.slice(serverPrefix.length, -1));
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
parentAgentIds.map((parentAgentId) =>
|
parentAgentIds.map((parentAgentId) =>
|
||||||
// A parent that was archived meanwhile has nothing left to catch up.
|
// A parent that was archived meanwhile has nothing left to catch up.
|
||||||
refreshProviderSubagents(client, serverId, parentAgentId).catch(() => undefined),
|
refreshProviderSubagents(client, serverId, parentAgentId, store).catch(() => undefined),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -168,195 +179,215 @@ function settleTimeline(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useProviderSubagentStore = create<ProviderSubagentState>((set) => ({
|
export const createProviderSubagentStore = () =>
|
||||||
descriptors: new Map(),
|
create<ProviderSubagentState>((set) => ({
|
||||||
timelines: new Map(),
|
descriptors: new Map(),
|
||||||
hiddenFromTrack: new Set(),
|
timelines: new Map(),
|
||||||
trackedParents: new Set(),
|
hiddenFromTrack: new Set(),
|
||||||
trackParent(serverId, parentAgentId) {
|
trackedParents: new Set(),
|
||||||
set((state) => {
|
trackParent(serverId, parentAgentId) {
|
||||||
const prefix = parentPrefix(serverId, parentAgentId);
|
set((state) => {
|
||||||
if (state.trackedParents.has(prefix)) return state;
|
const prefix = parentPrefix(serverId, parentAgentId);
|
||||||
return { trackedParents: new Set(state.trackedParents).add(prefix) };
|
if (state.trackedParents.has(prefix)) return state;
|
||||||
});
|
return { trackedParents: new Set(state.trackedParents).add(prefix) };
|
||||||
},
|
});
|
||||||
hideFromTrack(serverId, parentAgentId, subagentIds) {
|
},
|
||||||
set((state) => {
|
hideFromTrack(serverId, parentAgentId, subagentIds) {
|
||||||
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
set((state) => {
|
||||||
for (const subagentId of subagentIds) {
|
|
||||||
const key = providerSubagentKey(serverId, parentAgentId, subagentId);
|
|
||||||
if (state.descriptors.get(key)?.status !== "running") hiddenFromTrack.add(key);
|
|
||||||
}
|
|
||||||
return { hiddenFromTrack };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
replaceList(serverId, parentAgentId, subagents) {
|
|
||||||
set((state) => {
|
|
||||||
const prefix = parentPrefix(serverId, parentAgentId);
|
|
||||||
const descriptors = new Map(
|
|
||||||
[...state.descriptors].filter(([key]) => !key.startsWith(prefix)),
|
|
||||||
);
|
|
||||||
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
|
||||||
for (const subagent of subagents) {
|
|
||||||
const key = providerSubagentKey(serverId, parentAgentId, subagent.id);
|
|
||||||
descriptors.set(key, subagent);
|
|
||||||
if (subagent.status === "running") {
|
|
||||||
hiddenFromTrack.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const retainedKeys = new Set(descriptors.keys());
|
|
||||||
const timelines = new Map(
|
|
||||||
[...state.timelines].filter(([key]) => !key.startsWith(prefix) || retainedKeys.has(key)),
|
|
||||||
);
|
|
||||||
for (const subagent of subagents) {
|
|
||||||
const key = providerSubagentKey(serverId, parentAgentId, subagent.id);
|
|
||||||
const current = timelines.get(key);
|
|
||||||
const previous = state.descriptors.get(key);
|
|
||||||
if (current && previous?.status !== subagent.status) {
|
|
||||||
timelines.set(key, settleTimeline(current, subagent));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { descriptors, timelines, hiddenFromTrack };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
applyUpdate(serverId, payload) {
|
|
||||||
set((state) => {
|
|
||||||
if (payload.kind === "upsert") {
|
|
||||||
const key = providerSubagentKey(
|
|
||||||
serverId,
|
|
||||||
payload.subagent.parentAgentId,
|
|
||||||
payload.subagent.id,
|
|
||||||
);
|
|
||||||
const descriptors = new Map(state.descriptors);
|
|
||||||
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
||||||
const previous = descriptors.get(key);
|
for (const subagentId of subagentIds) {
|
||||||
descriptors.set(key, payload.subagent);
|
const key = providerSubagentKey(serverId, parentAgentId, subagentId);
|
||||||
if (payload.subagent.status === "running") {
|
if (state.descriptors.get(key)?.status !== "running") hiddenFromTrack.add(key);
|
||||||
hiddenFromTrack.delete(key);
|
|
||||||
}
|
}
|
||||||
let timelines = state.timelines;
|
return { hiddenFromTrack };
|
||||||
const current = state.timelines.get(key);
|
});
|
||||||
if (current && previous?.status !== payload.subagent.status) {
|
},
|
||||||
timelines = new Map(state.timelines);
|
replaceList(serverId, parentAgentId, subagents) {
|
||||||
timelines.set(key, settleTimeline(current, payload.subagent));
|
set((state) => {
|
||||||
|
const prefix = parentPrefix(serverId, parentAgentId);
|
||||||
|
const descriptors = new Map(
|
||||||
|
[...state.descriptors].filter(([key]) => !key.startsWith(prefix)),
|
||||||
|
);
|
||||||
|
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
||||||
|
for (const subagent of subagents) {
|
||||||
|
const key = providerSubagentKey(serverId, parentAgentId, subagent.id);
|
||||||
|
descriptors.set(key, subagent);
|
||||||
|
if (subagent.status === "running") {
|
||||||
|
hiddenFromTrack.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const retainedKeys = new Set(descriptors.keys());
|
||||||
|
const timelines = new Map(
|
||||||
|
[...state.timelines].filter(([key]) => !key.startsWith(prefix) || retainedKeys.has(key)),
|
||||||
|
);
|
||||||
|
for (const subagent of subagents) {
|
||||||
|
const key = providerSubagentKey(serverId, parentAgentId, subagent.id);
|
||||||
|
const current = timelines.get(key);
|
||||||
|
const previous = state.descriptors.get(key);
|
||||||
|
if (current && previous?.status !== subagent.status) {
|
||||||
|
timelines.set(key, settleTimeline(current, subagent));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { descriptors, timelines, hiddenFromTrack };
|
return { descriptors, timelines, hiddenFromTrack };
|
||||||
}
|
});
|
||||||
if (payload.kind === "remove") {
|
},
|
||||||
|
applyUpdate(serverId, payload) {
|
||||||
|
set((state) => {
|
||||||
|
if (payload.kind === "upsert") {
|
||||||
|
const key = providerSubagentKey(
|
||||||
|
serverId,
|
||||||
|
payload.subagent.parentAgentId,
|
||||||
|
payload.subagent.id,
|
||||||
|
);
|
||||||
|
const descriptors = new Map(state.descriptors);
|
||||||
|
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
||||||
|
const previous = descriptors.get(key);
|
||||||
|
descriptors.set(key, payload.subagent);
|
||||||
|
if (payload.subagent.status === "running") {
|
||||||
|
hiddenFromTrack.delete(key);
|
||||||
|
}
|
||||||
|
let timelines = state.timelines;
|
||||||
|
const current = state.timelines.get(key);
|
||||||
|
if (current && previous?.status !== payload.subagent.status) {
|
||||||
|
timelines = new Map(state.timelines);
|
||||||
|
timelines.set(key, settleTimeline(current, payload.subagent));
|
||||||
|
}
|
||||||
|
return { descriptors, timelines, hiddenFromTrack };
|
||||||
|
}
|
||||||
|
if (payload.kind === "remove") {
|
||||||
|
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
||||||
|
const descriptors = new Map(state.descriptors);
|
||||||
|
descriptors.delete(key);
|
||||||
|
const timelines = new Map(state.timelines);
|
||||||
|
timelines.delete(key);
|
||||||
|
return { descriptors, timelines };
|
||||||
|
}
|
||||||
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
||||||
const descriptors = new Map(state.descriptors);
|
const existing = state.timelines.get(key);
|
||||||
descriptors.delete(key);
|
if (existing?.epoch && existing.epoch !== payload.epoch) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const current = existing ?? EMPTY_TIMELINE;
|
||||||
|
if (payload.seq <= current.lastSeq) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const next = processAgentStreamEvent({
|
||||||
|
currentTail: current.tail,
|
||||||
|
currentHead: current.head,
|
||||||
|
currentCursor: current.cursor,
|
||||||
|
hasAuthoritativeBaseline: current.cursor !== undefined,
|
||||||
|
event: { type: "timeline", provider: payload.provider, item: payload.item },
|
||||||
|
timestamp: new Date(payload.timestamp),
|
||||||
|
seq: payload.seq,
|
||||||
|
epoch: payload.epoch,
|
||||||
|
});
|
||||||
const timelines = new Map(state.timelines);
|
const timelines = new Map(state.timelines);
|
||||||
timelines.delete(key);
|
timelines.set(
|
||||||
return { descriptors, timelines };
|
key,
|
||||||
}
|
settleTimeline(
|
||||||
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
{
|
||||||
const existing = state.timelines.get(key);
|
tail: next.tail,
|
||||||
if (existing?.epoch && existing.epoch !== payload.epoch) {
|
head: next.head,
|
||||||
return state;
|
epoch: payload.epoch,
|
||||||
}
|
lastSeq: payload.seq,
|
||||||
const current = existing ?? EMPTY_TIMELINE;
|
cursor: next.cursor ?? current.cursor,
|
||||||
if (payload.seq <= current.lastSeq) {
|
hasOlder: current.hasOlder,
|
||||||
return state;
|
needsRefresh:
|
||||||
}
|
current.needsRefresh ||
|
||||||
const next = processAgentStreamEvent({
|
next.sideEffects.some((effect) => effect.type === "catch_up"),
|
||||||
currentTail: current.tail,
|
},
|
||||||
currentHead: current.head,
|
state.descriptors.get(key),
|
||||||
currentCursor: current.cursor,
|
),
|
||||||
hasAuthoritativeBaseline: current.cursor !== undefined,
|
);
|
||||||
event: { type: "timeline", provider: payload.provider, item: payload.item },
|
return { timelines };
|
||||||
timestamp: new Date(payload.timestamp),
|
|
||||||
seq: payload.seq,
|
|
||||||
epoch: payload.epoch,
|
|
||||||
});
|
});
|
||||||
const timelines = new Map(state.timelines);
|
},
|
||||||
timelines.set(
|
replaceTimeline(serverId, payload) {
|
||||||
key,
|
const provider = payload.provider;
|
||||||
settleTimeline(
|
if (!provider) {
|
||||||
{
|
return;
|
||||||
tail: next.tail,
|
}
|
||||||
head: next.head,
|
set((state) => {
|
||||||
epoch: payload.epoch,
|
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
||||||
lastSeq: payload.seq,
|
const existing = state.timelines.get(key);
|
||||||
cursor: next.cursor ?? current.cursor,
|
const current = existing ?? EMPTY_TIMELINE;
|
||||||
hasOlder: current.hasOlder,
|
// Normalize optional wire metadata at the child timeline boundary. Legacy
|
||||||
needsRefresh:
|
// notices are complete singleton items; history always comes from a projected host.
|
||||||
current.needsRefresh || next.sideEffects.some((effect) => effect.type === "catch_up"),
|
const entries = payload.rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
provider,
|
||||||
|
seqStart: row.seqStart ?? row.seq,
|
||||||
|
seqEnd: row.seqEnd ?? row.seq,
|
||||||
|
}));
|
||||||
|
const result = processTimelineResponse({
|
||||||
|
payload: {
|
||||||
|
...payload,
|
||||||
|
agentId: payload.subagentId,
|
||||||
|
projection: "projected",
|
||||||
|
startCursor:
|
||||||
|
payload.startCursor ??
|
||||||
|
(entries.length
|
||||||
|
? { seq: Math.min(...entries.map((entry) => entry.seqStart)) }
|
||||||
|
: null),
|
||||||
|
endCursor:
|
||||||
|
payload.endCursor ??
|
||||||
|
(entries.length ? { seq: Math.max(...entries.map((entry) => entry.seqEnd)) } : null),
|
||||||
|
entries,
|
||||||
},
|
},
|
||||||
state.descriptors.get(key),
|
currentTail: current.tail,
|
||||||
),
|
currentHead: current.head,
|
||||||
);
|
currentCursor: current.cursor,
|
||||||
return { timelines };
|
isInitializing: current.cursor === undefined,
|
||||||
});
|
hasActiveInitDeferred: current.cursor === undefined,
|
||||||
},
|
initRequestDirection: "tail",
|
||||||
replaceTimeline(serverId, payload) {
|
sendingClientMessageIds: [],
|
||||||
const provider = payload.provider;
|
});
|
||||||
if (!provider) {
|
const timelines = new Map(state.timelines);
|
||||||
return;
|
timelines.set(
|
||||||
}
|
key,
|
||||||
set((state) => {
|
settleTimeline(
|
||||||
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
{
|
||||||
const existing = state.timelines.get(key);
|
tail: result.tail,
|
||||||
const current = existing ?? EMPTY_TIMELINE;
|
head: result.head,
|
||||||
// Normalize optional wire metadata at the child timeline boundary. Legacy
|
epoch: payload.epoch,
|
||||||
// notices are complete singleton items; history always comes from a projected host.
|
lastSeq:
|
||||||
const entries = payload.rows.map((row) => ({
|
payload.reset || current.epoch !== payload.epoch
|
||||||
...row,
|
? (result.cursor?.endSeq ?? 0)
|
||||||
provider,
|
: Math.max(current.lastSeq, result.cursor?.endSeq ?? 0),
|
||||||
seqStart: row.seqStart ?? row.seq,
|
cursor: result.cursor ?? undefined,
|
||||||
seqEnd: row.seqEnd ?? row.seq,
|
hasOlder:
|
||||||
}));
|
result.older === "unchanged" ? current.hasOlder : result.older === "available",
|
||||||
const result = processTimelineResponse({
|
needsRefresh:
|
||||||
payload: {
|
result.sideEffects.some((effect) => effect.type === "catch_up") &&
|
||||||
...payload,
|
(payload.hasNewer || current.lastSeq > (result.cursor?.endSeq ?? 0)),
|
||||||
agentId: payload.subagentId,
|
},
|
||||||
projection: "projected",
|
state.descriptors.get(key),
|
||||||
startCursor:
|
),
|
||||||
payload.startCursor ??
|
);
|
||||||
(entries.length ? { seq: Math.min(...entries.map((entry) => entry.seqStart)) } : null),
|
return { timelines };
|
||||||
endCursor:
|
|
||||||
payload.endCursor ??
|
|
||||||
(entries.length ? { seq: Math.max(...entries.map((entry) => entry.seqEnd)) } : null),
|
|
||||||
entries,
|
|
||||||
},
|
|
||||||
currentTail: current.tail,
|
|
||||||
currentHead: current.head,
|
|
||||||
currentCursor: current.cursor,
|
|
||||||
isInitializing: current.cursor === undefined,
|
|
||||||
hasActiveInitDeferred: current.cursor === undefined,
|
|
||||||
initRequestDirection: "tail",
|
|
||||||
sendingClientMessageIds: [],
|
|
||||||
});
|
});
|
||||||
const timelines = new Map(state.timelines);
|
},
|
||||||
timelines.set(
|
}));
|
||||||
key,
|
|
||||||
settleTimeline(
|
export type ProviderSubagentStoreApi = ReturnType<typeof createProviderSubagentStore>;
|
||||||
{
|
// COMPAT(unboundProviderSubagents): added in v0.12, remove after 2027-04-20.
|
||||||
tail: result.tail,
|
// Existing shell callers retain their store; bound consumers receive one captured owner.
|
||||||
head: result.head,
|
export const defaultProviderSubagentStore = createProviderSubagentStore();
|
||||||
epoch: payload.epoch,
|
export const ProviderSubagentStoreContext = createContext<ProviderSubagentStoreApi | null>(null);
|
||||||
lastSeq:
|
export function useProviderSubagentStoreApi(): ProviderSubagentStoreApi {
|
||||||
payload.reset || current.epoch !== payload.epoch
|
return useContext(ProviderSubagentStoreContext) ?? defaultProviderSubagentStore;
|
||||||
? (result.cursor?.endSeq ?? 0)
|
}
|
||||||
: Math.max(current.lastSeq, result.cursor?.endSeq ?? 0),
|
export const useProviderSubagentStore: ProviderSubagentStoreApi = Object.assign(
|
||||||
cursor: result.cursor ?? undefined,
|
function useProviderSubagentStore<T>(selector: (state: ProviderSubagentState) => T): T {
|
||||||
hasOlder:
|
return useStore(useProviderSubagentStoreApi(), selector);
|
||||||
result.older === "unchanged" ? current.hasOlder : result.older === "available",
|
|
||||||
needsRefresh:
|
|
||||||
result.sideEffects.some((effect) => effect.type === "catch_up") &&
|
|
||||||
(payload.hasNewer || current.lastSeq > (result.cursor?.endSeq ?? 0)),
|
|
||||||
},
|
|
||||||
state.descriptors.get(key),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return { timelines };
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
}));
|
defaultProviderSubagentStore,
|
||||||
|
);
|
||||||
|
|
||||||
/** Owns child history bootstrap and recovery while a pane observes the child. */
|
/** Owns child history bootstrap and recovery while a pane observes the child. */
|
||||||
export function observeProviderSubagentTimeline({
|
export function observeProviderSubagentTimeline({
|
||||||
client,
|
client,
|
||||||
|
store = defaultProviderSubagentStore,
|
||||||
serverId,
|
serverId,
|
||||||
parentAgentId,
|
parentAgentId,
|
||||||
subagentId,
|
subagentId,
|
||||||
@@ -364,6 +395,7 @@ export function observeProviderSubagentTimeline({
|
|||||||
reportError,
|
reportError,
|
||||||
}: {
|
}: {
|
||||||
client: Pick<DaemonClient, "fetchProviderSubagentTimeline">;
|
client: Pick<DaemonClient, "fetchProviderSubagentTimeline">;
|
||||||
|
store?: ProviderSubagentStoreApi;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
parentAgentId: string;
|
parentAgentId: string;
|
||||||
subagentId: string;
|
subagentId: string;
|
||||||
@@ -382,25 +414,21 @@ export function observeProviderSubagentTimeline({
|
|||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
if (active) useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
|
if (active) store.getState().replaceTimeline(serverId, payload);
|
||||||
succeeded = true;
|
succeeded = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A later stream update or reopening the pane retries a disconnected read.
|
// A later stream update or reopening the pane retries a disconnected read.
|
||||||
if (!(error instanceof DaemonConnectionError)) throw error;
|
if (!(error instanceof DaemonConnectionError)) throw error;
|
||||||
} finally {
|
} finally {
|
||||||
fetching = false;
|
fetching = false;
|
||||||
if (
|
if (succeeded && active && store.getState().timelines.get(key)?.needsRefresh)
|
||||||
succeeded &&
|
|
||||||
active &&
|
|
||||||
useProviderSubagentStore.getState().timelines.get(key)?.needsRefresh
|
|
||||||
)
|
|
||||||
requestRefresh();
|
requestRefresh();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const requestRefresh = () => {
|
const requestRefresh = () => {
|
||||||
void refresh().catch(reportError);
|
void refresh().catch(reportError);
|
||||||
};
|
};
|
||||||
const unsubscribe = useProviderSubagentStore.subscribe((state) => {
|
const unsubscribe = store.subscribe((state) => {
|
||||||
if (state.timelines.get(key)?.needsRefresh) requestRefresh();
|
if (state.timelines.get(key)?.needsRefresh) requestRefresh();
|
||||||
});
|
});
|
||||||
requestRefresh();
|
requestRefresh();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { managedAgentFields } from "@/subagents/test-fixtures";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
@@ -5,14 +6,125 @@ import {
|
|||||||
selectProviderSubagentsForParent,
|
selectProviderSubagentsForParent,
|
||||||
selectSubagentsForParent,
|
selectSubagentsForParent,
|
||||||
} from "./select";
|
} from "./select";
|
||||||
import { createWorkspaceAgentVisibilitySelector } from "@/workspace-tabs/agent-visibility";
|
import { membershipFixture, relationshipFixture } from "./test-fixtures";
|
||||||
import { useProviderSubagentStore } from "./provider-store";
|
import { useProviderSubagentStore } from "./provider-store";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
|
import { ManagedSubagents } from "./managed";
|
||||||
|
|
||||||
const SERVER_ID = "server-1";
|
const SERVER_ID = "server-1";
|
||||||
const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z");
|
const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z");
|
||||||
const EMPTY_PENDING_ARCHIVE_IDS = new Set<string>();
|
const EMPTY_PENDING_ARCHIVE_IDS = new Set<string>();
|
||||||
|
|
||||||
|
it("observes authoritative qualified membership with colliding IDs and retains it through authority withdrawal", async () => {
|
||||||
|
const harnessModule = new URL(
|
||||||
|
"../../../server/src/server/test-utils/paseo-daemon.ts",
|
||||||
|
import.meta.url,
|
||||||
|
).pathname;
|
||||||
|
const { createTestPaseoDaemon } = await import(harnessModule);
|
||||||
|
const { createHostClient } = await import("@getpaseo/client/node");
|
||||||
|
const { createPaseoApi } = await import("@getpaseo/client");
|
||||||
|
const a = await createTestPaseoDaemon();
|
||||||
|
const b = await createTestPaseoDaemon();
|
||||||
|
const origin = createHostClient(`127.0.0.1:${a.port}`, {
|
||||||
|
clientId: crypto.randomUUID(),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
});
|
||||||
|
const direct = createHostClient(`127.0.0.1:${b.port}`, {
|
||||||
|
clientId: crypto.randomUUID(),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
});
|
||||||
|
await Promise.all([origin.connect(), direct.connect()]);
|
||||||
|
const paseo = createPaseoApi(origin);
|
||||||
|
const destination = createPaseoApi(direct);
|
||||||
|
const changed = new Set<() => void>();
|
||||||
|
let available = true;
|
||||||
|
const originRuntime = paseo.hosts.runtime();
|
||||||
|
const model = new ManagedSubagents({
|
||||||
|
getClient: (serverId) => (available && serverId === a.daemon.getServerId() ? origin : null),
|
||||||
|
getSnapshot: () => ({ connectionStatus: available ? "online" : "offline" }),
|
||||||
|
withAgent: async (address, operation) => operation(await originRuntime.agent(address)),
|
||||||
|
subscribe: (_serverId, listener) => {
|
||||||
|
changed.add(listener);
|
||||||
|
return () => {
|
||||||
|
changed.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const registration = await paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||||
|
await expect.poll(async () => (await paseo.hosts.list()).hosts[0]?.status).toBe("online");
|
||||||
|
const parent = await paseo.agents.create({
|
||||||
|
cwd: a.staticDir,
|
||||||
|
config: { provider: "codex/test" },
|
||||||
|
});
|
||||||
|
const workspace = await destination.workspaces.create({
|
||||||
|
source: { kind: "directory", path: b.staticDir },
|
||||||
|
});
|
||||||
|
const collision = crypto.randomUUID();
|
||||||
|
const local = await parent.subagents.create({
|
||||||
|
agentId: collision,
|
||||||
|
config: { provider: "codex/test" },
|
||||||
|
idempotencyKey: "local",
|
||||||
|
});
|
||||||
|
const routed = await parent.subagents.create({
|
||||||
|
agentId: collision,
|
||||||
|
serverId: b.daemon.getServerId(),
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
config: { provider: "codex/test" },
|
||||||
|
idempotencyKey: "routed",
|
||||||
|
});
|
||||||
|
const directory = model.directory(parent.address);
|
||||||
|
const stopView = directory.subscribe(() => {});
|
||||||
|
await expect.poll(() => directory.getSnapshot().status).toBe("current");
|
||||||
|
expect(directory.getSnapshot().rows.map((row) => row.child)).toEqual(
|
||||||
|
expect.arrayContaining([local.address, routed.address]),
|
||||||
|
);
|
||||||
|
expect(directory.getSnapshot().rows).toHaveLength(2);
|
||||||
|
stopView();
|
||||||
|
await parent.subagents.create({
|
||||||
|
config: { provider: "codex/test" },
|
||||||
|
idempotencyKey: "without-view",
|
||||||
|
});
|
||||||
|
await expect.poll(() => directory.getSnapshot().rows.length).toBe(3);
|
||||||
|
available = false;
|
||||||
|
for (const listener of changed) listener();
|
||||||
|
expect(directory.getSnapshot()).toMatchObject({ status: "unavailable" });
|
||||||
|
expect(directory.getSnapshot().rows).toHaveLength(3);
|
||||||
|
expect(directory.getSnapshot().rows.every((row) => row.freshness === "unavailable")).toBe(true);
|
||||||
|
expect(direct.getConnectionState().status).toBe("connected");
|
||||||
|
await parent.subagents.create({
|
||||||
|
config: { provider: "codex/test" },
|
||||||
|
idempotencyKey: "while-withdrawn",
|
||||||
|
});
|
||||||
|
expect(directory.getSnapshot().rows).toHaveLength(3);
|
||||||
|
available = true;
|
||||||
|
for (const listener of changed) listener();
|
||||||
|
await expect.poll(() => directory.getSnapshot().rows.length).toBe(4);
|
||||||
|
expect(directory.getSnapshot().status).toBe("current");
|
||||||
|
// A managed action resolves its complete address through the parent capability.
|
||||||
|
await directory.archive(local.address);
|
||||||
|
await expect.poll(async () => (await local.refresh())?.agent.archivedAt).not.toBeNull();
|
||||||
|
expect((await routed.refresh())?.agent.archivedAt).toBeNull();
|
||||||
|
await paseo.hosts.update(registration.registrationId, { enabled: false });
|
||||||
|
await expect(directory.archive(routed.address)).rejects.toBeDefined();
|
||||||
|
// The separately connected B remains usable but must never satisfy the A-owned action.
|
||||||
|
expect((await destination.agents.ref(collision).refresh())?.agent.archivedAt).toBeNull();
|
||||||
|
expect(directory.getSnapshot().archiving.size).toBe(0);
|
||||||
|
await model.releaseServer(parent.serverId);
|
||||||
|
const released = directory.getSnapshot();
|
||||||
|
await parent.subagents.create({
|
||||||
|
config: { provider: "codex/test" },
|
||||||
|
idempotencyKey: "after-release",
|
||||||
|
});
|
||||||
|
expect(directory.getSnapshot()).toBe(released);
|
||||||
|
} finally {
|
||||||
|
await model.releaseServer(a.daemon.getServerId());
|
||||||
|
await Promise.allSettled([originRuntime.dispose(), paseo.dispose(), destination.dispose()]);
|
||||||
|
await Promise.allSettled([origin.close(), direct.close()]);
|
||||||
|
await Promise.allSettled([a.close(), b.close()]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const AGENT_DEFAULTS: Agent = {
|
const AGENT_DEFAULTS: Agent = {
|
||||||
serverId: SERVER_ID,
|
serverId: SERVER_ID,
|
||||||
id: "agent",
|
id: "agent",
|
||||||
@@ -47,7 +159,8 @@ const AGENT_DEFAULTS: Agent = {
|
|||||||
attentionReason: null,
|
attentionReason: null,
|
||||||
attentionTimestamp: null,
|
attentionTimestamp: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
parentAgentId: null,
|
parent: null,
|
||||||
|
managedRelationship: null,
|
||||||
labels: {},
|
labels: {},
|
||||||
projectPlacement: null,
|
projectPlacement: null,
|
||||||
};
|
};
|
||||||
@@ -180,420 +293,295 @@ describe("selectSubagentsForParent", () => {
|
|||||||
).map((row) => row.id),
|
).map((row) => row.id),
|
||||||
).toEqual(["nested"]);
|
).toEqual(["nested"]);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("returns only non-archived children for the requested parent", () => {
|
const fixtureParent = { serverId: SERVER_ID, agentId: "parent" };
|
||||||
|
const authority = { originServerId: SERVER_ID, serverId: SERVER_ID };
|
||||||
|
function membership(agents: Agent[]) {
|
||||||
|
return membershipFixture(
|
||||||
|
fixtureParent,
|
||||||
|
agents.map((agent) => relationshipFixture(fixtureParent, agent)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("authoritative managed row projection", () => {
|
||||||
|
it("projects only supplied membership, independently of labels, siblings and grandchildren", () => {
|
||||||
setAgents([
|
setAgents([
|
||||||
makeAgent({ id: "parent-a" }),
|
|
||||||
makeAgent({ id: "child-a", parentAgentId: "parent-a" }),
|
|
||||||
makeAgent({
|
makeAgent({
|
||||||
id: "archived-child",
|
id: "unrelated",
|
||||||
parentAgentId: "parent-a",
|
...managedAgentFields({ serverId: SERVER_ID, agentId: "parent" }),
|
||||||
archivedAt: new Date("2026-03-08T12:00:00.000Z"),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rows = selectSubagentsForParent(
|
|
||||||
useSessionStore.getState(),
|
|
||||||
{
|
|
||||||
serverId: SERVER_ID,
|
|
||||||
parentAgentId: "parent-a",
|
|
||||||
},
|
|
||||||
EMPTY_PENDING_ARCHIVE_IDS,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes siblings, unrelated agents, and grandchildren", () => {
|
|
||||||
setAgents([
|
|
||||||
makeAgent({ id: "parent-a" }),
|
|
||||||
makeAgent({ id: "parent-b" }),
|
|
||||||
makeAgent({ id: "child-a", parentAgentId: "parent-a" }),
|
|
||||||
makeAgent({ id: "sibling-b", parentAgentId: "parent-b" }),
|
|
||||||
makeAgent({ id: "grandchild-a", parentAgentId: "child-a" }),
|
|
||||||
makeAgent({ id: "unrelated" }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rows = selectSubagentsForParent(
|
|
||||||
useSessionStore.getState(),
|
|
||||||
{
|
|
||||||
serverId: SERVER_ID,
|
|
||||||
parentAgentId: "parent-a",
|
|
||||||
},
|
|
||||||
EMPTY_PENDING_ARCHIVE_IDS,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows only direct children for each parent", () => {
|
|
||||||
setAgents([
|
|
||||||
makeAgent({ id: "parent" }),
|
|
||||||
makeAgent({ id: "child", parentAgentId: "parent" }),
|
|
||||||
makeAgent({ id: "grandchild", parentAgentId: "child" }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const parentRows = selectSubagentsForParent(
|
|
||||||
useSessionStore.getState(),
|
|
||||||
{
|
|
||||||
serverId: SERVER_ID,
|
|
||||||
parentAgentId: "parent",
|
|
||||||
},
|
|
||||||
EMPTY_PENDING_ARCHIVE_IDS,
|
|
||||||
);
|
|
||||||
const childRows = selectSubagentsForParent(
|
|
||||||
useSessionStore.getState(),
|
|
||||||
{
|
|
||||||
serverId: SERVER_ID,
|
|
||||||
parentAgentId: "child",
|
|
||||||
},
|
|
||||||
EMPTY_PENDING_ARCHIVE_IDS,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(parentRows.map((row) => row.id)).toEqual(["child"]);
|
|
||||||
expect(childRows.map((row) => row.id)).toEqual(["grandchild"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sorts by createdAt ascending", () => {
|
|
||||||
setAgents([
|
|
||||||
makeAgent({ id: "parent" }),
|
|
||||||
makeAgent({
|
|
||||||
id: "third",
|
|
||||||
parentAgentId: "parent",
|
|
||||||
createdAt: new Date("2026-03-08T10:03:00.000Z"),
|
|
||||||
}),
|
}),
|
||||||
makeAgent({
|
makeAgent({
|
||||||
id: "first",
|
id: "grandchild",
|
||||||
parentAgentId: "parent",
|
...managedAgentFields({ serverId: SERVER_ID, agentId: "child" }),
|
||||||
createdAt: new Date("2026-03-08T10:01:00.000Z"),
|
|
||||||
}),
|
|
||||||
makeAgent({
|
|
||||||
id: "second",
|
|
||||||
parentAgentId: "parent",
|
|
||||||
createdAt: new Date("2026-03-08T10:02:00.000Z"),
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
const state = membership([
|
||||||
const rows = selectSubagentsForParent(
|
|
||||||
useSessionStore.getState(),
|
|
||||||
{
|
|
||||||
serverId: SERVER_ID,
|
|
||||||
parentAgentId: "parent",
|
|
||||||
},
|
|
||||||
EMPTY_PENDING_ARCHIVE_IDS,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(rows.map((row) => row.id)).toEqual(["first", "second", "third"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps only row-rendered fields and does not expose onOpen", () => {
|
|
||||||
const createdAt = new Date("2026-03-08T10:01:00.000Z");
|
|
||||||
setAgents([
|
|
||||||
makeAgent({ id: "parent" }),
|
|
||||||
makeAgent({
|
makeAgent({
|
||||||
id: "child",
|
id: "child",
|
||||||
parentAgentId: "parent",
|
...managedAgentFields({ serverId: SERVER_ID, agentId: "untrusted-other-fixtureParent" }),
|
||||||
provider: "claude",
|
|
||||||
title: "Review child",
|
|
||||||
status: "running",
|
|
||||||
requiresAttention: true,
|
|
||||||
createdAt,
|
|
||||||
model: "should-not-leak",
|
|
||||||
cwd: "/private/project",
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
const rows = selectSubagentsForParent(state, authority, EMPTY_PENDING_ARCHIVE_IDS);
|
||||||
const rows = selectSubagentsForParent(
|
expect(rows.map((row) => row.address)).toEqual([{ serverId: SERVER_ID, agentId: "child" }]);
|
||||||
useSessionStore.getState(),
|
expect(selectSubagentsForParent(membership([]), authority, EMPTY_PENDING_ARCHIVE_IDS)).toEqual(
|
||||||
{
|
[],
|
||||||
serverId: SERVER_ID,
|
|
||||||
parentAgentId: "parent",
|
|
||||||
},
|
|
||||||
EMPTY_PENDING_ARCHIVE_IDS,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(rows).toEqual([
|
|
||||||
{
|
|
||||||
kind: "paseo",
|
|
||||||
id: "child",
|
|
||||||
provider: "claude",
|
|
||||||
title: "Review child",
|
|
||||||
description: null,
|
|
||||||
subtitle: null,
|
|
||||||
status: "running",
|
|
||||||
turn: { phase: "idle", cancellationRequestId: null },
|
|
||||||
requiresAttention: true,
|
|
||||||
createdAt,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(Object.keys(rows[0] ?? {}).sort()).toEqual([
|
|
||||||
"createdAt",
|
|
||||||
"description",
|
|
||||||
"id",
|
|
||||||
"kind",
|
|
||||||
"provider",
|
|
||||||
"requiresAttention",
|
|
||||||
"status",
|
|
||||||
"subtitle",
|
|
||||||
"title",
|
|
||||||
"turn",
|
|
||||||
]);
|
|
||||||
expect(rows[0]).not.toHaveProperty("onOpen");
|
|
||||||
expect(rows[0]).not.toHaveProperty("model");
|
|
||||||
expect(rows[0]).not.toHaveProperty("cwd");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves a child when parentAgentId changes", () => {
|
it("removes confirmed terminal membership but retains creating and pending rows", () => {
|
||||||
const child = makeAgent({ id: "child", parentAgentId: "parent-a" });
|
const phases = [
|
||||||
setAgents([makeAgent({ id: "parent-a" }), makeAgent({ id: "parent-b" }), child]);
|
"attached",
|
||||||
|
"creating",
|
||||||
expect(
|
"detach_pending",
|
||||||
selectSubagentsForParent(
|
"lifecycle_pending",
|
||||||
useSessionStore.getState(),
|
"detached",
|
||||||
{
|
"archived",
|
||||||
serverId: SERVER_ID,
|
"forgotten",
|
||||||
parentAgentId: "parent-a",
|
"canceled",
|
||||||
},
|
] as const;
|
||||||
EMPTY_PENDING_ARCHIVE_IDS,
|
const state = membershipFixture(
|
||||||
).map((row) => row.id),
|
fixtureParent,
|
||||||
).toEqual(["child"]);
|
phases.map((phase) =>
|
||||||
expect(
|
relationshipFixture(fixtureParent, makeAgent({ id: phase }), { phase }),
|
||||||
selectSubagentsForParent(
|
|
||||||
useSessionStore.getState(),
|
|
||||||
{
|
|
||||||
serverId: SERVER_ID,
|
|
||||||
parentAgentId: "parent-b",
|
|
||||||
},
|
|
||||||
EMPTY_PENDING_ARCHIVE_IDS,
|
|
||||||
).map((row) => row.id),
|
|
||||||
).toEqual([]);
|
|
||||||
|
|
||||||
setAgents([
|
|
||||||
makeAgent({ id: "parent-a" }),
|
|
||||||
makeAgent({ id: "parent-b" }),
|
|
||||||
{ ...child, parentAgentId: "parent-b" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
selectSubagentsForParent(
|
|
||||||
useSessionStore.getState(),
|
|
||||||
{
|
|
||||||
serverId: SERVER_ID,
|
|
||||||
parentAgentId: "parent-a",
|
|
||||||
},
|
|
||||||
EMPTY_PENDING_ARCHIVE_IDS,
|
|
||||||
).map((row) => row.id),
|
|
||||||
).toEqual([]);
|
|
||||||
expect(
|
|
||||||
selectSubagentsForParent(
|
|
||||||
useSessionStore.getState(),
|
|
||||||
{
|
|
||||||
serverId: SERVER_ID,
|
|
||||||
parentAgentId: "parent-b",
|
|
||||||
},
|
|
||||||
EMPTY_PENDING_ARCHIVE_IDS,
|
|
||||||
).map((row) => row.id),
|
|
||||||
).toEqual(["child"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes children whose archive is pending", () => {
|
|
||||||
setAgents([
|
|
||||||
makeAgent({ id: "parent" }),
|
|
||||||
makeAgent({ id: "child-a", parentAgentId: "parent" }),
|
|
||||||
makeAgent({ id: "child-b", parentAgentId: "parent" }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rows = selectSubagentsForParent(
|
|
||||||
useSessionStore.getState(),
|
|
||||||
{
|
|
||||||
serverId: SERVER_ID,
|
|
||||||
parentAgentId: "parent",
|
|
||||||
},
|
|
||||||
new Set(["child-b"]),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the shared empty array when pending archive hides the last child", () => {
|
|
||||||
setAgents([makeAgent({ id: "parent" }), makeAgent({ id: "child", parentAgentId: "parent" })]);
|
|
||||||
|
|
||||||
const rows = selectSubagentsForParent(
|
|
||||||
useSessionStore.getState(),
|
|
||||||
{
|
|
||||||
serverId: SERVER_ID,
|
|
||||||
parentAgentId: "parent",
|
|
||||||
},
|
|
||||||
new Set(["child"]),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(rows).toEqual([]);
|
|
||||||
expect(rows).toBe(
|
|
||||||
selectSubagentsForParent(
|
|
||||||
useSessionStore.getState(),
|
|
||||||
{
|
|
||||||
serverId: SERVER_ID,
|
|
||||||
parentAgentId: "missing-parent",
|
|
||||||
},
|
|
||||||
EMPTY_PENDING_ARCHIVE_IDS,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
selectSubagentsForParent(state, authority, EMPTY_PENDING_ARCHIVE_IDS).map((row) => row.phase),
|
||||||
|
).toEqual(["attached", "creating", "detach_pending", "lifecycle_pending"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts by creation time and preserves ordinary presentation fields without exposing raw agent data", () => {
|
||||||
|
const earlier = makeAgent({
|
||||||
|
id: "first",
|
||||||
|
title: "Review child",
|
||||||
|
provider: "claude",
|
||||||
|
status: "running",
|
||||||
|
requiresAttention: true,
|
||||||
|
createdAt: new Date("2026-03-08T09:00:00Z"),
|
||||||
|
});
|
||||||
|
const rows = selectSubagentsForParent(
|
||||||
|
membership([makeAgent({ id: "second" }), earlier]),
|
||||||
|
authority,
|
||||||
|
EMPTY_PENDING_ARCHIVE_IDS,
|
||||||
|
);
|
||||||
|
expect(rows.map((row) => row.address.agentId)).toEqual(["first", "second"]);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
title: "Review child",
|
||||||
|
provider: "claude",
|
||||||
|
status: "running",
|
||||||
|
requiresAttention: true,
|
||||||
|
createdAt: earlier.createdAt,
|
||||||
|
authority,
|
||||||
|
});
|
||||||
|
expect(rows[0]).not.toHaveProperty("onOpen");
|
||||||
|
expect(rows[0]).not.toHaveProperty("cwd");
|
||||||
|
expect(rows[0]).not.toHaveProperty("model");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps colliding IDs distinct and hides only the qualified pending archive", () => {
|
||||||
|
const rows = selectSubagentsForParent(
|
||||||
|
membership([makeAgent({ id: "same" }), makeAgent({ serverId: "server-2", id: "same" })]),
|
||||||
|
authority,
|
||||||
|
new Set([JSON.stringify([SERVER_ID, "same"])]),
|
||||||
|
);
|
||||||
|
expect(rows.map((row) => row.address)).toEqual([{ serverId: "server-2", agentId: "same" }]);
|
||||||
|
expect(rows[0]?.authority).toEqual({ originServerId: SERVER_ID, serverId: "server-2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains unavailable membership with its last snapshot and unresolved membership without a fabricated status", () => {
|
||||||
|
const state = membershipFixture(fixtureParent, [
|
||||||
|
relationshipFixture(fixtureParent, makeAgent({ id: "stale", status: "running" }), {
|
||||||
|
freshness: "unavailable",
|
||||||
|
error: { code: "host_offline", message: "Host unavailable" },
|
||||||
|
}),
|
||||||
|
relationshipFixture(fixtureParent, makeAgent({ id: "creating" }), {
|
||||||
|
phase: "creating",
|
||||||
|
snapshot: undefined,
|
||||||
|
freshness: "unavailable",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const rows = selectSubagentsForParent(state, authority, EMPTY_PENDING_ARCHIVE_IDS);
|
||||||
|
expect(rows.find((row) => row.address.agentId === "stale")).toMatchObject({
|
||||||
|
status: "running",
|
||||||
|
freshness: "unavailable",
|
||||||
|
error: { code: "host_offline" },
|
||||||
|
});
|
||||||
|
expect(rows.find((row) => row.address.agentId === "creating")).toMatchObject({
|
||||||
|
status: null,
|
||||||
|
turn: null,
|
||||||
|
createdAt: null,
|
||||||
|
phase: "creating",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createSubagentsForParentSelector", () => {
|
describe("createSubagentsForParentSelector", () => {
|
||||||
class IterationCountingMap<K, V> extends Map<K, V> {
|
it("retains row identity across 100 unrelated transcript commits without reading the session agent map", () => {
|
||||||
iterations = 0;
|
setAgents([makeAgent({ id: "parent" })]);
|
||||||
override values() {
|
const state = membership([makeAgent({ id: "child" })]);
|
||||||
this.iterations += 1;
|
const select = createSubagentsForParentSelector(authority, EMPTY_PENDING_ARCHIVE_IDS);
|
||||||
return super.values();
|
const before = select(state);
|
||||||
}
|
for (let tick = 0; tick < 100; tick++) {
|
||||||
override entries() {
|
useSessionStore.getState().setAgentStreamState(SERVER_ID, "parent", {
|
||||||
this.iterations += 1;
|
head: [
|
||||||
return super.entries();
|
{
|
||||||
}
|
kind: "assistant_message",
|
||||||
override [Symbol.iterator]() {
|
id: "message",
|
||||||
this.iterations += 1;
|
text: `token ${tick}`,
|
||||||
return super[Symbol.iterator]();
|
timestamp: AGENT_TIMESTAMP,
|
||||||
}
|
},
|
||||||
}
|
],
|
||||||
|
|
||||||
const params = { serverId: SERVER_ID, parentAgentId: "parent" };
|
|
||||||
|
|
||||||
function streamTick(text: string): void {
|
|
||||||
useSessionStore.getState().setAgentStreamState(SERVER_ID, "parent", {
|
|
||||||
head: [
|
|
||||||
{
|
|
||||||
kind: "assistant_message",
|
|
||||||
id: "message-1",
|
|
||||||
text,
|
|
||||||
timestamp: new Date("2026-03-08T10:05:00.000Z"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it("returns the cached rows without walking agents when a stream tick leaves them unchanged", () => {
|
|
||||||
const agents = new IterationCountingMap<string, Agent>(
|
|
||||||
[
|
|
||||||
makeAgent({ id: "parent" }),
|
|
||||||
makeAgent({ id: "child", parentAgentId: "parent" }),
|
|
||||||
makeAgent({ id: "unrelated" }),
|
|
||||||
].map((agent) => [agent.id, agent]),
|
|
||||||
);
|
|
||||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
|
||||||
useSessionStore.getState().setAgents(SERVER_ID, agents);
|
|
||||||
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
|
|
||||||
const before = select(useSessionStore.getState());
|
|
||||||
const iterationsAfterFirstSelect = agents.iterations;
|
|
||||||
|
|
||||||
for (let tick = 0; tick < 100; tick += 1) {
|
|
||||||
streamTick(`token ${tick}`);
|
|
||||||
expect(select(useSessionStore.getState())).toBe(before);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents);
|
|
||||||
expect(agents.iterations).toBe(iterationsAfterFirstSelect);
|
|
||||||
expect(before.map((row) => row.id)).toEqual(["child"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recomputes when the agents map changes", () => {
|
|
||||||
setAgents([makeAgent({ id: "parent" }), makeAgent({ id: "child-a", parentAgentId: "parent" })]);
|
|
||||||
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
|
|
||||||
const before = select(useSessionStore.getState());
|
|
||||||
|
|
||||||
useSessionStore.getState().setAgents(SERVER_ID, (current) => {
|
|
||||||
const child = makeAgent({
|
|
||||||
id: "child-b",
|
|
||||||
parentAgentId: "parent",
|
|
||||||
createdAt: new Date("2026-03-08T10:01:00.000Z"),
|
|
||||||
});
|
});
|
||||||
return new Map(current).set(child.id, child);
|
expect(select(state)).toBe(before);
|
||||||
});
|
|
||||||
const after = select(useSessionStore.getState());
|
|
||||||
|
|
||||||
expect(after).not.toBe(before);
|
|
||||||
expect(after.map((row) => row.id)).toEqual(["child-a", "child-b"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the previous rows when a changed agents map yields the same rows", () => {
|
|
||||||
setAgents([
|
|
||||||
makeAgent({ id: "parent" }),
|
|
||||||
makeAgent({ id: "child", parentAgentId: "parent" }),
|
|
||||||
makeAgent({ id: "unrelated" }),
|
|
||||||
]);
|
|
||||||
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
|
|
||||||
const before = select(useSessionStore.getState());
|
|
||||||
|
|
||||||
useSessionStore.getState().setAgents(SERVER_ID, (current) => {
|
|
||||||
return new Map(current).set("unrelated", makeAgent({ id: "unrelated", status: "running" }));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(select(useSessionStore.getState())).toBe(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies the pending archive ids it was created with", () => {
|
|
||||||
setAgents([
|
|
||||||
makeAgent({ id: "parent" }),
|
|
||||||
makeAgent({ id: "child-a", parentAgentId: "parent" }),
|
|
||||||
makeAgent({ id: "child-b", parentAgentId: "parent" }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rows = createSubagentsForParentSelector(
|
|
||||||
params,
|
|
||||||
new Set(["child-b"]),
|
|
||||||
)(useSessionStore.getState());
|
|
||||||
|
|
||||||
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
|
||||||
});
|
|
||||||
it("does no agent map walks for 100 stream commits with unchanged source maps", () => {
|
|
||||||
const agents = new IterationCountingMap<string, Agent>(
|
|
||||||
Array.from({ length: 200 }, (_, i) => {
|
|
||||||
const agent = makeAgent({
|
|
||||||
id: `agent-${i}`,
|
|
||||||
workspaceId: "workspace",
|
|
||||||
parentAgentId: i < 3 ? null : `agent-${i % 3}`,
|
|
||||||
});
|
|
||||||
return [agent.id, agent] as const;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
|
||||||
useSessionStore.getState().setAgents(SERVER_ID, agents);
|
|
||||||
const selectVisibility = createWorkspaceAgentVisibilitySelector({
|
|
||||||
serverId: SERVER_ID,
|
|
||||||
workspaceId: "workspace",
|
|
||||||
});
|
|
||||||
const selectChildren = Array.from({ length: 3 }, (_, i) =>
|
|
||||||
createSubagentsForParentSelector(
|
|
||||||
{ serverId: SERVER_ID, parentAgentId: `agent-${i}` },
|
|
||||||
EMPTY_PENDING_ARCHIVE_IDS,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const select = (state: ReturnType<typeof useSessionStore.getState>) => {
|
|
||||||
selectVisibility(state);
|
|
||||||
selectChildren.forEach((selector) => selector(state));
|
|
||||||
};
|
|
||||||
select(useSessionStore.getState());
|
|
||||||
const before = agents.iterations;
|
|
||||||
const unsubscribe = useSessionStore.subscribe(select);
|
|
||||||
try {
|
|
||||||
for (let tick = 0; tick < 100; tick++) {
|
|
||||||
useSessionStore.getState().setAgentStreamState(SERVER_ID, "agent-0", {
|
|
||||||
head: [
|
|
||||||
{
|
|
||||||
kind: "assistant_message",
|
|
||||||
id: "message",
|
|
||||||
text: `token ${tick}`,
|
|
||||||
timestamp: AGENT_TIMESTAMP,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents);
|
|
||||||
expect(agents.iterations - before).toBe(0);
|
|
||||||
} finally {
|
|
||||||
unsubscribe();
|
|
||||||
useSessionStore.getState().clearSession(SERVER_ID);
|
|
||||||
}
|
}
|
||||||
|
expect(before.map((row) => row.address.agentId)).toEqual(["child"]);
|
||||||
|
});
|
||||||
|
it("recomputes changed membership and preserves equal projections after a refreshed snapshot", () => {
|
||||||
|
const select = createSubagentsForParentSelector(authority, EMPTY_PENDING_ARCHIVE_IDS);
|
||||||
|
const state = membership([makeAgent({ id: "first" })]);
|
||||||
|
const before = select(state);
|
||||||
|
expect(select({ ...state })).toBe(before);
|
||||||
|
const after = select(membership([makeAgent({ id: "first" }), makeAgent({ id: "second" })]));
|
||||||
|
expect(after).not.toBe(before);
|
||||||
|
expect(after.map((row) => row.address.agentId)).toEqual(["first", "second"]);
|
||||||
|
});
|
||||||
|
it("applies captured qualified archive keys, including hiding the last child", () => {
|
||||||
|
const select = createSubagentsForParentSelector(
|
||||||
|
authority,
|
||||||
|
new Set([JSON.stringify([SERVER_ID, "child"])]),
|
||||||
|
);
|
||||||
|
expect(select(membership([makeAgent({ id: "child" })]))).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves child actions through the owning app runtime after initial failure and blocks disposed callers", async () => {
|
||||||
|
const harnessModule = new URL(
|
||||||
|
"../../../server/src/server/test-utils/paseo-daemon.ts",
|
||||||
|
import.meta.url,
|
||||||
|
).pathname;
|
||||||
|
const { createTestPaseoDaemon } = await import(harnessModule);
|
||||||
|
const { createHostClient } = await import("@getpaseo/client/node");
|
||||||
|
const { createPaseoApi } = await import("@getpaseo/client");
|
||||||
|
const { acquireHostConnection } = await import("@getpaseo/client/internal/host-connection");
|
||||||
|
const { HostRuntimeStore } = await import("@/runtime/host-runtime");
|
||||||
|
const { createSessionStore } = await import("@/stores/session-store");
|
||||||
|
const { indexedDB, IDBKeyRange } = await import("fake-indexeddb");
|
||||||
|
globalThis.indexedDB = indexedDB;
|
||||||
|
globalThis.IDBKeyRange = IDBKeyRange;
|
||||||
|
const daemons = await Promise.all([
|
||||||
|
createTestPaseoDaemon(),
|
||||||
|
createTestPaseoDaemon(),
|
||||||
|
createTestPaseoDaemon(),
|
||||||
|
]);
|
||||||
|
const [a, b, c] = daemons;
|
||||||
|
const clients = daemons.map((daemon) =>
|
||||||
|
createHostClient(`127.0.0.1:${daemon.port}`, {
|
||||||
|
clientId: crypto.randomUUID(),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await Promise.all(clients.map((client) => client.connect()));
|
||||||
|
const [origin, parentApi, destination] = clients.map((client) => createPaseoApi(client));
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
let failC = true;
|
||||||
|
let acquisitions = 0;
|
||||||
|
const registry = new HostRuntimeStore({
|
||||||
|
sessionStore: createSessionStore(),
|
||||||
|
storage: {
|
||||||
|
getItem: async (key) => storage.get(key) ?? null,
|
||||||
|
setItem: async (key, value) => {
|
||||||
|
storage.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: async (key) => {
|
||||||
|
storage.delete(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolveHostConnection: async (source, serverId, signal) => {
|
||||||
|
acquisitions++;
|
||||||
|
expect(source).toBe(clients[0]);
|
||||||
|
if (serverId === c.daemon.getServerId() && failC)
|
||||||
|
throw new Error("Initial C acquisition unavailable");
|
||||||
|
return acquireHostConnection(source, serverId, signal);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const leases: Array<import("@/runtime/host-runtime").OwnedHostAuthorityBinding> = [];
|
||||||
|
try {
|
||||||
|
const [, cRegistration] = await Promise.all([
|
||||||
|
origin!.hosts.add({ connection: `127.0.0.1:${b.port}` }),
|
||||||
|
origin!.hosts.add({ connection: `127.0.0.1:${c.port}` }),
|
||||||
|
parentApi!.hosts.add({ connection: `127.0.0.1:${c.port}` }),
|
||||||
|
]);
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
(await origin!.hosts.list()).hosts.every((host) => host.status === "online"),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await expect.poll(async () => (await parentApi!.hosts.list()).hosts[0]?.status).toBe("online");
|
||||||
|
const parent = await parentApi!.agents.create({
|
||||||
|
cwd: b.staticDir,
|
||||||
|
config: { provider: "codex/test" },
|
||||||
|
});
|
||||||
|
const workspace = await destination!.workspaces.create({
|
||||||
|
source: { kind: "directory", path: c.staticDir },
|
||||||
|
});
|
||||||
|
const child = await parent.subagents.create({
|
||||||
|
serverId: c.daemon.getServerId(),
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
config: { provider: "codex/test" },
|
||||||
|
idempotencyKey: "bound-parent-child",
|
||||||
|
});
|
||||||
|
await registry.upsertDirectConnection({
|
||||||
|
serverId: a.daemon.getServerId(),
|
||||||
|
endpoint: `127.0.0.1:${a.port}`,
|
||||||
|
existingClient: clients[0],
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(() => registry.getSnapshot(a.daemon.getServerId())?.connectionStatus)
|
||||||
|
.toBe("online");
|
||||||
|
const retainedC = registry.acquireBinding({
|
||||||
|
originServerId: a.daemon.getServerId(),
|
||||||
|
serverId: c.daemon.getServerId(),
|
||||||
|
});
|
||||||
|
leases.push(retainedC);
|
||||||
|
await expect(retainedC.binding.ready).rejects.toThrow("Initial C acquisition unavailable");
|
||||||
|
failC = false;
|
||||||
|
await origin!.hosts.update(cRegistration!.registrationId, {
|
||||||
|
metadata: { revision: "recovered" },
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(() => retainedC.binding.runtime.getSnapshot(c.daemon.getServerId())?.connectionStatus)
|
||||||
|
.toBe("online");
|
||||||
|
const boundParent = registry.acquireBinding({
|
||||||
|
originServerId: a.daemon.getServerId(),
|
||||||
|
serverId: b.daemon.getServerId(),
|
||||||
|
});
|
||||||
|
leases.push(boundParent);
|
||||||
|
await boundParent.binding.ready;
|
||||||
|
const directory = boundParent.binding.runtime.subagents.directory(parent.address);
|
||||||
|
directory.subscribe(() => {});
|
||||||
|
await expect.poll(() => directory.getSnapshot().rows.length).toBe(1);
|
||||||
|
await directory.archive(child.address);
|
||||||
|
expect((await child.refresh())?.agent.archivedAt).not.toBeNull();
|
||||||
|
const captured = boundParent.binding.runtime.withAgent.bind(boundParent.binding.runtime);
|
||||||
|
await boundParent.release();
|
||||||
|
const beforeDisposedCall = acquisitions;
|
||||||
|
await expect(captured(child.address, (agent) => agent.refresh())).rejects.toThrow("disposed");
|
||||||
|
expect(acquisitions).toBe(beforeDisposedCall);
|
||||||
|
const direct = registry.acquireBinding({
|
||||||
|
originServerId: a.daemon.getServerId(),
|
||||||
|
serverId: a.daemon.getServerId(),
|
||||||
|
});
|
||||||
|
leases.push(direct);
|
||||||
|
await direct.binding.ready;
|
||||||
|
expect(
|
||||||
|
await direct.binding.runtime.withAgent(child.address, async (agent) => agent.address),
|
||||||
|
).toEqual(child.address);
|
||||||
|
} finally {
|
||||||
|
await Promise.allSettled(leases.map((lease) => lease.release()));
|
||||||
|
registry.syncHosts([]);
|
||||||
|
await Promise.allSettled([origin, parentApi, destination].map((api) => api!.dispose()));
|
||||||
|
await Promise.allSettled(clients.map((client) => client.close()));
|
||||||
|
await Promise.allSettled(daemons.map((daemon) => daemon.close()));
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|||||||
@@ -1,23 +1,43 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useSyncExternalStore } from "react";
|
||||||
import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent";
|
|
||||||
import equal from "fast-deep-equal";
|
import equal from "fast-deep-equal";
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
import { refreshProviderSubagents, useProviderSubagentStore } from "./provider-store";
|
import {
|
||||||
|
refreshProviderSubagents,
|
||||||
|
useProviderSubagentStore,
|
||||||
|
useProviderSubagentStoreApi,
|
||||||
|
} from "./provider-store";
|
||||||
|
import { useHostRuntimeStore, type HostAuthorityReference } from "@/runtime/host-runtime";
|
||||||
|
import type { AgentAddress, WorkspaceAddress } from "@getpaseo/protocol/addresses";
|
||||||
|
import type { ManagedSubagentState } from "./managed";
|
||||||
|
import type { SubagentRelationship } from "@getpaseo/protocol/messages";
|
||||||
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
|
import {
|
||||||
|
buildAgentFactsLine,
|
||||||
|
agentFactsSnapshot,
|
||||||
|
type AgentFactsSnapshot,
|
||||||
|
} from "@/panels/agent-facts-line";
|
||||||
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
|
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||||
|
|
||||||
export interface PaseoSubagentRow {
|
export interface PaseoSubagentRow {
|
||||||
kind: "paseo";
|
kind: "paseo";
|
||||||
id: Agent["id"];
|
address: AgentAddress;
|
||||||
|
authority: HostAuthorityReference;
|
||||||
|
relationshipId: string;
|
||||||
|
workspace: WorkspaceAddress | null;
|
||||||
|
freshness: SubagentRelationship["freshness"];
|
||||||
|
phase: SubagentRelationship["phase"];
|
||||||
|
error: SubagentRelationship["error"];
|
||||||
provider: Agent["provider"];
|
provider: Agent["provider"];
|
||||||
title: Agent["title"];
|
title: Agent["title"];
|
||||||
/** Managed agents have a real title, so the union's task line is always absent for them. */
|
/** Managed agents have a real title, so the union's task line is always absent for them. */
|
||||||
description: null;
|
description: null;
|
||||||
subtitle: null;
|
subtitle: string | null;
|
||||||
status: Agent["status"];
|
facts: AgentFactsSnapshot | null;
|
||||||
turn: Agent["turn"];
|
status: Agent["status"] | null;
|
||||||
|
turn: Agent["turn"] | null;
|
||||||
requiresAttention: Agent["requiresAttention"];
|
requiresAttention: Agent["requiresAttention"];
|
||||||
createdAt: Agent["createdAt"];
|
createdAt: Agent["createdAt"] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderSubagentRow {
|
export interface ProviderSubagentRow {
|
||||||
@@ -39,7 +59,6 @@ export interface ProviderSubagentRow {
|
|||||||
|
|
||||||
export type SubagentRow = PaseoSubagentRow | ProviderSubagentRow;
|
export type SubagentRow = PaseoSubagentRow | ProviderSubagentRow;
|
||||||
|
|
||||||
type SessionStoreSnapshot = ReturnType<typeof useSessionStore.getState>;
|
|
||||||
type ProviderSubagentStoreSnapshot = ReturnType<typeof useProviderSubagentStore.getState>;
|
type ProviderSubagentStoreSnapshot = ReturnType<typeof useProviderSubagentStore.getState>;
|
||||||
|
|
||||||
interface SelectSubagentsParams {
|
interface SelectSubagentsParams {
|
||||||
@@ -49,79 +68,75 @@ interface SelectSubagentsParams {
|
|||||||
providerParentSubagentId?: string;
|
providerParentSubagentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_SUBAGENT_ROWS: SubagentRow[] = [];
|
|
||||||
const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = [];
|
const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = [];
|
||||||
|
|
||||||
function toSubagentRow(agent: Agent): SubagentRow {
|
export function subagentRowIdentity(row: SubagentRow): string {
|
||||||
return {
|
return row.kind === "paseo"
|
||||||
kind: "paseo",
|
? JSON.stringify(["paseo", row.address.serverId, row.address.agentId])
|
||||||
id: agent.id,
|
: JSON.stringify(["provider", row.parentAgentId, row.id]);
|
||||||
provider: agent.provider,
|
|
||||||
title: agent.title,
|
|
||||||
description: null,
|
|
||||||
subtitle: null,
|
|
||||||
status: agent.status,
|
|
||||||
turn: agent.turn,
|
|
||||||
requiresAttention: agent.requiresAttention,
|
|
||||||
createdAt: agent.createdAt,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectSubagentsForParent(
|
export function selectSubagentsForParent(
|
||||||
state: SessionStoreSnapshot,
|
state: ManagedSubagentState,
|
||||||
params: SelectSubagentsParams,
|
authority: HostAuthorityReference,
|
||||||
pendingArchiveIds: ReadonlySet<string>,
|
pendingArchiveKeys: ReadonlySet<string>,
|
||||||
): SubagentRow[] {
|
): PaseoSubagentRow[] {
|
||||||
const agents = state.sessions[params.serverId]?.agents;
|
const rows: PaseoSubagentRow[] = [];
|
||||||
if (!agents || agents.size === 0) {
|
for (const relationship of state.rows) {
|
||||||
return EMPTY_SUBAGENT_ROWS;
|
if (["detached", "archived", "forgotten", "canceled"].includes(relationship.phase)) continue;
|
||||||
|
const address = relationship.child;
|
||||||
|
if (pendingArchiveKeys.has(JSON.stringify([address.serverId, address.agentId]))) continue;
|
||||||
|
const snapshot = relationship.snapshot
|
||||||
|
? normalizeAgentSnapshot(relationship.snapshot, address.serverId)
|
||||||
|
: null;
|
||||||
|
rows.push({
|
||||||
|
kind: "paseo",
|
||||||
|
address,
|
||||||
|
authority: { originServerId: authority.originServerId, serverId: address.serverId },
|
||||||
|
relationshipId: relationship.id,
|
||||||
|
workspace: relationship.workspace ?? null,
|
||||||
|
phase: relationship.phase,
|
||||||
|
freshness: relationship.freshness,
|
||||||
|
error: relationship.error,
|
||||||
|
provider: snapshot?.provider ?? "",
|
||||||
|
title: snapshot?.title ?? null,
|
||||||
|
description: null,
|
||||||
|
subtitle: snapshot
|
||||||
|
? buildAgentFactsLine({ ...agentFactsSnapshot(snapshot), models: null })
|
||||||
|
: null,
|
||||||
|
facts: snapshot ? agentFactsSnapshot(snapshot) : null,
|
||||||
|
status: snapshot?.status ?? null,
|
||||||
|
turn: snapshot?.turn ?? null,
|
||||||
|
requiresAttention: snapshot?.requiresAttention ?? false,
|
||||||
|
createdAt: snapshot?.createdAt ?? null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
rows.sort(compareSubagentRows);
|
||||||
const rows: SubagentRow[] = [];
|
|
||||||
for (const agent of agents.values()) {
|
|
||||||
if (
|
|
||||||
agent.archivedAt ||
|
|
||||||
pendingArchiveIds.has(agent.id) ||
|
|
||||||
agent.parentAgentId !== params.parentAgentId
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
rows.push(toSubagentRow(agent));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return EMPTY_SUBAGENT_ROWS;
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
|
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session-store selectors run on every store update, including every stream tick, which
|
|
||||||
// leaves the agent map untouched. Reuse the previous rows until the map changes, and keep
|
|
||||||
// them when a recompute yields equal rows so the hook's equality check stays O(1).
|
|
||||||
export function createSubagentsForParentSelector(
|
export function createSubagentsForParentSelector(
|
||||||
params: SelectSubagentsParams,
|
authority: HostAuthorityReference,
|
||||||
pendingArchiveIds: ReadonlySet<string>,
|
pendingArchiveKeys: ReadonlySet<string>,
|
||||||
): (state: SessionStoreSnapshot) => SubagentRow[] {
|
): (state: ManagedSubagentState) => PaseoSubagentRow[] {
|
||||||
let previousAgents: Map<string, Agent> | undefined;
|
let previous: ManagedSubagentState | null = null;
|
||||||
let previousRows: SubagentRow[] | null = null;
|
let previousRows: PaseoSubagentRow[] = [];
|
||||||
|
|
||||||
return (state) => {
|
return (state) => {
|
||||||
const agents = state.sessions[params.serverId]?.agents;
|
if (previous === state) return previousRows;
|
||||||
if (previousRows && agents === previousAgents) {
|
const rows = selectSubagentsForParent(state, authority, pendingArchiveKeys);
|
||||||
return previousRows;
|
previous = state;
|
||||||
}
|
if (!equal(previousRows, rows)) previousRows = rows;
|
||||||
|
|
||||||
const rows = selectSubagentsForParent(state, params, pendingArchiveIds);
|
|
||||||
previousAgents = agents;
|
|
||||||
if (!previousRows || !equal(previousRows, rows)) {
|
|
||||||
previousRows = rows;
|
|
||||||
}
|
|
||||||
return previousRows;
|
return previousRows;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareSubagentRows(left: SubagentRow, right: SubagentRow): number {
|
||||||
|
return (
|
||||||
|
(left.createdAt?.getTime() ?? 0) - (right.createdAt?.getTime() ?? 0) ||
|
||||||
|
subagentRowIdentity(left).localeCompare(subagentRowIdentity(right))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function selectProviderSubagentsForParent(
|
export function selectProviderSubagentsForParent(
|
||||||
state: ProviderSubagentStoreSnapshot,
|
state: ProviderSubagentStoreSnapshot,
|
||||||
params: SelectSubagentsParams,
|
params: SelectSubagentsParams,
|
||||||
@@ -153,18 +168,25 @@ export function selectProviderSubagentsForParent(
|
|||||||
createdAt: new Date(subagent.createdAt),
|
createdAt: new Date(subagent.createdAt),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
|
rows.sort(compareSubagentRows);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
|
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
|
||||||
const { serverId, parentAgentId } = params;
|
const { serverId, parentAgentId } = params;
|
||||||
const pendingArchiveIds = usePendingArchiveAgentIds(serverId);
|
const providerStore = useProviderSubagentStoreApi();
|
||||||
const selectPaseoRows = useMemo(
|
const runtime = useHostRuntimeStore();
|
||||||
() => createSubagentsForParentSelector({ serverId, parentAgentId }, pendingArchiveIds),
|
const directory = runtime.subagents.directory({ serverId, agentId: parentAgentId });
|
||||||
[serverId, parentAgentId, pendingArchiveIds],
|
const membership = useSyncExternalStore(
|
||||||
|
directory.subscribe,
|
||||||
|
directory.getSnapshot,
|
||||||
|
directory.getSnapshot,
|
||||||
|
);
|
||||||
|
const paseoRows = useMemo(
|
||||||
|
() =>
|
||||||
|
selectSubagentsForParent(membership, runtime.authorityFor(serverId), membership.archiving),
|
||||||
|
[membership, runtime, serverId],
|
||||||
);
|
);
|
||||||
const paseoRows = useStoreWithEqualityFn(useSessionStore, selectPaseoRows, equal);
|
|
||||||
const supported = useSessionStore(
|
const supported = useSessionStore(
|
||||||
(state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true,
|
(state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true,
|
||||||
);
|
);
|
||||||
@@ -173,7 +195,7 @@ export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRo
|
|||||||
state.sessions[params.serverId]?.serverInfo?.features?.providerSubagentNesting === true,
|
state.sessions[params.serverId]?.serverInfo?.features?.providerSubagentNesting === true,
|
||||||
);
|
);
|
||||||
const providerRows = useStoreWithEqualityFn(
|
const providerRows = useStoreWithEqualityFn(
|
||||||
useProviderSubagentStore,
|
providerStore,
|
||||||
(state) => selectProviderSubagentsForParent(state, params, supported, nestingSupported),
|
(state) => selectProviderSubagentsForParent(state, params, supported, nestingSupported),
|
||||||
equal,
|
equal,
|
||||||
);
|
);
|
||||||
@@ -181,16 +203,19 @@ export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRo
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client || !supported) return;
|
if (!client || !supported) return;
|
||||||
void refreshProviderSubagents(client, params.serverId, params.parentAgentId).catch(
|
void refreshProviderSubagents(
|
||||||
() => undefined,
|
client,
|
||||||
);
|
params.serverId,
|
||||||
}, [client, params.parentAgentId, params.serverId, supported]);
|
params.parentAgentId,
|
||||||
|
providerStore,
|
||||||
|
).catch(() => undefined);
|
||||||
|
}, [client, params.parentAgentId, params.serverId, providerStore, supported]);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (params.providerParentSubagentId) return providerRows;
|
if (params.providerParentSubagentId) return providerRows;
|
||||||
if (providerRows.length === 0) return paseoRows;
|
if (providerRows.length === 0) return paseoRows;
|
||||||
const rows = [...paseoRows, ...providerRows];
|
const rows = [...paseoRows, ...providerRows];
|
||||||
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
|
rows.sort(compareSubagentRows);
|
||||||
return rows;
|
return rows;
|
||||||
}, [params.providerParentSubagentId, paseoRows, providerRows]);
|
}, [params.providerParentSubagentId, paseoRows, providerRows]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||||
|
import type { SubagentRelationship } from "@getpaseo/protocol/messages";
|
||||||
|
import type { Agent } from "@/stores/session-store";
|
||||||
|
import { projectAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
|
import type { ManagedSubagentState } from "./managed";
|
||||||
|
|
||||||
|
export function relationshipFixture(
|
||||||
|
parent: AgentAddress,
|
||||||
|
agent: Agent,
|
||||||
|
overrides: Partial<SubagentRelationship> = {},
|
||||||
|
): SubagentRelationship {
|
||||||
|
return {
|
||||||
|
id: JSON.stringify([parent, agent.serverId, agent.id]),
|
||||||
|
parent,
|
||||||
|
child: { serverId: agent.serverId, agentId: agent.id },
|
||||||
|
...(agent.workspaceId
|
||||||
|
? { workspace: { serverId: agent.serverId, workspaceId: agent.workspaceId } }
|
||||||
|
: {}),
|
||||||
|
snapshot: projectAgentSnapshot(agent),
|
||||||
|
phase: "attached",
|
||||||
|
observedAt: agent.updatedAt.toISOString(),
|
||||||
|
freshness: "current",
|
||||||
|
error: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function membershipFixture(
|
||||||
|
parent: AgentAddress,
|
||||||
|
rows: SubagentRelationship[],
|
||||||
|
): ManagedSubagentState {
|
||||||
|
return { parent, rows, status: "current", error: null, archiving: new Set() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function managedAgentFields(
|
||||||
|
parent: AgentAddress | null,
|
||||||
|
parentWorkspace: import("@getpaseo/protocol/addresses").WorkspaceAddress | null = null,
|
||||||
|
): Pick<Agent, "parent" | "managedRelationship"> {
|
||||||
|
return {
|
||||||
|
parent,
|
||||||
|
managedRelationship: parent ? { id: JSON.stringify(parent), parent, parentWorkspace } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,12 +8,17 @@ import {
|
|||||||
resolveRowLabel,
|
resolveRowLabel,
|
||||||
} from "./track-presentation";
|
} from "./track-presentation";
|
||||||
|
|
||||||
function row(
|
function row(overrides: Partial<PaseoSubagentRow> & { id: string }): PaseoSubagentRow {
|
||||||
overrides: Partial<PaseoSubagentRow> & Pick<PaseoSubagentRow, "id">,
|
|
||||||
): PaseoSubagentRow {
|
|
||||||
return {
|
return {
|
||||||
kind: "paseo",
|
kind: "paseo",
|
||||||
id: overrides.id,
|
facts: null,
|
||||||
|
address: { serverId: "server-1", agentId: overrides.id },
|
||||||
|
authority: { originServerId: "server-1", serverId: "server-1" },
|
||||||
|
relationshipId: overrides.id,
|
||||||
|
workspace: null,
|
||||||
|
freshness: "current",
|
||||||
|
phase: "attached",
|
||||||
|
error: null,
|
||||||
provider: overrides.provider ?? "codex",
|
provider: overrides.provider ?? "codex",
|
||||||
title: overrides.title ?? `Agent ${overrides.id}`,
|
title: overrides.title ?? `Agent ${overrides.id}`,
|
||||||
description: null,
|
description: null,
|
||||||
@@ -170,7 +175,7 @@ describe("resolveRowLabel", () => {
|
|||||||
describe("buildSubagentRowPresentationData", () => {
|
describe("buildSubagentRowPresentationData", () => {
|
||||||
it("namespaces the key with a subagent prefix", () => {
|
it("namespaces the key with a subagent prefix", () => {
|
||||||
expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe(
|
expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe(
|
||||||
"paseo_subagent_child-a",
|
JSON.stringify(["paseo", "server-1", "child-a"]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user