mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
fix(desktop): integrate cloud handoff with hub ownership
This commit is contained in:
@@ -19,7 +19,7 @@ sidecar/
|
||||
├── client-context.ts # Desktop client/account identity for shared telemetry
|
||||
├── commands.ts # Command router
|
||||
├── chat-session.ts # Shared-Hub chat session adapter (local + cloud routing)
|
||||
├── cloud-sessions.ts # Cloud session REST client + Hub-proxy manager
|
||||
├── cloud-sessions.ts # Desktop projection of hub-owned cloud sessions
|
||||
├── cline-auth.ts # Refresh-aware Cline auth token resolution
|
||||
├── feature-flags.ts # Cloud sessions gate (env override + settings toggle)
|
||||
├── session-data/ # Shared discovery, messages, artifacts, search helpers
|
||||
@@ -117,9 +117,11 @@ const pendingApprovals = new Map<string, {
|
||||
// When frontend responds → resolve promise
|
||||
```
|
||||
|
||||
Cloud sessions route approvals the same way, but the resolver forwards the
|
||||
response to the sandbox Hub (`approval.respond`), which is why `resolve` may
|
||||
be async.
|
||||
Cloud sessions project approval snapshots from the shared Hub. Their async
|
||||
resolvers send `respondApproval` through the cloud RPC facade; the daemon forwards
|
||||
the response to the sandbox Hub. Desktop owns no cloud API client or sandbox connection.
|
||||
Disposing its cloud viewer only unsubscribes; account-scope retirement and shutdown
|
||||
are owned by the daemon.
|
||||
|
||||
### 3. Provider Management — Direct ProviderSettingsManager
|
||||
|
||||
@@ -200,7 +202,7 @@ Supported commands:
|
||||
|
||||
| Command | Implementation |
|
||||
|---------|---------------|
|
||||
| `chat_session_command` | shared Hub through `ClineCore`; cloud sessions route to `CloudSessionManager` |
|
||||
| `chat_session_command` | shared Hub through `ClineCore`; cloud sessions use `HubCloudSessionClient` through `CloudSessionManager` |
|
||||
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
|
||||
| `list_provider_models` | `getLocalProviderModels` |
|
||||
| `list_transcription_models` | `getLocalTranscriptionModels` |
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyHandoffGit,
|
||||
@@ -435,7 +435,14 @@ describe("cloud handoff Git preparation", () => {
|
||||
"secret.key",
|
||||
".npmrc",
|
||||
".yarnrc.yml",
|
||||
".netrc",
|
||||
".pgpass",
|
||||
".pypirc",
|
||||
".aws/credentials",
|
||||
"release.jks",
|
||||
"release.keystore",
|
||||
])("blocks potential secret %s without staging it", async (name) => {
|
||||
mkdirSync(dirname(join(repo, name)), { recursive: true });
|
||||
writeFileSync(join(repo, name), "private");
|
||||
await expect(inspectHandoffGit(repo)).rejects.toThrow("sensitive files");
|
||||
expect(git("diff", "--cached", "--name-only")).toBe("");
|
||||
@@ -445,7 +452,9 @@ describe("cloud handoff Git preparation", () => {
|
||||
[".env", false],
|
||||
[".env", true],
|
||||
[".npmrc", true],
|
||||
[".aws/credentials", true],
|
||||
] as const)("rejects sensitive unpublished %s history (deleted later: %s) without mutation", async (path, deleted) => {
|
||||
mkdirSync(dirname(join(repo, path)), { recursive: true });
|
||||
writeFileSync(join(repo, path), "SYNTHETIC_LOCAL_ONLY=value\n");
|
||||
git("add", path);
|
||||
git("commit", "-m", "local credentials");
|
||||
@@ -465,6 +474,11 @@ describe("cloud handoff Git preparation", () => {
|
||||
expect(readFileSync(join(repo, ".git/index"))).toEqual(index);
|
||||
expect(git("ls-remote", "origin")).toBe(remoteRefs);
|
||||
});
|
||||
it("allows credential templates in the checkpoint", async () => {
|
||||
writeFileSync(join(repo, ".netrc.example"), "machine example.com\n");
|
||||
const plan = await inspectHandoffGit(repo);
|
||||
expect(plan.files.map(({ path }) => path)).toContain(".netrc.example");
|
||||
});
|
||||
|
||||
it("rejects different push repositories before any local mutation", async () => {
|
||||
transport.pushUrl = "https://github.com/other/repo";
|
||||
|
||||
@@ -82,7 +82,7 @@ async function optionalConfig(cwd: string, key: string) {
|
||||
|
||||
function isSensitivePath(path: string): boolean {
|
||||
return (
|
||||
/(^|\/)(\.env(?:\.[^/]*)?|\.npmrc|\.yarnrc\.yml|id_rsa|id_ed25519|credentials\.json)$|\.(pem|key|p12|pfx)$/i.test(
|
||||
/(^|\/)(\.env(?:\.[^/]*)?|\.npmrc|\.yarnrc\.yml|\.netrc|\.pgpass|\.pypirc|\.aws\/credentials|id_rsa|id_ed25519|credentials\.json)$|\.(pem|key|p12|pfx|jks|keystore)$/i.test(
|
||||
path,
|
||||
) && !/\.(example|sample|template)$/i.test(path)
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { summarizeConversation } from "@cline/core";
|
||||
import { loadCloudModels } from "@cline/core/cloud";
|
||||
import { DEFAULT_MAX_IMAGE_DECODED_BYTES } from "@cline/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -9,11 +8,12 @@ import {
|
||||
handlePrepareHandoff,
|
||||
} from "./cloud-handoff";
|
||||
import * as gitPreparation from "./cloud-handoff-git";
|
||||
import { createCloudSessionTestRuntime } from "./cloud-session-test-runtime";
|
||||
import {
|
||||
CloudCreationUnconfirmedError,
|
||||
type CloudSessionApi,
|
||||
CloudSessionError,
|
||||
CloudSessionManager,
|
||||
getCloudSessionManager,
|
||||
resetCloudSessionManager,
|
||||
} from "./cloud-sessions";
|
||||
import { createSidecarContext, getEnvironmentContext } from "./context";
|
||||
@@ -34,10 +34,6 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
summarizeConversation: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@cline/core/cloud", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core/cloud")>();
|
||||
return { ...actual, loadCloudModels: vi.fn() };
|
||||
});
|
||||
|
||||
const image =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jT1sAAAAASUVORK5CYII=";
|
||||
@@ -47,7 +43,7 @@ afterEach(() => {
|
||||
cleanupCloudHandoffGates();
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
async function fixture() {
|
||||
const sourceSessionId = "local-source";
|
||||
const modelId = "test-model";
|
||||
const git = {
|
||||
@@ -56,9 +52,6 @@ function fixture() {
|
||||
headSha: "a".repeat(40),
|
||||
workspaceRelativePath: "packages/app",
|
||||
};
|
||||
vi.mocked(loadCloudModels).mockResolvedValue([
|
||||
{ id: modelId, catalogId: "cline-cloud", name: "Test" },
|
||||
]);
|
||||
const plan: gitPreparation.HandoffGitPlan = {
|
||||
id: "plan",
|
||||
root: "/workspace/project",
|
||||
@@ -118,11 +111,16 @@ function fixture() {
|
||||
status: "idle",
|
||||
startedAt: 1,
|
||||
});
|
||||
const cloud = new CloudSessionManager(ctx, {
|
||||
const cloud = await createCloudSessionTestRuntime(ctx, {
|
||||
api: {} as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "synthetic",
|
||||
});
|
||||
const models = vi
|
||||
.spyOn(cloud, "listModels")
|
||||
.mockResolvedValue([
|
||||
{ id: modelId, catalogId: "cline-cloud", name: "Test" },
|
||||
]);
|
||||
vi.spyOn(cloud, "prepareHandoffRepository").mockResolvedValue({});
|
||||
const create = vi.spyOn(cloud, "create").mockResolvedValue({
|
||||
sessionId: "ses-target",
|
||||
@@ -140,7 +138,6 @@ function fixture() {
|
||||
args[5]?.beforeDispatch?.();
|
||||
return { sessionId: "ses-target", ok: true };
|
||||
});
|
||||
ctx.cloudSessionManager = cloud;
|
||||
const summarize = vi
|
||||
.fn()
|
||||
.mockResolvedValue("Search form: implemented. Next: add tests.");
|
||||
@@ -160,6 +157,9 @@ function fixture() {
|
||||
expect(preview).toMatchObject({ gitPreparation: { id: plan.id } });
|
||||
};
|
||||
return {
|
||||
cloud,
|
||||
models,
|
||||
viewer: getCloudSessionManager(ctx),
|
||||
ctx,
|
||||
sourceSessionId,
|
||||
git,
|
||||
@@ -182,7 +182,7 @@ function fixture() {
|
||||
|
||||
describe("summary cloud handoff", () => {
|
||||
it("returns a missed completion before preparing another cloud task", async () => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
await f.prepare();
|
||||
const result = await f.handoff();
|
||||
const retry = await handlePrepareHandoff(f.ctx, f.request);
|
||||
@@ -210,14 +210,14 @@ describe("summary cloud handoff", () => {
|
||||
await f.prepare();
|
||||
});
|
||||
it("does not return another account's completed handoff", async () => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
await f.prepare();
|
||||
await f.handoff();
|
||||
f.ctx.cloudSessionManager = fixture().ctx.cloudSessionManager;
|
||||
f.ctx.cloudSessionManager = (await fixture()).ctx.cloudSessionManager;
|
||||
await f.prepare();
|
||||
});
|
||||
it("discards only the canceled preview, not a newer preview", async () => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
await f.prepare();
|
||||
await handleDiscardHandoff(f.ctx, {
|
||||
...f.request,
|
||||
@@ -239,7 +239,7 @@ describe("summary cloud handoff", () => {
|
||||
false,
|
||||
true,
|
||||
])("invalidates only turns overlapping capture (captured=%s)", async (captured) => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
const ctx = getEnvironmentContext(f.ctx, "local");
|
||||
vi.mocked(gitPreparation.inspectHandoffGit).mockImplementation(
|
||||
async (_cwd, onCaptured?: () => void) => {
|
||||
@@ -269,7 +269,7 @@ describe("summary cloud handoff", () => {
|
||||
}
|
||||
});
|
||||
it("shows a provisioning failure reason without losing the unsent draft", async () => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
await f.prepare();
|
||||
f.send.mockRejectedValue(
|
||||
new CloudSessionError("session_failed", "Repository clone failed."),
|
||||
@@ -287,7 +287,7 @@ describe("summary cloud handoff", () => {
|
||||
"verification",
|
||||
"model",
|
||||
] as const)("rejects an account reset during %s before further publication or creation", async (stage) => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
await handlePrepareHandoff(f.ctx, f.request);
|
||||
if (stage === "preview") await resetCloudSessionManager(f.ctx);
|
||||
if (stage === "summary")
|
||||
@@ -305,19 +305,53 @@ describe("summary cloud handoff", () => {
|
||||
await resetCloudSessionManager(f.ctx);
|
||||
});
|
||||
if (stage === "model")
|
||||
vi.mocked(loadCloudModels).mockImplementation(async () => {
|
||||
f.models.mockImplementation(async () => {
|
||||
await resetCloudSessionManager(f.ctx);
|
||||
return [{ id: "test-model", catalogId: "cline-cloud", name: "Test" }];
|
||||
});
|
||||
await expect(f.handoff(f.request)).rejects.toThrow("account changed");
|
||||
await expect(f.handoff(f.request)).rejects.toThrow(
|
||||
/account changed|viewer is disposed/,
|
||||
);
|
||||
if (stage === "preview" || stage === "summary")
|
||||
expect(f.apply).not.toHaveBeenCalled();
|
||||
expect(f.ctx.cloudSessionManager).toBeNull();
|
||||
expect(f.create).not.toHaveBeenCalled();
|
||||
expect(f.send).not.toHaveBeenCalled();
|
||||
});
|
||||
it("invalidates a prepared handoff when the same viewer changes account and back", async () => {
|
||||
const f = await fixture();
|
||||
await f.prepare();
|
||||
const scope = f.viewer.scope;
|
||||
if (!scope) throw new Error("Missing connected test scope");
|
||||
f.summarize.mockImplementation(async () => {
|
||||
vi.spyOn(f.viewer, "scope", "get").mockReturnValue({
|
||||
...scope,
|
||||
scopeGeneration: scope.scopeGeneration + 2,
|
||||
});
|
||||
return "Captured summary";
|
||||
});
|
||||
await expect(f.handoff()).rejects.toThrow("account changed");
|
||||
expect(f.apply).not.toHaveBeenCalled();
|
||||
expect(f.create).not.toHaveBeenCalled();
|
||||
expect(f.send).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not expose a completed handoff after the same viewer changes account", async () => {
|
||||
const f = await fixture();
|
||||
await f.prepare();
|
||||
await f.handoff();
|
||||
const scope = f.viewer.scope;
|
||||
if (!scope) throw new Error("Missing connected test scope");
|
||||
vi.spyOn(f.viewer, "scope", "get").mockReturnValue({
|
||||
...scope,
|
||||
scopeGeneration: scope.scopeGeneration + 1,
|
||||
});
|
||||
// The server still has the old scope, so a new preview must also be rejected.
|
||||
await expect(handlePrepareHandoff(f.ctx, f.request)).rejects.toThrow(
|
||||
"account changed",
|
||||
);
|
||||
});
|
||||
it("uses the captured conversation and configuration while the source continues locally", async () => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
const originalMessages = structuredClone(f.messages);
|
||||
const originalConfig = structuredClone(f.request.config);
|
||||
vi.mocked(gitPreparation.inspectHandoffGit).mockImplementation(async () => {
|
||||
@@ -358,7 +392,7 @@ describe("summary cloud handoff", () => {
|
||||
});
|
||||
|
||||
it("rejects competing previews and duplicate confirmed requests", async () => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
const first = handlePrepareHandoff(f.ctx, f.request);
|
||||
await expect(handlePrepareHandoff(f.ctx, f.request)).rejects.toThrow(
|
||||
"already in progress",
|
||||
@@ -385,7 +419,7 @@ describe("summary cloud handoff", () => {
|
||||
});
|
||||
|
||||
it("publishes a dedicated branch, sends one combined prompt with invocation images, and leaves source history alone", async () => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
const preparationProgress: unknown[] = [];
|
||||
f.ctx.wsClients.add({
|
||||
data: { canApproveTools: true },
|
||||
@@ -452,7 +486,7 @@ describe("summary cloud handoff", () => {
|
||||
});
|
||||
|
||||
it("allows bare /cloud with an image and a deliberate second handoff", async () => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
await f.prepare();
|
||||
await f.handoff({
|
||||
...f.request,
|
||||
@@ -471,7 +505,7 @@ describe("summary cloud handoff", () => {
|
||||
});
|
||||
|
||||
it("uses the selected local connection to summarize through the command handler", async () => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
const scoped = getEnvironmentContext(f.ctx, "local");
|
||||
await handlePrepareHandoff(scoped, {
|
||||
...f.request,
|
||||
@@ -515,7 +549,7 @@ describe("summary cloud handoff", () => {
|
||||
"running in Hub",
|
||||
"queued",
|
||||
] as const)("rejects a %s source before Git preparation", async (state) => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
if (state === "busy") {
|
||||
const source = f.ctx.liveSessions.get(f.sourceSessionId);
|
||||
if (!source) throw new Error("Source fixture is missing");
|
||||
@@ -538,7 +572,7 @@ describe("summary cloud handoff", () => {
|
||||
"empty",
|
||||
"empty context",
|
||||
] as const)("does not publish Git or create a cloud task after summary %s", async (failure) => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
if (failure === "failure")
|
||||
f.summarize.mockRejectedValue(new Error("Summary failed"));
|
||||
if (failure === "empty") f.summarize.mockResolvedValue(" ");
|
||||
@@ -558,14 +592,14 @@ describe("summary cloud handoff", () => {
|
||||
"git",
|
||||
"model",
|
||||
] as const)("rejects %s becoming unavailable after preview", async (changed) => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
await f.prepare();
|
||||
if (changed === "git")
|
||||
f.verifyPublished.mockRejectedValue(
|
||||
new Error("Published branch changed"),
|
||||
);
|
||||
else
|
||||
vi.mocked(loadCloudModels).mockResolvedValue([
|
||||
f.models.mockResolvedValue([
|
||||
{ id: "other-model", catalogId: "cline-cloud", name: "Other" },
|
||||
]);
|
||||
await expect(f.handoff()).rejects.toThrow(
|
||||
@@ -580,8 +614,9 @@ describe("summary cloud handoff", () => {
|
||||
it.each([
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
])("returns the prepared prompt only for a definitely unsent failure (dispatched: %s)", async (dispatched) => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
const completed: unknown[] = [];
|
||||
f.ctx.wsClients.add({
|
||||
data: { canApproveTools: true },
|
||||
@@ -595,16 +630,24 @@ describe("summary cloud handoff", () => {
|
||||
},
|
||||
} as unknown as SidecarWebSocketClient);
|
||||
await f.prepare();
|
||||
f.send.mockImplementation(async (...args) => {
|
||||
if (dispatched) args[5]?.beforeDispatch?.();
|
||||
throw new Error("Connection failed");
|
||||
});
|
||||
if (dispatched === undefined) {
|
||||
const send = f.viewer.send.bind(f.viewer);
|
||||
vi.spyOn(f.viewer, "send").mockImplementation(async (...args) => {
|
||||
await send(...args);
|
||||
throw new Error("Reply lost after send");
|
||||
});
|
||||
} else {
|
||||
f.send.mockImplementation(async (...args) => {
|
||||
if (dispatched) args[5]?.beforeDispatch?.();
|
||||
throw new Error("Connection failed");
|
||||
});
|
||||
}
|
||||
const result = await f.handoff({
|
||||
...f.request,
|
||||
nextCommand: "Add tests",
|
||||
attachments: { userImages: [image] },
|
||||
});
|
||||
if (dispatched) expect(result).not.toHaveProperty("unsentPrompt");
|
||||
if (dispatched !== false) expect(result).not.toHaveProperty("unsentPrompt");
|
||||
else
|
||||
expect(result).toHaveProperty(
|
||||
"unsentPrompt",
|
||||
@@ -612,9 +655,9 @@ describe("summary cloud handoff", () => {
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
sessionId: "ses-target",
|
||||
warningKind: dispatched ? "unconfirmed" : "unqueued",
|
||||
warningKind: dispatched !== false ? "unconfirmed" : "unqueued",
|
||||
warning: expect.stringContaining(
|
||||
dispatched
|
||||
dispatched !== false
|
||||
? "delivery is unconfirmed"
|
||||
: "closing it loses the unsent draft",
|
||||
),
|
||||
@@ -623,7 +666,7 @@ describe("summary cloud handoff", () => {
|
||||
});
|
||||
|
||||
it("does not automatically retry unconfirmed creation", async () => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
await f.prepare();
|
||||
f.create.mockRejectedValue(
|
||||
new CloudCreationUnconfirmedError(new Error("Connection lost")),
|
||||
@@ -638,7 +681,7 @@ describe("summary cloud handoff", () => {
|
||||
"count",
|
||||
"bytes",
|
||||
] as const)("validates image %s limits before summarizing or publishing", async (limit) => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
await f.prepare();
|
||||
const images =
|
||||
limit === "count"
|
||||
@@ -658,7 +701,7 @@ describe("summary cloud handoff", () => {
|
||||
});
|
||||
|
||||
it("rejects an unapproved Git preparation", async () => {
|
||||
const f = fixture();
|
||||
const f = await fixture();
|
||||
await handlePrepareHandoff(f.ctx, f.request);
|
||||
await expect(
|
||||
f.handoff({ ...f.request, gitPreparationId: "stale" }),
|
||||
|
||||
@@ -2,8 +2,8 @@ import { randomUUID } from "node:crypto";
|
||||
import { buildCloudHandoffDashboardUrl, type ClineCore } from "@cline/core";
|
||||
import {
|
||||
type CloudCreationOptions,
|
||||
CloudSessionError,
|
||||
loadCloudModels,
|
||||
type CloudHubScope,
|
||||
CloudSendError,
|
||||
} from "@cline/core/cloud";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import {
|
||||
@@ -45,6 +45,7 @@ import type {
|
||||
|
||||
type HandoffSnapshot = {
|
||||
cloud: CloudSessionManager;
|
||||
scope: CloudHubScope;
|
||||
plan: HandoffGitPlan;
|
||||
messages: MessageWithMetadata[];
|
||||
config: JsonRecord;
|
||||
@@ -58,6 +59,7 @@ const gitPreparations = new WeakMap<
|
||||
type HandoffRequest = {
|
||||
id: string;
|
||||
cloud: CloudSessionManager;
|
||||
scope: CloudHubScope;
|
||||
} & ({ status: "running" } | { status: "complete"; result: HandoffResult });
|
||||
const handoffRequests = new WeakMap<
|
||||
SidecarContext,
|
||||
@@ -136,8 +138,13 @@ async function prepareCloudHandoff(
|
||||
function assertHandoffAccount(
|
||||
ctx: SidecarContext,
|
||||
cloud: CloudSessionManager,
|
||||
scope: CloudHubScope,
|
||||
): void {
|
||||
if (getSidecarContextOwner(ctx).cloudSessionManager !== cloud)
|
||||
if (
|
||||
getSidecarContextOwner(ctx).cloudSessionManager !== cloud ||
|
||||
cloud.scope?.serviceId !== scope.serviceId ||
|
||||
cloud.scope?.scopeGeneration !== scope.scopeGeneration
|
||||
)
|
||||
throw new Error(
|
||||
"The cloud account changed. Run /cloud again to review the preparation plan.",
|
||||
);
|
||||
@@ -146,17 +153,19 @@ function assertHandoffAccount(
|
||||
async function resolveCloudModel(
|
||||
ctx: SidecarContext,
|
||||
cloud: CloudSessionManager,
|
||||
scope: CloudHubScope,
|
||||
config: JsonRecord,
|
||||
repoUrl: string,
|
||||
) {
|
||||
assertHandoffAccount(ctx, cloud);
|
||||
const { organizationId } = await cloud.prepareHandoffRepository(repoUrl);
|
||||
assertHandoffAccount(ctx, cloud);
|
||||
assertHandoffAccount(ctx, cloud, scope);
|
||||
const { organizationId } = await cloud.prepareHandoffRepository(
|
||||
repoUrl,
|
||||
scope,
|
||||
);
|
||||
assertHandoffAccount(ctx, cloud, scope);
|
||||
const modelId = String(config.model ?? config.modelId ?? "").trim();
|
||||
const models = await loadCloudModels(getClineEnvironmentConfig().apiBaseUrl, {
|
||||
isOrganizationSession: Boolean(organizationId),
|
||||
});
|
||||
assertHandoffAccount(ctx, cloud);
|
||||
const models = await cloud.listModels(scope);
|
||||
assertHandoffAccount(ctx, cloud, scope);
|
||||
if (!modelId || !models.some((model) => model.id === modelId))
|
||||
throw new Error(
|
||||
`The selected model ${modelId || "(none)"} is not available in Cline Cloud for this account. Select a supported model before continuing.`,
|
||||
@@ -176,6 +185,8 @@ export async function handlePrepareHandoff(
|
||||
if (
|
||||
previous?.status === "complete" &&
|
||||
previous.cloud === getSidecarContextOwner(ctx).cloudSessionManager &&
|
||||
previous.scope.serviceId === previous.cloud.scope?.serviceId &&
|
||||
previous.scope.scopeGeneration === previous.cloud.scope?.scopeGeneration &&
|
||||
previous.id !== request.completedHandoffAttemptId
|
||||
) {
|
||||
return {
|
||||
@@ -194,6 +205,9 @@ export async function handlePrepareHandoff(
|
||||
try {
|
||||
const cloud = getCloudSessionManager(ctx);
|
||||
const source = await prepareCloudHandoff(ctx, request);
|
||||
await cloud.connect();
|
||||
const scope = cloud.scope;
|
||||
if (!scope) throw new Error("Cloud account is unavailable.");
|
||||
const config = structuredClone(source.config);
|
||||
const messages = structuredClone(
|
||||
await source.manager.readLiveMessages(sessionId),
|
||||
@@ -207,14 +221,14 @@ export async function handlePrepareHandoff(
|
||||
);
|
||||
active.set(sessionId, "captured");
|
||||
});
|
||||
await resolveCloudModel(ctx, cloud, config, plan.repoUrl);
|
||||
assertHandoffAccount(ctx, cloud);
|
||||
await resolveCloudModel(ctx, cloud, scope, config, plan.repoUrl);
|
||||
assertHandoffAccount(ctx, cloud, scope);
|
||||
let plans = gitPlans.get(ctx);
|
||||
if (!plans) {
|
||||
plans = new Map();
|
||||
gitPlans.set(ctx, plans);
|
||||
}
|
||||
plans.set(sessionId, { cloud, plan, messages, config });
|
||||
plans.set(sessionId, { cloud, scope, plan, messages, config });
|
||||
return { gitPreparation: previewHandoffGit(plan) };
|
||||
} finally {
|
||||
active.delete(sessionId);
|
||||
@@ -260,8 +274,8 @@ async function handleHandoffOnce(
|
||||
const sourceSessionId = request.sessionId?.trim();
|
||||
if (!sourceSessionId) throw new Error("sessionId is required");
|
||||
gitPlans.get(ctx)?.delete(sourceSessionId);
|
||||
const { cloud, config, messages, plan } = approved;
|
||||
assertHandoffAccount(ctx, cloud);
|
||||
const { cloud, scope, config, messages, plan } = approved;
|
||||
assertHandoffAccount(ctx, cloud, scope);
|
||||
const emitProgress = (phase: string, message: string, sessionId?: string) =>
|
||||
sendEvent(ctx, "cloud_handoff_progress", {
|
||||
sourceSessionId,
|
||||
@@ -281,23 +295,24 @@ async function handleHandoffOnce(
|
||||
const { modelId, organizationId } = await resolveCloudModel(
|
||||
ctx,
|
||||
cloud,
|
||||
scope,
|
||||
config,
|
||||
plan.repoUrl,
|
||||
);
|
||||
assertHandoffAccount(ctx, cloud);
|
||||
assertHandoffAccount(ctx, cloud, scope);
|
||||
emitProgress("summarizing", "Preparing a summary of this conversation.");
|
||||
const summary = (await summarize(messages, config)).trim();
|
||||
assertHandoffAccount(ctx, cloud);
|
||||
assertHandoffAccount(ctx, cloud, scope);
|
||||
if (!summary)
|
||||
throw new Error(
|
||||
"The conversation summary was empty. Nothing was published or started in cloud.",
|
||||
);
|
||||
emitProgress("checking", "Publishing the approved Git checkpoint.");
|
||||
const headSha = await applyHandoffGit(plan);
|
||||
assertHandoffAccount(ctx, cloud);
|
||||
assertHandoffAccount(ctx, cloud, scope);
|
||||
assertCloudHandoffAvailable();
|
||||
await verifyPublishedHandoffGit(plan, headSha);
|
||||
assertHandoffAccount(ctx, cloud);
|
||||
assertHandoffAccount(ctx, cloud, scope);
|
||||
const followUp = request.nextCommand?.trim();
|
||||
const command = [
|
||||
summary,
|
||||
@@ -321,15 +336,18 @@ async function handleHandoffOnce(
|
||||
: {}),
|
||||
reasoningEffort: readReasoningEffort(config.reasoningEffort),
|
||||
};
|
||||
const target = await cloud.create({
|
||||
requestId: request.handoffAttemptId?.trim() || randomUUID(),
|
||||
repoUrl: plan.repoUrl,
|
||||
branch: plan.branch,
|
||||
modelId,
|
||||
organizationId: organizationId ?? null,
|
||||
...initialTask,
|
||||
initialPrompt: command,
|
||||
});
|
||||
const target = await cloud.create(
|
||||
{
|
||||
requestId: request.handoffAttemptId?.trim() || randomUUID(),
|
||||
repoUrl: plan.repoUrl,
|
||||
branch: plan.branch,
|
||||
modelId,
|
||||
organizationId: organizationId ?? null,
|
||||
...initialTask,
|
||||
initialPrompt: command,
|
||||
},
|
||||
scope,
|
||||
);
|
||||
const sessionId = target.sessionId;
|
||||
const dashboardUrl = buildCloudHandoffDashboardUrl(
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
@@ -337,7 +355,6 @@ async function handleHandoffOnce(
|
||||
);
|
||||
let warning: string | undefined;
|
||||
let warningKind: "unqueued" | "unconfirmed" | undefined;
|
||||
let dispatched = false;
|
||||
try {
|
||||
emitProgress("provisioning", "Preparing the cloud workspace.", sessionId);
|
||||
const result = await cloud.send(
|
||||
@@ -346,16 +363,7 @@ async function handleHandoffOnce(
|
||||
undefined,
|
||||
modelId,
|
||||
images,
|
||||
{
|
||||
beforeDispatch: () => {
|
||||
dispatched = true;
|
||||
emitProgress(
|
||||
"sending",
|
||||
"Sending the summary and your instruction.",
|
||||
sessionId,
|
||||
);
|
||||
},
|
||||
},
|
||||
scope,
|
||||
);
|
||||
if (result.recoveredAfterDisconnect) {
|
||||
warningKind = "unconfirmed";
|
||||
@@ -363,14 +371,18 @@ async function handleHandoffOnce(
|
||||
"The cloud connection was interrupted. Check the conversation before resending.";
|
||||
}
|
||||
} catch (error) {
|
||||
warningKind = dispatched ? "unconfirmed" : "unqueued";
|
||||
warningKind =
|
||||
error instanceof CloudSendError && !error.dispatched
|
||||
? "unqueued"
|
||||
: "unconfirmed";
|
||||
warning =
|
||||
warningKind === "unconfirmed"
|
||||
? "The cloud session was created, but delivery is unconfirmed. Check the conversation before resending."
|
||||
: "The cloud workspace was created, but the prompt was not sent. Keep Cline open to retry; closing it loses the unsent draft and may require a new cloud task.";
|
||||
if (error instanceof CloudSessionError)
|
||||
if (error instanceof CloudSendError && error.detail)
|
||||
warning = `${error.detail} ${warning}`;
|
||||
}
|
||||
assertHandoffAccount(ctx, cloud, scope);
|
||||
const result = {
|
||||
sourceSessionId,
|
||||
sessionId,
|
||||
@@ -407,8 +419,8 @@ export async function handleHandoff(
|
||||
if (!approved || approved.plan.id !== request.gitPreparationId)
|
||||
throw new Error("Run /cloud again to review the preparation plan.");
|
||||
const id = request.handoffAttemptId?.trim() || randomUUID();
|
||||
const { cloud } = approved;
|
||||
requests.set(sessionId, { id, cloud, status: "running" });
|
||||
const { cloud, scope } = approved;
|
||||
requests.set(sessionId, { id, cloud, scope, status: "running" });
|
||||
try {
|
||||
const result = await handleHandoffOnce(
|
||||
ctx,
|
||||
@@ -416,7 +428,7 @@ export async function handleHandoff(
|
||||
summarize,
|
||||
approved,
|
||||
);
|
||||
requests.set(sessionId, { id, cloud, status: "complete", result });
|
||||
requests.set(sessionId, { id, cloud, scope, status: "complete", result });
|
||||
return result;
|
||||
} catch (error) {
|
||||
requests.delete(sessionId);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
CloudSessionController,
|
||||
type CloudSessionControllerOptions,
|
||||
} from "@cline/core/cloud";
|
||||
import { HubCloudSessionService } from "@cline/core/hub";
|
||||
import type { HubEventEnvelope } from "@cline/shared";
|
||||
import { afterEach } from "vitest";
|
||||
import { CloudSessionManager } from "./cloud-sessions";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
const services = new Set<HubCloudSessionService>();
|
||||
const viewers = new WeakMap<
|
||||
SidecarContext,
|
||||
() => Promise<CloudSessionManager>
|
||||
>();
|
||||
export async function reconnectCloudSessionTestViewer(
|
||||
ctx: SidecarContext,
|
||||
): Promise<CloudSessionManager> {
|
||||
const connect = viewers.get(ctx);
|
||||
if (!connect) throw new Error("Missing cloud test runtime");
|
||||
return await connect();
|
||||
}
|
||||
afterEach(async () => {
|
||||
await Promise.all([...services].map((service) => service.dispose()));
|
||||
services.clear();
|
||||
});
|
||||
|
||||
export class TestCloudSessionController extends CloudSessionController {
|
||||
constructor(ctx: SidecarContext, options: CloudSessionControllerOptions) {
|
||||
super({
|
||||
...options,
|
||||
lateCreateDisposition: options.lateCreateDisposition ?? "preserve",
|
||||
clientIdentity: options.clientIdentity ?? {
|
||||
prefix: "hub-cloud",
|
||||
type: "hub-cloud",
|
||||
displayName: "Cline Hub cloud session",
|
||||
source: "desktop",
|
||||
},
|
||||
});
|
||||
for (const [id, state] of ctx.liveSessions)
|
||||
if (state.config.executionTarget === "cloud")
|
||||
this.seedSessionState(id, state);
|
||||
}
|
||||
}
|
||||
export async function createCloudSessionTestRuntime(
|
||||
ctx: SidecarContext,
|
||||
options: CloudSessionControllerOptions,
|
||||
): Promise<TestCloudSessionController> {
|
||||
const controller = new TestCloudSessionController(ctx, options);
|
||||
const listeners = new Set<(event: HubEventEnvelope) => void>();
|
||||
const service = new HubCloudSessionService({
|
||||
getScope: () => "test-account",
|
||||
createController: () => controller,
|
||||
publish: (event, version) => {
|
||||
for (const listener of listeners)
|
||||
listener({
|
||||
version: "v1",
|
||||
event: "cloud.event",
|
||||
sessionId: event?.sessionId,
|
||||
payload: { event, ...version },
|
||||
});
|
||||
},
|
||||
});
|
||||
services.add(service);
|
||||
const connectViewer = async () => {
|
||||
const projection = new CloudSessionManager(ctx, async () => ({
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
command: async (command, payload, _sessionId, options) => {
|
||||
await options?.beforeDispatch?.();
|
||||
options?.onDispatch?.("test-request");
|
||||
const serialized = payload
|
||||
? JSON.parse(JSON.stringify(payload))
|
||||
: undefined;
|
||||
const result =
|
||||
command === "cloud.watch"
|
||||
? service.watch()
|
||||
: await service.command(
|
||||
String(serialized?.operation),
|
||||
serialized?.args,
|
||||
serialized?.scope,
|
||||
);
|
||||
return {
|
||||
version: "v1",
|
||||
ok: true,
|
||||
payload: JSON.parse(JSON.stringify(result)),
|
||||
};
|
||||
},
|
||||
}));
|
||||
ctx.cloudSessionManager = projection;
|
||||
await projection.connect();
|
||||
return projection;
|
||||
};
|
||||
viewers.set(ctx, connectViewer);
|
||||
await connectViewer();
|
||||
return controller;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@cline/core";
|
||||
import type { HubEventEnvelope } from "@cline/shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCloudSessionTestRuntime } from "./cloud-session-test-runtime";
|
||||
import {
|
||||
CloudSessionApi,
|
||||
CloudSessionError,
|
||||
@@ -209,14 +210,14 @@ class FakeHubClient {
|
||||
}
|
||||
}
|
||||
|
||||
function createFixture({
|
||||
async function createFixture({
|
||||
hub = new FakeHubClient(),
|
||||
...options
|
||||
}: Partial<ConstructorParameters<typeof CloudSessionManager>[1]> & {
|
||||
}: Partial<import("@cline/core/cloud").CloudSessionControllerOptions> & {
|
||||
hub?: FakeHubClient;
|
||||
} = {}) {
|
||||
const { ctx, events } = createContext();
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: {
|
||||
list: async () => [REMOTE_SESSION],
|
||||
create: createRemote,
|
||||
@@ -238,7 +239,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
"always",
|
||||
"other",
|
||||
])("bounds history retry for %s failure", async (failure) => {
|
||||
const { manager, hub } = createFixture();
|
||||
const { manager, hub } = await createFixture();
|
||||
const connect = vi.spyOn(hub, "connect");
|
||||
await manager.attach("ses-outer");
|
||||
let reads = 0;
|
||||
@@ -277,7 +278,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
};
|
||||
const archive = [{ role: "assistant", content: "saved history" }];
|
||||
const history = vi.fn<() => Promise<unknown[] | null>>(async () => archive);
|
||||
const { manager } = createFixture({
|
||||
const { manager } = await createFixture({
|
||||
hub,
|
||||
api: {
|
||||
list: async () => [
|
||||
@@ -327,7 +328,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
new HubTransportError("hub_connection_closed", "disposed"),
|
||||
);
|
||||
};
|
||||
const { manager, events } = createFixture({
|
||||
const { manager, events } = await createFixture({
|
||||
hub,
|
||||
api: {
|
||||
list: async () => [REMOTE_SESSION],
|
||||
@@ -365,7 +366,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
"session.messages",
|
||||
"session.pending_prompts",
|
||||
])("stops hydration disposed during %s without later commands or events", async (stage) => {
|
||||
const { manager, hub, events } = createFixture();
|
||||
const { manager, hub, events } = await createFixture();
|
||||
await manager.attach("ses-outer");
|
||||
const started = Promise.withResolvers<void>();
|
||||
const blocked = Promise.withResolvers<void>();
|
||||
@@ -401,7 +402,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
});
|
||||
|
||||
it("stops hydration when disposed by a snapshot subscriber", async () => {
|
||||
const { manager } = createFixture();
|
||||
const { manager } = await createFixture();
|
||||
await manager.attach("ses-outer");
|
||||
const unsubscribe = manager.subscribe((event) => {
|
||||
if (event.type === "snapshot" && event.replace) void manager.dispose();
|
||||
@@ -414,7 +415,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
"abort",
|
||||
"dispose",
|
||||
] as const)("%s prevents waiting commands from dispatching without clobbering newer state", async (action) => {
|
||||
const { manager, hub, ctx } = createFixture();
|
||||
const { manager, hub, ctx } = await createFixture();
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
let release!: () => void;
|
||||
@@ -480,7 +481,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
});
|
||||
|
||||
it("waits for reconnect discovery to abort an already-dispatched run", async () => {
|
||||
const { manager, hub, ctx } = createFixture();
|
||||
const { manager, hub, ctx } = await createFixture();
|
||||
await manager.list();
|
||||
await manager.send("ses-outer", "already dispatched");
|
||||
await hub.resolveHeaders?.();
|
||||
@@ -527,7 +528,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
"replaced",
|
||||
"same",
|
||||
] as const)("revalidates the prompt target when reconnect discovery is %s", async (discovery) => {
|
||||
const { manager, hub, ctx } = createFixture();
|
||||
const { manager, hub, ctx } = await createFixture();
|
||||
await manager.attach("ses-outer");
|
||||
await hub.resolveHeaders?.();
|
||||
const blocked = Promise.withResolvers<void>();
|
||||
@@ -584,7 +585,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
"same",
|
||||
"disposed",
|
||||
] as const)("guards queue mutations when the connection is %s at dispatch", async (state) => {
|
||||
const { manager, hub } = createFixture();
|
||||
const { manager, hub } = await createFixture();
|
||||
await manager.attach("ses-outer");
|
||||
await hub.resolveHeaders?.();
|
||||
const blocked = Promise.withResolvers<void>();
|
||||
@@ -642,7 +643,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
"replaced",
|
||||
"same",
|
||||
] as const)("revalidates the Stop target when reconnect discovery is %s at dispatch", async (discovery) => {
|
||||
const { manager, hub, ctx } = createFixture();
|
||||
const { manager, hub, ctx } = await createFixture();
|
||||
await manager.list();
|
||||
await manager.send("ses-outer", "running prompt");
|
||||
await hub.resolveHeaders?.();
|
||||
@@ -709,7 +710,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
});
|
||||
|
||||
it("does not acknowledge Stop when reconnect discovery keeps failing", async () => {
|
||||
const { manager, hub, ctx } = createFixture();
|
||||
const { manager, hub, ctx } = await createFixture();
|
||||
await manager.list();
|
||||
await manager.send("ses-outer", "running prompt");
|
||||
await hub.resolveHeaders?.();
|
||||
@@ -733,7 +734,9 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
"session.list",
|
||||
"session.create",
|
||||
])("cancels a send stopped while %s is pending", async (blockedCommand) => {
|
||||
const { manager, hub } = createFixture({ hub: new FakeHubClient(false) });
|
||||
const { manager, hub } = await createFixture({
|
||||
hub: new FakeHubClient(false),
|
||||
});
|
||||
await manager.create(CREATE_INPUT);
|
||||
let release!: () => void;
|
||||
hub.commandHook = (command) => {
|
||||
@@ -763,7 +766,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
"dispose",
|
||||
] as const)("%s cancels a provisioning send before opening the Hub", async (action) => {
|
||||
let provisioningSignal: AbortSignal | undefined;
|
||||
const { manager, hub, ctx } = createFixture({
|
||||
const { manager, hub, ctx } = await createFixture({
|
||||
api: {
|
||||
create: async () => ({
|
||||
sessionId: "ses-outer",
|
||||
@@ -796,7 +799,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
expect(ctx.liveSessions.has("ses-outer")).toBe(action === "abort");
|
||||
});
|
||||
it("reuses the newest inner Hub session and translates events to the outer id", async () => {
|
||||
const { manager, events, hub } = createFixture();
|
||||
const { manager, events, hub } = await createFixture();
|
||||
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
@@ -830,7 +833,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
["run.failed", "error"],
|
||||
["run.aborted", "aborted"],
|
||||
] as const)("ignores stale running snapshots after %s", async (event, status) => {
|
||||
const { manager, ctx, events, hub } = createFixture();
|
||||
const { manager, ctx, events, hub } = await createFixture();
|
||||
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
@@ -863,7 +866,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
"session.pending_prompt_submitted",
|
||||
"session.attached",
|
||||
] as const)("accepts a new turn after completion through %s", async (event) => {
|
||||
const { manager, ctx, events, hub } = createFixture();
|
||||
const { manager, ctx, events, hub } = await createFixture();
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
const envelope = { version: "v1" as const, sessionId: "inner-1" };
|
||||
@@ -920,7 +923,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
metadata: { parentSessionId: "inner-root" },
|
||||
},
|
||||
];
|
||||
const { manager } = createFixture({ hub });
|
||||
const { manager } = await createFixture({ hub });
|
||||
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
@@ -937,7 +940,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
const clientIds: string[] = [];
|
||||
const hubs = [new FakeHubClient(), new FakeHubClient()];
|
||||
for (const hub of hubs) {
|
||||
const { manager } = createFixture({
|
||||
const { manager } = await createFixture({
|
||||
createHubClient: (options) => {
|
||||
clientIds.push(String(options.clientId));
|
||||
return hub as never;
|
||||
@@ -949,8 +952,8 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
|
||||
expect(new Set(clientIds).size).toBe(2);
|
||||
expect(clientIds).toEqual([
|
||||
expect.stringMatching(/^code-cloud-ses-outer-/),
|
||||
expect.stringMatching(/^code-cloud-ses-outer-/),
|
||||
expect.stringMatching(/^hub-cloud-ses-outer-/),
|
||||
expect.stringMatching(/^hub-cloud-ses-outer-/),
|
||||
]);
|
||||
expect(hubs.map((hub) => hub.subscriptionSessionIds)).toEqual([
|
||||
["ses-outer", "inner-1"],
|
||||
@@ -969,7 +972,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
}
|
||||
return await originalCommand(command, payload, sessionId, options);
|
||||
};
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: {
|
||||
list: async () => [
|
||||
{
|
||||
@@ -1020,7 +1023,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
});
|
||||
vi.spyOn(api, "list").mockResolvedValue([REMOTE_SESSION]);
|
||||
vi.spyOn(api, "history").mockResolvedValue(archive);
|
||||
const { manager } = createFixture({
|
||||
const { manager } = await createFixture({
|
||||
hub,
|
||||
api,
|
||||
});
|
||||
@@ -1050,7 +1053,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
hub.commandHook = async (command) => {
|
||||
if (command === "session.list") await blocked;
|
||||
};
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: { list: async () => [REMOTE_SESSION] } as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
@@ -1089,7 +1092,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
throw new HubTransportError("hub_connect_failed", "pod starting");
|
||||
}
|
||||
})();
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: { list: async () => [REMOTE_SESSION] } as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
@@ -1115,7 +1118,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
});
|
||||
|
||||
it("deduplicates the latest 2,000 Hub event IDs and evicts oldest first", async () => {
|
||||
const { manager, events, hub } = createFixture();
|
||||
const { manager, events, hub } = await createFixture();
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
const replayed: HubEventEnvelope = {
|
||||
@@ -1162,7 +1165,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
it("resolves fresh bearer headers for each WebSocket connection attempt", async () => {
|
||||
const hub = new FakeHubClient();
|
||||
const tokens = ["workos:first", "workos:refreshed"];
|
||||
const { manager, events } = createFixture({
|
||||
const { manager, events } = await createFixture({
|
||||
getAuthToken: async () => tokens.shift(),
|
||||
hub,
|
||||
});
|
||||
@@ -1216,7 +1219,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
const { ctx } = createContext();
|
||||
const hub = new FakeHubClient();
|
||||
let resolveHeaders: (() => unknown) | undefined;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: { list: async () => [REMOTE_SESSION] } as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
@@ -1258,7 +1261,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
it.each([
|
||||
0, 2_000,
|
||||
])("preserves post-snapshot output after buffering %s events", async (count) => {
|
||||
const { manager, events, hub } = createFixture();
|
||||
const { manager, events, hub } = await createFixture();
|
||||
await manager.attach("ses-outer");
|
||||
hub.messages = [{ role: "assistant", content: "OK" }];
|
||||
const emit = (event: HubEventEnvelope["event"], payload = {}) =>
|
||||
@@ -1296,7 +1299,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
};
|
||||
let organizationLookups = 0;
|
||||
const listScopes: Array<string | undefined> = [];
|
||||
const { manager, events } = createFixture({
|
||||
const { manager, events } = await createFixture({
|
||||
api: {
|
||||
list: async (organizationId?: string) => {
|
||||
listScopes.push(organizationId);
|
||||
@@ -1338,7 +1341,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
throw new Error("rehydration failed");
|
||||
}
|
||||
};
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: {
|
||||
list: async () => [
|
||||
{
|
||||
@@ -1373,7 +1376,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
});
|
||||
|
||||
it("maps cloud approvals into the existing UI and responds on the inner id", async () => {
|
||||
const { manager, ctx, events, hub } = createFixture();
|
||||
const { manager, ctx, events, hub } = await createFixture();
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
|
||||
@@ -1419,7 +1422,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
inputJson: '{"path":"README.md"}',
|
||||
},
|
||||
];
|
||||
const { manager, ctx, events } = createFixture({ hub });
|
||||
const { manager, ctx, events } = await createFixture({ hub });
|
||||
await manager.list();
|
||||
|
||||
await manager.attach("ses-outer");
|
||||
@@ -1459,7 +1462,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
hub.pendingApprovals = [
|
||||
{ approvalId: "approval-replay", toolName: "run_commands" },
|
||||
];
|
||||
const { manager, ctx, events } = createFixture({ hub });
|
||||
const { manager, ctx, events } = await createFixture({ hub });
|
||||
await manager.attach("ses-outer");
|
||||
expect(ctx.pendingApprovals.size).toBe(1);
|
||||
// The durable copy and pending replay share an event ID.
|
||||
@@ -1492,7 +1495,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
});
|
||||
|
||||
it("does not restore pending approvals after the manager is disposed", async () => {
|
||||
const { manager, ctx, events, hub } = createFixture();
|
||||
const { manager, ctx, events, hub } = await createFixture();
|
||||
await manager.list();
|
||||
|
||||
await manager.attach("ses-outer");
|
||||
@@ -1522,7 +1525,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
[" tsk-01ABCDEF1234ABCD ", "cline/1234abcd"],
|
||||
[undefined, "cline/es-outer"],
|
||||
])("includes save-work guidance when the task id is %s", async (taskId, branch) => {
|
||||
const { manager, hub } = createFixture({
|
||||
const { manager, hub } = await createFixture({
|
||||
hub: new FakeHubClient(false),
|
||||
api: {
|
||||
create: createRemote,
|
||||
@@ -1554,7 +1557,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
});
|
||||
|
||||
it("creates and sends to an inner session while preserving the outer id", async () => {
|
||||
const { manager, hub } = createFixture({
|
||||
const { manager, hub } = await createFixture({
|
||||
hub: new FakeHubClient(false),
|
||||
api: {
|
||||
list: async () => [
|
||||
@@ -1618,7 +1621,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
it("preserves the live Hub model across REST discovery refreshes", async () => {
|
||||
const hub = new FakeHubClient();
|
||||
hub.listedModel = "anthropic/claude-opus-4-1";
|
||||
const { manager } = createFixture({
|
||||
const { manager } = await createFixture({
|
||||
hub,
|
||||
api: {
|
||||
list: async () => [
|
||||
@@ -1657,7 +1660,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
const hub = new FakeHubClient();
|
||||
const originalModel = REMOTE_SESSION.metadata.modelId ?? "";
|
||||
const externalModel = "anthropic/claude-opus-4-1";
|
||||
const { manager, ctx } = createFixture({ hub });
|
||||
const { manager, ctx } = await createFixture({ hub });
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
|
||||
@@ -1692,7 +1695,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
});
|
||||
|
||||
it("rejects a model update to a stale reconnect target and allows retry", async () => {
|
||||
const { manager, hub } = createFixture({
|
||||
const { manager, hub } = await createFixture({
|
||||
api: {
|
||||
list: async () => [structuredClone(REMOTE_SESSION)],
|
||||
} as CloudSessionApi,
|
||||
@@ -1750,7 +1753,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
const hub = new FakeHubClient();
|
||||
const selectedModel = REMOTE_SESSION.metadata.modelId ?? "";
|
||||
const externalModel = "anthropic/claude-opus-4-1";
|
||||
const { manager, ctx } = createFixture({ hub });
|
||||
const { manager, ctx } = await createFixture({ hub });
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
hub.attachedModel = externalModel;
|
||||
@@ -1768,7 +1771,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
it("disposes the Hub connection before deleting the outer session", async () => {
|
||||
const hub = new FakeHubClient();
|
||||
let deleted = "";
|
||||
const { manager, ctx } = createFixture({
|
||||
const { manager, ctx } = await createFixture({
|
||||
hub,
|
||||
api: {
|
||||
list: async () => [REMOTE_SESSION],
|
||||
@@ -1791,7 +1794,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
const hub = new FakeHubClient();
|
||||
const { promise: deleteBlocked, resolve: releaseDelete } =
|
||||
Promise.withResolvers<void>();
|
||||
const { manager, ctx } = createFixture({
|
||||
const { manager, ctx } = await createFixture({
|
||||
hub,
|
||||
api: {
|
||||
list: async () => [REMOTE_SESSION],
|
||||
@@ -1819,7 +1822,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
});
|
||||
|
||||
it("still cleans up locally when the session is already gone remotely", async () => {
|
||||
const { manager, ctx, hub } = createFixture({
|
||||
const { manager, ctx, hub } = await createFixture({
|
||||
api: {
|
||||
list: async () => [REMOTE_SESSION],
|
||||
delete: async () => {
|
||||
@@ -1838,7 +1841,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
it("reaps the connection when the sidebar poll reports the session expired", async () => {
|
||||
const hub = new FakeHubClient();
|
||||
let expired = false;
|
||||
const { manager, ctx } = createFixture({
|
||||
const { manager, ctx } = await createFixture({
|
||||
hub,
|
||||
api: {
|
||||
list: async () =>
|
||||
@@ -1871,7 +1874,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
it("clears expired-session approvals while preserving other sessions", async () => {
|
||||
const hub = new FakeHubClient();
|
||||
let expired = false;
|
||||
const { manager, ctx, events } = createFixture({
|
||||
const { manager, ctx, events } = await createFixture({
|
||||
hub,
|
||||
api: {
|
||||
list: async () =>
|
||||
@@ -1927,21 +1930,21 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
).rejects.toThrow(/closed/i);
|
||||
});
|
||||
|
||||
it("drops authenticated cloud connections when account context changes", async () => {
|
||||
const { manager, ctx, hub } = createFixture();
|
||||
ctx.cloudSessionManager = manager;
|
||||
it("keeps hub-owned cloud connections when the desktop viewer disconnects", async () => {
|
||||
const { manager, ctx, hub } = await createFixture();
|
||||
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
|
||||
await resetCloudSessionManager(ctx);
|
||||
|
||||
expect(hub.disposed).toBe(true);
|
||||
expect(hub.disposed).toBe(false);
|
||||
expect(ctx.cloudSessionManager).toBeNull();
|
||||
expect(ctx.liveSessions.has("ses-outer")).toBe(false);
|
||||
});
|
||||
|
||||
it("propagates the live error when hydration fails and no snapshot exists", async () => {
|
||||
const { manager, ctx } = createFixture({
|
||||
const { manager, ctx } = await createFixture({
|
||||
api: {
|
||||
list: async () => [REMOTE_SESSION],
|
||||
history: async () => null,
|
||||
@@ -1950,7 +1953,6 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
throw new Error("sandbox unreachable");
|
||||
},
|
||||
});
|
||||
ctx.cloudSessionManager = manager;
|
||||
|
||||
await expect(manager.readMessages(REMOTE_SESSION.id)).rejects.toThrow(
|
||||
"sandbox unreachable",
|
||||
@@ -1964,7 +1966,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
expiredAt: "2026-08-04T00:00:00.000Z",
|
||||
};
|
||||
let historyCalls = 0;
|
||||
const { manager, ctx } = createFixture({
|
||||
const { manager, ctx } = await createFixture({
|
||||
api: {
|
||||
list: async () => [expired],
|
||||
create: async () => {
|
||||
@@ -1979,7 +1981,6 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
throw new Error("expired sessions must not open a websocket");
|
||||
},
|
||||
});
|
||||
ctx.cloudSessionManager = manager;
|
||||
|
||||
const attached = await manager.attach(expired.id);
|
||||
expect(attached).toMatchObject({
|
||||
@@ -1998,7 +1999,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
});
|
||||
|
||||
it("falls back to archived history when live hydration fails", async () => {
|
||||
const { manager, ctx } = createFixture({
|
||||
const { manager, ctx } = await createFixture({
|
||||
api: {
|
||||
list: async () => [REMOTE_SESSION],
|
||||
create: async () => {
|
||||
@@ -2010,7 +2011,6 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
throw new Error("sandbox unreachable");
|
||||
},
|
||||
});
|
||||
ctx.cloudSessionManager = manager;
|
||||
|
||||
const messages = await manager.readMessages(REMOTE_SESSION.id);
|
||||
expect(messages).toEqual([{ role: "assistant", content: "snapshot" }]);
|
||||
@@ -2018,7 +2018,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
|
||||
it("keeps server provisioning rows visible and reconciles their status", async () => {
|
||||
let status = "provisioning";
|
||||
const { manager } = createFixture({
|
||||
const { manager } = await createFixture({
|
||||
api: {
|
||||
list: async () => [{ ...REMOTE_SESSION, status: "provisioning" }],
|
||||
status: async () => ({ sessionId: REMOTE_SESSION.id, status }),
|
||||
@@ -2068,8 +2068,9 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
const { manager, ctx } = createFixture({
|
||||
const { manager, ctx } = await createFixture({
|
||||
api,
|
||||
lateCreateDisposition: "delete",
|
||||
getAuthToken: async () => authToken,
|
||||
});
|
||||
const creating = manager.create({
|
||||
@@ -2100,7 +2101,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
const orgSession = { ...REMOTE_SESSION, id: "ses-org" };
|
||||
const hub = new FakeHubClient();
|
||||
vi.spyOn(hub, "connect").mockRejectedValue(new Error("pod offline"));
|
||||
const { manager, ctx } = createFixture({
|
||||
const { manager, ctx } = await createFixture({
|
||||
hub,
|
||||
api: {
|
||||
list: async (organizationId?: string) => {
|
||||
@@ -2110,7 +2111,6 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
} as unknown as CloudSessionApi,
|
||||
getActiveOrganizationId: async () => "org-cline-bot",
|
||||
});
|
||||
ctx.cloudSessionManager = manager;
|
||||
|
||||
await manager.list();
|
||||
await expect(manager.attach("ses-org")).rejects.toThrow("pod offline");
|
||||
@@ -2122,7 +2122,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
let clientCount = 0;
|
||||
let failNextInnerCreate = true;
|
||||
const clients: FakeHubClient[] = [];
|
||||
const { manager, ctx } = createFixture({
|
||||
const { manager, ctx } = await createFixture({
|
||||
api: {
|
||||
create: createRemote,
|
||||
list: async () => [{ ...REMOTE_SESSION, title: undefined }],
|
||||
@@ -2140,7 +2140,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
return hub as never;
|
||||
},
|
||||
});
|
||||
ctx.cloudSessionManager = manager;
|
||||
|
||||
await manager.create(CREATE_INPUT);
|
||||
await manager.list();
|
||||
|
||||
@@ -2156,14 +2156,14 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
});
|
||||
|
||||
it("single-flights inner-session creation under concurrent sends", async () => {
|
||||
const { manager, ctx, hub } = createFixture({
|
||||
const { manager, ctx, hub } = await createFixture({
|
||||
hub: new FakeHubClient(false),
|
||||
api: {
|
||||
create: createRemote,
|
||||
list: async () => [{ ...REMOTE_SESSION, title: undefined }],
|
||||
} as unknown as CloudSessionApi,
|
||||
});
|
||||
ctx.cloudSessionManager = manager;
|
||||
|
||||
await manager.create(CREATE_INPUT);
|
||||
await manager.list();
|
||||
|
||||
@@ -2186,7 +2186,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
id: "ses-personal",
|
||||
title: undefined,
|
||||
};
|
||||
const { manager, ctx } = createFixture({
|
||||
const { manager, ctx } = await createFixture({
|
||||
hub,
|
||||
api: {
|
||||
list: async (organizationId?: string) =>
|
||||
@@ -2194,7 +2194,6 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
} as unknown as CloudSessionApi,
|
||||
getActiveOrganizationId: async () => scope,
|
||||
});
|
||||
ctx.cloudSessionManager = manager;
|
||||
|
||||
await manager.list();
|
||||
await manager.attach("ses-org-a");
|
||||
@@ -2219,7 +2218,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
toolName: "run_commands",
|
||||
},
|
||||
];
|
||||
const { manager, ctx, events } = createFixture({ hub });
|
||||
const { manager, ctx, events } = await createFixture({ hub });
|
||||
hub.commandHook = async (command) => {
|
||||
if (command === "session.attach") throw new Error("attach failed");
|
||||
};
|
||||
@@ -2236,7 +2235,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
|
||||
it("does not respond after disposal races an approval resolver", async () => {
|
||||
const hub = new FakeHubClient();
|
||||
const { manager, ctx } = createFixture({ hub });
|
||||
const { manager, ctx } = await createFixture({ hub });
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
hub.events?.({
|
||||
@@ -2274,7 +2273,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
|
||||
it("preserves replacement approvals when initial setup finishes after disposal", async () => {
|
||||
const hub = new FakeHubClient();
|
||||
const { manager, ctx } = createFixture({ hub });
|
||||
const { manager, ctx } = await createFixture({ hub });
|
||||
const reached = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
hub.commandHook = async (command) => {
|
||||
@@ -2304,7 +2303,7 @@ describe("CloudSessionManager Hub runtime", () => {
|
||||
|
||||
it("does not resurrect approvals from a buffered snapshot after disposal", async () => {
|
||||
const hub = new FakeHubClient();
|
||||
const { manager, ctx, events } = createFixture({ hub });
|
||||
const { manager, ctx, events } = await createFixture({ hub });
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
const reached = Promise.withResolvers<void>();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCloudSessionTestRuntime } from "./cloud-session-test-runtime";
|
||||
import {
|
||||
type CloudSessionApi,
|
||||
CloudSessionError,
|
||||
@@ -56,7 +57,7 @@ describe("CloudSessionManager lifecycle", () => {
|
||||
const create = vi.fn(async () => {
|
||||
throw new CloudSessionError("request_failed", message, undefined, 502);
|
||||
});
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: { create } as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
@@ -71,7 +72,7 @@ describe("CloudSessionManager lifecycle", () => {
|
||||
expect(create).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("projects the outer remote-session id as the desktop session id", () => {
|
||||
it("projects the outer remote-session id as the desktop session id", async () => {
|
||||
expect(
|
||||
cloudSessionToDiscoveryRecord({
|
||||
...REMOTE_SESSION,
|
||||
@@ -98,7 +99,7 @@ describe("CloudSessionManager lifecycle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a live session's future expiredAt as a TTL, not an end time", () => {
|
||||
it("treats a live session's future expiredAt as a TTL, not an end time", async () => {
|
||||
const alive = cloudSessionToDiscoveryRecord({
|
||||
...REMOTE_SESSION,
|
||||
expiredAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
@@ -124,7 +125,7 @@ describe("CloudSessionManager lifecycle", () => {
|
||||
prompt: "Fix reconnect behavior\nwith a regression test",
|
||||
attachedViaHub: true,
|
||||
});
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: { list: async () => [REMOTE_SESSION] } as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
@@ -151,7 +152,7 @@ describe("CloudSessionManager lifecycle", () => {
|
||||
] as const)("reconciles a %s session without a Hub connection", async (status) => {
|
||||
const { ctx } = createContext();
|
||||
const remote: CloudSessionRecord = { ...REMOTE_SESSION };
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: {
|
||||
create: async () => ({
|
||||
sessionId: remote.id,
|
||||
@@ -216,7 +217,7 @@ describe("CloudSessionManager lifecycle", () => {
|
||||
sandboxUrl: string;
|
||||
}) => void)
|
||||
| undefined;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: {
|
||||
list: async () => [],
|
||||
create: () => {
|
||||
@@ -255,7 +256,7 @@ describe("CloudSessionManager lifecycle", () => {
|
||||
it("keeps identical starts from separate chats independent", async () => {
|
||||
const { ctx } = createContext();
|
||||
let createCalls = 0;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: {
|
||||
list: async () => [],
|
||||
create: async () => {
|
||||
@@ -296,7 +297,7 @@ describe("CloudSessionManager lifecycle", () => {
|
||||
? Promise.reject(new Error("Cloud list unavailable"))
|
||||
: refresh.promise,
|
||||
);
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: {
|
||||
create: async () => ({
|
||||
sessionId: "ses-created",
|
||||
@@ -326,7 +327,6 @@ describe("CloudSessionManager lifecycle", () => {
|
||||
expect.objectContaining({
|
||||
sessionId: "ses-created",
|
||||
origin: "cloud",
|
||||
environmentId: "local",
|
||||
prompt: "Fix this",
|
||||
}),
|
||||
]);
|
||||
@@ -356,7 +356,7 @@ describe("CloudSessionManager lifecycle", () => {
|
||||
if (result === "ready") return { status: "ready" };
|
||||
throw new CloudSessionError(result, "Status check failed");
|
||||
});
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: {
|
||||
create: async () => ({
|
||||
sessionId: "ses-created",
|
||||
@@ -389,7 +389,7 @@ describe("CloudSessionManager lifecycle", () => {
|
||||
const { ctx } = createContext();
|
||||
let listCalls = 0;
|
||||
let finishRefresh: ((value: CloudSessionRecord[]) => void) | undefined;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: {
|
||||
list: async () => {
|
||||
listCalls += 1;
|
||||
@@ -420,7 +420,7 @@ describe("CloudSessionManager lifecycle", () => {
|
||||
const branchScopes: Array<string | undefined> = [];
|
||||
let createInput: Record<string, unknown> | undefined;
|
||||
const orgSession = { ...REMOTE_SESSION, id: "ses-org", title: undefined };
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: {
|
||||
list: async (organizationId?: string) => {
|
||||
listCalls.push(organizationId);
|
||||
@@ -473,7 +473,7 @@ describe("CloudSessionManager lifecycle", () => {
|
||||
let cachedScope = serverScope;
|
||||
const lookupOptions: Array<{ fresh?: boolean } | undefined> = [];
|
||||
let createInput: Record<string, unknown> | undefined;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: {
|
||||
list: async () => [],
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
@@ -508,7 +508,7 @@ describe("CloudSessionManager lifecycle", () => {
|
||||
it("does not silently bill personal credits when account scope lookup fails", async () => {
|
||||
const { ctx } = createContext();
|
||||
let createInput: Record<string, unknown> | undefined;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api: {
|
||||
list: async () => [],
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
import type { HubEventEnvelope } from "@cline/shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CloudSessionApi,
|
||||
CloudSessionManager,
|
||||
type CloudSessionRecord,
|
||||
} from "./cloud-sessions";
|
||||
createCloudSessionTestRuntime,
|
||||
type TestCloudSessionController,
|
||||
} from "./cloud-session-test-runtime";
|
||||
import { CloudSessionApi, type CloudSessionRecord } from "./cloud-sessions";
|
||||
import { createSidecarContext } from "./context";
|
||||
import type { LiveSession } from "./types";
|
||||
|
||||
@@ -43,7 +43,7 @@ async function createFixture() {
|
||||
});
|
||||
vi.spyOn(api, "list").mockResolvedValue([remote]);
|
||||
const updateTitle = vi.spyOn(api, "updateTitle").mockResolvedValue(remote);
|
||||
const manager = new CloudSessionManager(ctx, { ...options, api });
|
||||
const manager = await createCloudSessionTestRuntime(ctx, { ...options, api });
|
||||
const replies: Record<string, Record<string, unknown>> = {
|
||||
"session.get": { session: { status: "idle" } },
|
||||
"session.messages": { messages: [] },
|
||||
@@ -66,7 +66,7 @@ async function createFixture() {
|
||||
},
|
||||
);
|
||||
const connection: Awaited<
|
||||
ReturnType<CloudSessionManager["ensureConnection"]>
|
||||
ReturnType<TestCloudSessionController["ensureConnection"]>
|
||||
> = {
|
||||
remote,
|
||||
innerSessionId: "inner-1",
|
||||
@@ -113,7 +113,7 @@ const transportError = () =>
|
||||
new HubTransportError("hub_connection_closed", "socket closed");
|
||||
|
||||
describe("CloudSessionManager state", () => {
|
||||
it("honors the dispatch hook before sending through the desktop adapter", async () => {
|
||||
it("honors the dispatch hook in the hub-owned controller", async () => {
|
||||
const { manager, command, reply, updateTitle, live } =
|
||||
await createFixture();
|
||||
const dispatch = vi.fn();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { HubEventEnvelope } from "@cline/shared";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { handleChatSessionCommand } from "./chat-session";
|
||||
import {
|
||||
createCloudSessionTestRuntime,
|
||||
reconnectCloudSessionTestViewer,
|
||||
} from "./cloud-session-test-runtime";
|
||||
import {
|
||||
type CloudSessionApi,
|
||||
CloudSessionError,
|
||||
@@ -128,7 +132,7 @@ class FakeHubClient {
|
||||
async dispose(): Promise<void> {}
|
||||
}
|
||||
|
||||
function createFixture({
|
||||
async function createFixture({
|
||||
hub = new FakeHubClient(),
|
||||
api = {
|
||||
list: async () => [
|
||||
@@ -140,13 +144,13 @@ function createFixture({
|
||||
api?: CloudSessionApi;
|
||||
} = {}) {
|
||||
const { ctx, events } = createContext();
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
const manager = await createCloudSessionTestRuntime(ctx, {
|
||||
api,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
createHubClient: () => hub as never,
|
||||
});
|
||||
ctx.cloudSessionManager = manager;
|
||||
|
||||
return { ctx, events, hub, manager };
|
||||
}
|
||||
|
||||
@@ -158,6 +162,50 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe("Cloud sessions sidecar wiring", () => {
|
||||
it("clears resolved approvals when a replacement snapshot has an empty approval list", async () => {
|
||||
const { ctx, events } = createContext();
|
||||
const viewer = new CloudSessionManager(ctx);
|
||||
const approval = {
|
||||
requestId: "request",
|
||||
sessionId: "ses-one",
|
||||
approvalId: "approval",
|
||||
createdAt: "2026-10-20",
|
||||
toolCallId: "tool",
|
||||
toolName: "bash",
|
||||
};
|
||||
const snapshot = {
|
||||
sessionId: "ses-one",
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
approvals: [approval],
|
||||
busy: true,
|
||||
status: "running",
|
||||
startedAt: 1,
|
||||
connectionState: "connected" as const,
|
||||
transcriptKnown: true,
|
||||
};
|
||||
viewer["project"]({
|
||||
type: "snapshot",
|
||||
sessionId: "ses-one",
|
||||
snapshot,
|
||||
replace: true,
|
||||
});
|
||||
expect(ctx.pendingApprovals.has("request")).toBe(true);
|
||||
viewer["project"]({
|
||||
type: "snapshot",
|
||||
sessionId: "ses-one",
|
||||
snapshot: { ...snapshot, approvals: [] },
|
||||
replace: true,
|
||||
});
|
||||
expect(ctx.pendingApprovals.size).toBe(0);
|
||||
expect(
|
||||
events.filter((event) => event.name === "tool_approval_state").at(-1)
|
||||
?.payload.items,
|
||||
).toEqual([]);
|
||||
await viewer.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"local",
|
||||
"ssh-remote",
|
||||
@@ -180,7 +228,7 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
process.env.CLINE_CODE_CLOUD_AGENTS = "0";
|
||||
try {
|
||||
const create = vi.fn();
|
||||
const { ctx, hub } = createFixture({
|
||||
const { ctx, hub } = await createFixture({
|
||||
api: { create } as unknown as CloudSessionApi,
|
||||
});
|
||||
const connect = vi.spyOn(hub, "connect");
|
||||
@@ -221,7 +269,7 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
expect(result[0]).toMatchObject({ sessionId: "ses-active" });
|
||||
});
|
||||
|
||||
it("does not project a live cloud session through local discovery", () => {
|
||||
it("does not project a live cloud session through local discovery", async () => {
|
||||
const { ctx } = createContext();
|
||||
ctx.liveSessions.set("ses-cloud", {
|
||||
busy: true,
|
||||
@@ -239,14 +287,18 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
});
|
||||
|
||||
it("keeps cloud discovery and run events local after switching to SSH", async () => {
|
||||
const { ctx, events, hub, manager } = createFixture();
|
||||
const { ctx, events, hub, manager } = await createFixture();
|
||||
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
ctx.activeEnvironmentId = "ssh-remote";
|
||||
for (const session of [
|
||||
...(await manager.listForDiscovery()),
|
||||
manager.getCachedDiscoveryRecord("ses-outer"),
|
||||
...(await (
|
||||
ctx.cloudSessionManager as CloudSessionManager
|
||||
).listForDiscovery()),
|
||||
(ctx.cloudSessionManager as CloudSessionManager).getCachedDiscoveryRecord(
|
||||
"ses-outer",
|
||||
),
|
||||
]) {
|
||||
expect(session).toMatchObject({
|
||||
environmentId: "local",
|
||||
@@ -277,7 +329,7 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
});
|
||||
|
||||
it("updates cloud settings from an SSH-scoped command before sending", async () => {
|
||||
const { ctx, hub, manager } = createFixture();
|
||||
const { ctx, hub, manager } = await createFixture();
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
|
||||
@@ -312,8 +364,34 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
false,
|
||||
true,
|
||||
])("waits for cloud preferences before sending (failure: %s)", async (failure) => {
|
||||
const { ctx, hub, manager } = await createFixture();
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
const viewer = getCloudSessionManager(ctx);
|
||||
const restoring = Promise.withResolvers<void>();
|
||||
vi.spyOn(viewer, "restoreCreationOptions").mockReturnValueOnce(
|
||||
restoring.promise,
|
||||
);
|
||||
const sending = viewer.send("ses-outer", "Next turn");
|
||||
const outcome = failure
|
||||
? expect(sending).rejects.toThrow("Preferences unavailable")
|
||||
: expect(sending).resolves.toMatchObject({ ok: true });
|
||||
expect(
|
||||
hub.commands.some(({ command }) => command === "session.send_input"),
|
||||
).toBe(false);
|
||||
if (failure) restoring.reject(new Error("Preferences unavailable"));
|
||||
else restoring.resolve();
|
||||
await outcome;
|
||||
expect(
|
||||
hub.commands.some(({ command }) => command === "session.send_input"),
|
||||
).toBe(!failure);
|
||||
});
|
||||
it("forwards image-only cloud messages and rejects file attachments", async () => {
|
||||
const { ctx, hub, manager } = createFixture();
|
||||
const { ctx, hub, manager } = await createFixture();
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
const image = "data:image/png;base64,aGVsbG8=";
|
||||
@@ -410,7 +488,7 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
});
|
||||
|
||||
it("bridges pending-prompt events and queue commands to the hub", async () => {
|
||||
const { ctx, events, hub, manager } = createFixture();
|
||||
const { ctx, events, hub, manager } = await createFixture();
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
|
||||
@@ -490,7 +568,7 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
status: "ready",
|
||||
sandboxUrl: "pod",
|
||||
}));
|
||||
const { ctx, hub, manager } = createFixture({
|
||||
const { ctx, hub, manager } = await createFixture({
|
||||
hub: new FakeHubClient(false),
|
||||
api: {
|
||||
list: async () => [],
|
||||
@@ -545,7 +623,7 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
list: async () => [session],
|
||||
create: async () => ({ sessionId: session.id, status: "ready" }),
|
||||
} as unknown as CloudSessionApi;
|
||||
const { ctx, manager } = createFixture({
|
||||
const { ctx, manager, hub } = await createFixture({
|
||||
hub: new FakeHubClient(false),
|
||||
api,
|
||||
});
|
||||
@@ -566,14 +644,7 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
await resetCloudSessionManager(ctx);
|
||||
expect(ctx.liveSessions.has(session.id)).toBe(false);
|
||||
|
||||
const hub = new FakeHubClient(false);
|
||||
const replacement = new CloudSessionManager(ctx, {
|
||||
api,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
createHubClient: () => hub as never,
|
||||
});
|
||||
ctx.cloudSessionManager = replacement;
|
||||
const replacement = await reconnectCloudSessionTestViewer(ctx);
|
||||
await replacement.attach(session.id);
|
||||
await replacement.send(session.id, "First prompt after reset");
|
||||
expect(
|
||||
@@ -602,7 +673,7 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
id: "ses-created",
|
||||
status: "provisioning",
|
||||
};
|
||||
const { events, hub, manager } = createFixture({
|
||||
const { events, hub, manager } = await createFixture({
|
||||
hub: new FakeHubClient(false),
|
||||
api: {
|
||||
create: async () => ({
|
||||
@@ -657,7 +728,7 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
});
|
||||
|
||||
it("retains a failed real session and rejects its first send", async () => {
|
||||
const { events, hub, manager } = createFixture({
|
||||
const { events, hub, manager } = await createFixture({
|
||||
hub: new FakeHubClient(false),
|
||||
api: {
|
||||
create: async () => ({
|
||||
@@ -697,7 +768,7 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
});
|
||||
|
||||
it("surfaces run.failed errors as a visible error message", async () => {
|
||||
const { events, hub, manager } = createFixture();
|
||||
const { events, hub, manager } = await createFixture();
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
|
||||
@@ -729,7 +800,7 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
] as const)("%s attaches an existing outer id with a cold registry", async (action) => {
|
||||
const create = vi.fn();
|
||||
const outerId = "ses-01H9XKYHEC1YFBXMJ8ZBES772P";
|
||||
const { ctx, hub } = createFixture({
|
||||
const { ctx, hub } = await createFixture({
|
||||
api: {
|
||||
list: async () => [{ ...REMOTE_SESSION, id: outerId }],
|
||||
create,
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { ClineAccountService, ProviderSettingsManager } from "@cline/core";
|
||||
import {
|
||||
type CloudCreationOptions,
|
||||
type CloudSendLifecycle,
|
||||
CloudSessionApi,
|
||||
CloudSessionController,
|
||||
type CloudSessionControllerOptions,
|
||||
type CloudHubConnection,
|
||||
type CloudHubScope,
|
||||
type CloudSessionEvent,
|
||||
type CreateCloudSessionInput,
|
||||
HubCloudSessionClient,
|
||||
} from "@cline/core/cloud";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { resolveFreshClineAuthToken } from "./cline-auth";
|
||||
import {
|
||||
ensureSharedHubClient,
|
||||
getEnvironmentContext,
|
||||
getSidecarContextOwner,
|
||||
handleHubLiveEvent,
|
||||
@@ -22,47 +19,31 @@ import { LOCAL_ENVIRONMENT_ID } from "./types";
|
||||
|
||||
export * from "@cline/core/cloud";
|
||||
|
||||
// Credential refresh replaces the controller, not the sidecar's creation intent.
|
||||
const pendingInitialTasksByContext = new WeakMap<
|
||||
SidecarContext,
|
||||
Map<string, CloudCreationOptions>
|
||||
>();
|
||||
|
||||
/** Desktop projection adapter. Cloud transport and authoritative state live in core. */
|
||||
export class CloudSessionManager extends CloudSessionController {
|
||||
export class CloudSessionManager extends HubCloudSessionClient {
|
||||
private readonly ownedIds = new Set<string>();
|
||||
private readonly approvalSnapshots = new Map<string, string>();
|
||||
private readonly ctx: SidecarContext;
|
||||
constructor(ctx: SidecarContext, options: CloudSessionControllerOptions) {
|
||||
constructor(
|
||||
ctx: SidecarContext,
|
||||
connect?: () => Promise<CloudHubConnection>,
|
||||
) {
|
||||
const owner = getSidecarContextOwner(ctx);
|
||||
let pendingInitialTasks = pendingInitialTasksByContext.get(owner);
|
||||
if (!pendingInitialTasks) {
|
||||
pendingInitialTasks = new Map();
|
||||
pendingInitialTasksByContext.set(owner, pendingInitialTasks);
|
||||
}
|
||||
super({
|
||||
...options,
|
||||
pendingInitialTasks,
|
||||
logger: options.logger ?? ctx.logger,
|
||||
lateCreateDisposition: options.lateCreateDisposition ?? "delete",
|
||||
clientIdentity: options.clientIdentity ?? {
|
||||
prefix: "code-cloud",
|
||||
type: "code-cloud-sidecar",
|
||||
displayName: "Cline Code cloud session",
|
||||
source: "desktop",
|
||||
},
|
||||
});
|
||||
this.ctx = getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID);
|
||||
for (const [id, state] of ctx.liveSessions)
|
||||
if (state.config.executionTarget === "cloud")
|
||||
this.seedSessionState(id, state);
|
||||
super(connect ?? (() => ensureSharedHubClient(owner)));
|
||||
this.ctx = getEnvironmentContext(owner, LOCAL_ENVIRONMENT_ID);
|
||||
this.subscribe((event) => this.project(event));
|
||||
}
|
||||
override async create(input: CreateCloudSessionInput) {
|
||||
return await super.create({
|
||||
...input,
|
||||
sandboxType: input.sandboxType ?? "resumable",
|
||||
});
|
||||
override async create(
|
||||
input: Omit<CreateCloudSessionInput, "handoff">,
|
||||
scope?: CloudHubScope,
|
||||
) {
|
||||
return await super.create(
|
||||
{
|
||||
...input,
|
||||
sandboxType: input.sandboxType ?? "resumable",
|
||||
},
|
||||
scope,
|
||||
);
|
||||
}
|
||||
override async listForDiscovery(
|
||||
options: { timeoutMs?: number } = {},
|
||||
@@ -80,6 +61,15 @@ export class CloudSessionManager extends CloudSessionController {
|
||||
? { ...record, environmentId: LOCAL_ENVIRONMENT_ID }
|
||||
: undefined;
|
||||
}
|
||||
override async getCrossScopeDiscoveryRecord(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
const record = await super.getCrossScopeDiscoveryRecord(sessionId);
|
||||
return record
|
||||
? { ...record, environmentId: LOCAL_ENVIRONMENT_ID }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private project(event: CloudSessionEvent): void {
|
||||
if (event.type === "prompt_accepted") return;
|
||||
const id = event.sessionId;
|
||||
@@ -126,6 +116,7 @@ export class CloudSessionManager extends CloudSessionController {
|
||||
else this.ctx.liveSessions.set(id, state);
|
||||
const approvalsJson = JSON.stringify(snapshot.approvals);
|
||||
if (
|
||||
event.replace ||
|
||||
event.cause === "approvals" ||
|
||||
(this.approvalSnapshots.get(id) !== approvalsJson &&
|
||||
snapshot.approvals.length > 0)
|
||||
@@ -143,7 +134,7 @@ export class CloudSessionManager extends CloudSessionController {
|
||||
items: snapshot.approvals,
|
||||
});
|
||||
}
|
||||
if (event.cause === "queue")
|
||||
if (event.cause === "queue" || event.replace)
|
||||
sendEvent(this.ctx, "prompts_in_queue_state", {
|
||||
sessionId: id,
|
||||
items: snapshot.promptsInQueue,
|
||||
@@ -179,27 +170,31 @@ export class CloudSessionManager extends CloudSessionController {
|
||||
delivery?: "queue" | "steer",
|
||||
modelId?: string,
|
||||
images?: string[],
|
||||
lifecycle?: CloudSendLifecycle,
|
||||
scope?: CloudHubScope,
|
||||
) {
|
||||
// Existing desktop commands update this local preference before lazy inner creation.
|
||||
const config = this.ctx.liveSessions.get(id)?.config;
|
||||
if (config)
|
||||
this.restoreCreationOptions(id, {
|
||||
...(typeof config.autoApproveTools === "boolean"
|
||||
? { autoApproveTools: config.autoApproveTools }
|
||||
: {}),
|
||||
...(typeof config.thinking === "boolean"
|
||||
? { thinking: config.thinking }
|
||||
: {}),
|
||||
...(typeof config.reasoningEffort === "string"
|
||||
? {
|
||||
reasoningEffort:
|
||||
config.reasoningEffort as CloudCreationOptions["reasoningEffort"],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
await this.restoreCreationOptions(
|
||||
id,
|
||||
{
|
||||
...(typeof config.autoApproveTools === "boolean"
|
||||
? { autoApproveTools: config.autoApproveTools }
|
||||
: {}),
|
||||
...(typeof config.thinking === "boolean"
|
||||
? { thinking: config.thinking }
|
||||
: {}),
|
||||
...(typeof config.reasoningEffort === "string"
|
||||
? {
|
||||
reasoningEffort:
|
||||
config.reasoningEffort as CloudCreationOptions["reasoningEffort"],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
scope,
|
||||
);
|
||||
try {
|
||||
return await super.send(id, prompt, delivery, modelId, images, lifecycle);
|
||||
return await super.send(id, prompt, delivery, modelId, images, scope);
|
||||
} finally {
|
||||
const snapshot = this.getSnapshot(id);
|
||||
if (snapshot)
|
||||
@@ -216,55 +211,14 @@ export function getCloudSessionManager(
|
||||
ctx: SidecarContext,
|
||||
): CloudSessionManager {
|
||||
ctx = getSidecarContextOwner(ctx);
|
||||
const existing = ctx.cloudSessionManager;
|
||||
if (existing instanceof CloudSessionManager) {
|
||||
return existing;
|
||||
}
|
||||
const environment = getClineEnvironmentConfig();
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
const getAuthToken = () =>
|
||||
resolveFreshClineAuthToken(providerSettingsManager, ctx);
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: environment.apiBaseUrl,
|
||||
appBaseUrl: environment.appBaseUrl,
|
||||
getAuthToken,
|
||||
});
|
||||
const accountService = new ClineAccountService({
|
||||
apiBaseUrl: environment.apiBaseUrl,
|
||||
getAuthToken,
|
||||
});
|
||||
// Cache successful org lookups across sidebar polls; never cache failures.
|
||||
let activeOrgCache: { id: string | undefined; at: number } | undefined;
|
||||
const getActiveOrganizationId = async (options?: {
|
||||
fresh?: boolean;
|
||||
}): Promise<string | undefined> => {
|
||||
if (
|
||||
!options?.fresh &&
|
||||
activeOrgCache &&
|
||||
Date.now() - activeOrgCache.at < 60_000
|
||||
) {
|
||||
return activeOrgCache.id;
|
||||
}
|
||||
if (!(await getAuthToken())?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const organizations = await accountService.fetchUserOrganizations();
|
||||
const id = organizations?.find(
|
||||
(organization) => organization.active,
|
||||
)?.organizationId;
|
||||
activeOrgCache = { id, at: Date.now() };
|
||||
return id;
|
||||
};
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api,
|
||||
apiBaseUrl: environment.apiBaseUrl,
|
||||
getAuthToken,
|
||||
getActiveOrganizationId,
|
||||
});
|
||||
if (ctx.cloudSessionManager instanceof CloudSessionManager)
|
||||
return ctx.cloudSessionManager;
|
||||
const manager = new CloudSessionManager(ctx);
|
||||
ctx.cloudSessionManager = manager;
|
||||
return manager;
|
||||
}
|
||||
|
||||
/** Release this desktop viewer; the hub retains cloud work and sandbox connections. */
|
||||
export async function resetCloudSessionManager(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { HubCloudSessionClient } from "@cline/core/cloud";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isClineAccountNotAuthenticatedResult } from "../webview/lib/cline-account-state";
|
||||
import type { SidecarContext } from "./types";
|
||||
@@ -70,6 +71,10 @@ async function runClineAccountCommand(ctx: SidecarContext) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(
|
||||
HubCloudSessionClient.prototype,
|
||||
"refreshOrganization",
|
||||
).mockResolvedValue(undefined);
|
||||
// Account context is persisted across launches; never hydrate the developer's
|
||||
// signed-in identity when a test expects an anonymous device identity.
|
||||
testDataDir = mkdtempSync(join(tmpdir(), "commands-account-test-"));
|
||||
@@ -194,6 +199,7 @@ describe("provider settings cloud session lifecycle", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(testDataDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -322,6 +328,44 @@ describe("cline_account command auth states", () => {
|
||||
* against a stale account (or the device).
|
||||
*/
|
||||
describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
it.each([
|
||||
"Hub unavailable",
|
||||
"organization request failed",
|
||||
"cloud disabled",
|
||||
])("keeps a successful account switch and desktop cleanup when %s", async (failure) => {
|
||||
vi.stubEnv(
|
||||
"CLINE_CODE_CLOUD_AGENTS",
|
||||
failure === "cloud disabled" ? "0" : "1",
|
||||
);
|
||||
const { getCloudSessionManager } = await import("./cloud-sessions");
|
||||
const { ctx } = createContext();
|
||||
const viewer = getCloudSessionManager(ctx);
|
||||
const dispose = vi.spyOn(viewer, "dispose");
|
||||
const send = vi.fn();
|
||||
ctx.wsClients.add({ send } as never);
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
const result = { switched: true };
|
||||
executeClineAccountActionMock.mockResolvedValue(result);
|
||||
vi.mocked(
|
||||
HubCloudSessionClient.prototype.refreshOrganization,
|
||||
).mockRejectedValue(new Error(failure));
|
||||
await expect(runOperation(ctx, "switchAccount")).resolves.toBe(result);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
expect(ctx.cloudSessionManager).toBeNull();
|
||||
expect(
|
||||
send.mock.calls.map(([message]) => JSON.parse(message).event.name),
|
||||
).toContain("cloud_sessions_changed");
|
||||
if (failure === "cloud disabled")
|
||||
expect(
|
||||
HubCloudSessionClient.prototype.refreshOrganization,
|
||||
).not.toHaveBeenCalled();
|
||||
else
|
||||
expect(
|
||||
HubCloudSessionClient.prototype.refreshOrganization,
|
||||
).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
async function currentFlagsUserId(): Promise<string | undefined> {
|
||||
const { getDesktopFeatureFlagsContext } = await import("./feature-flags");
|
||||
return getDesktopFeatureFlagsContext().userId ?? undefined;
|
||||
@@ -413,6 +457,7 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
});
|
||||
|
||||
it("leaves the signed-in identity intact across an organization switch", async () => {
|
||||
vi.stubEnv("CLINE_CODE_CLOUD_AGENTS", "1");
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
@@ -426,6 +471,9 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
});
|
||||
|
||||
await runOperation(ctx, "switchAccount");
|
||||
expect(
|
||||
HubCloudSessionClient.prototype.refreshOrganization,
|
||||
).toHaveBeenCalledOnce();
|
||||
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
});
|
||||
|
||||
@@ -2815,6 +2815,16 @@ export async function handleCommand(
|
||||
accountService,
|
||||
);
|
||||
if (operation === "switchAccount") {
|
||||
if (isCloudAgentsEnabled()) {
|
||||
try {
|
||||
await getCloudSessionManager(ctx).refreshOrganization();
|
||||
} catch (error) {
|
||||
ctx.logger?.error?.(
|
||||
"Failed to refresh cloud organization after account switch",
|
||||
{ error },
|
||||
);
|
||||
}
|
||||
}
|
||||
await resetCloudSessionManager(ctx);
|
||||
// The sidebar must re-scope immediately (personal ⇄ org), not on
|
||||
// the next 12s poll.
|
||||
|
||||
@@ -4,7 +4,8 @@ import { join } from "node:path";
|
||||
import type { RuntimeCapabilities } from "@cline/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { materializeUserFiles } from "./attachments";
|
||||
import { CloudSessionManager, type CloudSessionRecord } from "./cloud-sessions";
|
||||
import { createCloudSessionTestRuntime } from "./cloud-session-test-runtime";
|
||||
import type { CloudSessionManager, CloudSessionRecord } from "./cloud-sessions";
|
||||
import type { LiveSession, SidecarContext } from "./types";
|
||||
|
||||
const createCoreMock = vi.hoisted(() => vi.fn());
|
||||
@@ -60,16 +61,17 @@ function readEvents(ctx: SidecarContext): Array<{
|
||||
);
|
||||
}
|
||||
|
||||
function createSearchCloudManager(
|
||||
async function createSearchCloudManager(
|
||||
ctx: SidecarContext,
|
||||
list: () => Promise<CloudSessionRecord[]>,
|
||||
): CloudSessionManager {
|
||||
): Promise<CloudSessionManager> {
|
||||
vi.stubEnv("CLINE_CODE_CLOUD_AGENTS", "1");
|
||||
return new CloudSessionManager(ctx, {
|
||||
await createCloudSessionTestRuntime(ctx, {
|
||||
api: { list } as never,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "token",
|
||||
});
|
||||
return ctx.cloudSessionManager as CloudSessionManager;
|
||||
}
|
||||
|
||||
function cloudSearchRecord(
|
||||
@@ -297,7 +299,10 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
sessionManager: { list } as never,
|
||||
unsubscribeSessionEvents: () => {},
|
||||
});
|
||||
ctx.cloudSessionManager = createSearchCloudManager(ctx, async () => []);
|
||||
ctx.cloudSessionManager = await createSearchCloudManager(
|
||||
ctx,
|
||||
async () => [],
|
||||
);
|
||||
|
||||
const results = (await handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
@@ -350,7 +355,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
sessionManager: { list } as never,
|
||||
unsubscribeSessionEvents: () => {},
|
||||
});
|
||||
ctx.cloudSessionManager = createSearchCloudManager(ctx, async () => [
|
||||
ctx.cloudSessionManager = await createSearchCloudManager(ctx, async () => [
|
||||
cloudSearchRecord("cloud-out-of-scope", "generate an image"),
|
||||
]);
|
||||
|
||||
@@ -395,7 +400,10 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
sessionManager: { list } as never,
|
||||
unsubscribeSessionEvents: () => {},
|
||||
});
|
||||
ctx.cloudSessionManager = createSearchCloudManager(ctx, async () => []);
|
||||
ctx.cloudSessionManager = await createSearchCloudManager(
|
||||
ctx,
|
||||
async () => [],
|
||||
);
|
||||
|
||||
const results = (await handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
@@ -437,7 +445,10 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
sessionManager: { list } as never,
|
||||
unsubscribeSessionEvents: () => {},
|
||||
});
|
||||
ctx.cloudSessionManager = createSearchCloudManager(ctx, async () => []);
|
||||
ctx.cloudSessionManager = await createSearchCloudManager(
|
||||
ctx,
|
||||
async () => [],
|
||||
);
|
||||
|
||||
const pending = handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
@@ -475,7 +486,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
sessionManager: { list: vi.fn(async () => []) } as never,
|
||||
unsubscribeSessionEvents: () => {},
|
||||
});
|
||||
ctx.cloudSessionManager = createSearchCloudManager(ctx, list);
|
||||
ctx.cloudSessionManager = await createSearchCloudManager(ctx, list);
|
||||
|
||||
const results = (await handleCommand(ctx, "search_sessions", {
|
||||
query: "UNIQUE-CLOUD-TITLE",
|
||||
@@ -527,7 +538,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
sessionManager: { list: vi.fn(async () => []) } as never,
|
||||
unsubscribeSessionEvents: () => {},
|
||||
});
|
||||
ctx.cloudSessionManager = createSearchCloudManager(ctx, async () => [
|
||||
ctx.cloudSessionManager = await createSearchCloudManager(ctx, async () => [
|
||||
cloudSearchRecord("shared", "match"),
|
||||
cloudSearchRecord("cloud-2", "match second", "2026-01-03T00:00:00.000Z"),
|
||||
cloudSearchRecord("cloud-3", "match third", "2026-01-02T00:00:00.000Z"),
|
||||
@@ -573,7 +584,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
} as never,
|
||||
unsubscribeSessionEvents: () => {},
|
||||
});
|
||||
ctx.cloudSessionManager = createSearchCloudManager(
|
||||
ctx.cloudSessionManager = await createSearchCloudManager(
|
||||
ctx,
|
||||
async () => await new Promise<CloudSessionRecord[]>(() => {}),
|
||||
);
|
||||
@@ -605,7 +616,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
sessionManager: { list: vi.fn(async () => []) } as never,
|
||||
unsubscribeSessionEvents: () => {},
|
||||
});
|
||||
ctx.cloudSessionManager = createSearchCloudManager(
|
||||
ctx.cloudSessionManager = await createSearchCloudManager(
|
||||
ctx,
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("CloudHandoffStatus", () => {
|
||||
expect(view.textContent).toContain("Open cloud task");
|
||||
});
|
||||
it.each([
|
||||
"sending",
|
||||
"provisioning",
|
||||
"complete",
|
||||
"warning",
|
||||
] as const)("offers a user-clicked cloud link during %s", (phase) => {
|
||||
@@ -109,7 +109,7 @@ describe("CloudHandoffStatus", () => {
|
||||
const view = render(
|
||||
<CloudHandoffStatus
|
||||
state={
|
||||
phase !== "sending"
|
||||
phase !== "provisioning"
|
||||
? {
|
||||
status: "complete",
|
||||
sessionId: "cloud-1",
|
||||
@@ -136,7 +136,7 @@ describe("CloudHandoffStatus", () => {
|
||||
? "Cloud task ready"
|
||||
: phase === "warning"
|
||||
? "Check the cloud task"
|
||||
: "Sending the cloud task",
|
||||
: "Preparing the cloud workspace",
|
||||
);
|
||||
if (phase === "warning") {
|
||||
expect(view.textContent).toContain("Check delivery before resending.");
|
||||
|
||||
@@ -162,11 +162,11 @@ describe("summary handoff lifecycle", () => {
|
||||
await h.lifecycle.onEvent({
|
||||
...result,
|
||||
handoffAttemptId,
|
||||
phase: "sending",
|
||||
phase: "provisioning",
|
||||
});
|
||||
expect(h.state().local).toMatchObject({
|
||||
status: "progress",
|
||||
phase: "sending",
|
||||
phase: "provisioning",
|
||||
sessionId: "cloud",
|
||||
});
|
||||
await h.lifecycle.onEvent({
|
||||
|
||||
@@ -14,7 +14,6 @@ export type HandoffProgressPhase =
|
||||
| "summarizing"
|
||||
| "creating"
|
||||
| "provisioning"
|
||||
| "sending"
|
||||
| "complete";
|
||||
|
||||
export const HANDOFF_PROGRESS_LABELS: Record<HandoffProgressPhase, string> = {
|
||||
@@ -22,7 +21,6 @@ export const HANDOFF_PROGRESS_LABELS: Record<HandoffProgressPhase, string> = {
|
||||
creating: "Creating the cloud session...",
|
||||
provisioning: "Preparing the cloud workspace...",
|
||||
summarizing: "Summarizing the local conversation...",
|
||||
sending: "Sending the cloud task...",
|
||||
complete: "Cloud handoff complete.",
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ Rules:
|
||||
- `shared` stays low-level and reusable
|
||||
- `agents` stays stateless — no session/storage/config concerns
|
||||
- `core` owns stateful orchestration, including the shared-hub daemon, server, and client adapters under `src/hub/`
|
||||
- `@cline/core/cloud` owns remote cloud-session transport and state; hosts own authentication, feature gating, persistence, and UI projection.
|
||||
- `@cline/core/cloud` owns remote cloud-session transport and state. The shared hub owns desktop cloud controllers, credentials, organization lookup, and connections; desktop owns feature gating, persistence, and UI projection.
|
||||
|
||||
## Change Routing
|
||||
|
||||
|
||||
+4
-2
@@ -136,10 +136,12 @@ Design rules:
|
||||
|
||||
- `core` is the app-facing orchestration layer over `agents`.
|
||||
- `session/fork-metadata` owns fork ancestry and removal of inherited handoff markers; hosts retain title, transcript, and workspace-restore policies.
|
||||
- `@cline/core/cloud` owns remote cloud-session state and emits immutable snapshots and events. Viewers hydrating active runs with `readMessages` reconcile canonical history at completion even if they missed the run start. Hosts supply authentication and project those snapshots into their UI; feature flags, account selection, and host persistence remain outside the controller. Importing this subpath does not initialize a local agent.
|
||||
- `@cline/core/cloud` owns remote cloud-session state and emits immutable snapshots and events. Viewers hydrating active runs with `readMessages` reconcile canonical history at completion even if they missed the run start. Desktop uses `HubCloudSessionClient` over its existing shared Hub connection. `HubCloudSessionService` in the daemon owns its account-scoped controller, refresh-aware credentials, organization lookup, provisioning, sandbox connections, queues, approvals, and transcript reconciliation. Desktop projects the snapshots into its UI; feature gating and host persistence remain in the app. Direct controller hosts (including handoff flows) can still supply authentication. Importing this subpath does not initialize a local agent.
|
||||
- `cloud/models` owns cloud model eligibility; `services/cloud-handoff` owns Git preflight, fingerprints, and transcript verification. Hosts own transfer orchestration, source locks, persistence, feature gating, and draft recovery.
|
||||
- Cloud RPC validates an explicit operation allowlist and binds requests to the viewer’s observed hub instance and account-scope generation before dispatch. Handoff and prompt dispatch lifecycle callbacks stay with direct controller hosts and are excluded from the serializable RPC arguments. Viewer disconnects do not stop cloud work or close daemon-owned sandbox connections. Account changes retire the old controller and invalidate viewer caches; daemon shutdown disposes it. Cloud events carry a hub-instance id, account-scope generation, and revision; reconnecting subscriptions receive a complete snapshot set, including an empty set, and remove sessions and approvals absent from it. Scope checks run at command/auth boundaries and periodically, avoiding provider-settings reads per streamed event. Token refresh updates credential-based scope identity without retiring the same account’s controller. After an organization switch, desktop attempts a cloud-enabled organization refresh without making switch success or viewer cleanup depend on it. Fresh requests invalidate the old cache before fetching; a returning viewer also invalidates it to cover switches made while the Hub was unreachable. Cloud events are excluded from the durable local event log so old-account transcripts cannot be replayed after a scope change. Dispatched cloud mutations are never automatically resent after a lost reply.
|
||||
- The cloud controller resumes suspended sessions and restores saved tasks.
|
||||
- Desktop retains pending first-task creation options in a context-owned map across credential-driven controller replacement. The shared controller consumes that intent when an inner task exists or is created; retaining an ID without its approval policy is not sufficient.
|
||||
- Multi-step handoff callers can pin the existing Hub account scope across repository preparation, model lookup, creation, and sending. Send lifecycle callbacks stay inside the daemon; a cloud-specific failure payload preserves whether inner dispatch began without changing ordinary send success results. Missing replies remain uncertain.
|
||||
- The hub retains pending first-task creation options with its controller across desktop viewer disconnects and reconnects. The controller consumes that intent when an inner task exists or is created. Account changes dispose the old scope and its intent.
|
||||
- hub-related modules live under `packages/core/src/hub/`, grouped by service:
|
||||
- `client/` contains host-facing hub clients and browser connection helpers
|
||||
- `daemon/` contains detached daemon startup, entrypoint, and local runtime handler wiring
|
||||
|
||||
+52
@@ -135,6 +135,58 @@ the outer session is deleted). `dispose` preserves an injected map; without one,
|
||||
the controller owns and clears its pending state. Restoring options alone never
|
||||
authorizes recreation of a missing established task.
|
||||
|
||||
### Hub-owned cloud viewers
|
||||
|
||||
`HubCloudSessionClient` from `@cline/core/cloud` uses an authenticated,
|
||||
registered `NodeHubClient` from `@cline/core/hub`. Reuse the host's existing
|
||||
Hub connection, or create and connect a `NodeHubClient` with the Hub URL,
|
||||
auth token, client ID, and workspace root. The cloud client accepts an async
|
||||
connection factory and connects lazily; `connect()` explicitly hydrates it.
|
||||
|
||||
```ts
|
||||
import { HubCloudSessionClient } from "@cline/core/cloud";
|
||||
import type { NodeHubClient } from "@cline/core/hub";
|
||||
|
||||
async function openCloudSession(hub: NodeHubClient, sessionId: string) {
|
||||
const viewer = new HubCloudSessionClient(async () => {
|
||||
await hub.connect();
|
||||
return hub;
|
||||
});
|
||||
const unsubscribe = viewer.subscribe((event) => console.log(event));
|
||||
try {
|
||||
await viewer.attach(sessionId);
|
||||
return await viewer.readMessages(sessionId);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await viewer.dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The Hub owns the account-scoped controller, credentials, organization lookup,
|
||||
provisioning, and sandbox connections. Disposing a viewer releases its subscription
|
||||
and projected state; admitted cloud work and sandbox connections continue. The
|
||||
host retains ownership of its shared `NodeHubClient`. Account changes retire the
|
||||
old controller; Hub shutdown disposes it. Direct controller hosts instead own
|
||||
and dispose their controller and its connections.
|
||||
|
||||
The viewer supports the serializable operations in `CLOUD_HUB_OPERATIONS`,
|
||||
with arguments/results described by `CloudHubArgs` and `CloudHubResult`.
|
||||
`CloudHubScope` binds commands to the observed Hub instance and account generation;
|
||||
callers use the viewer methods rather than constructing RPC envelopes. Snapshot
|
||||
recovery includes empty sets, removes deleted sessions, and rejects stale revisions.
|
||||
Dispatched actions are not automatically resent after an uncertain reply.
|
||||
Handoff callbacks and the sixth `send` lifecycle argument are direct-controller
|
||||
features and cannot cross this RPC boundary. Hub-backed creation defaults to
|
||||
`"resumable"` sandboxes.
|
||||
|
||||
After a successful organization switch, `refreshOrganization()` invalidates the
|
||||
Hub's cached organization before fetching the current one. A fetch failure leaves
|
||||
the old cache invalidated. Account-switch success and desktop cleanup must not
|
||||
depend on this optional refresh; cloud-disabled hosts skip it. A new viewer's
|
||||
watch also invalidates the cache, covering switches made while the Hub was
|
||||
unreachable without interrupting existing cloud work.
|
||||
|
||||
## Experimental cloud handoff
|
||||
|
||||
`loadCloudModels` and `CloudSessionController.listModels()` provide eligible cloud
|
||||
|
||||
@@ -838,6 +838,10 @@ export class CloudSessionController {
|
||||
};
|
||||
}
|
||||
|
||||
async refreshOrganization(): Promise<void> {
|
||||
await this.resolveActiveOrganizationId({ fresh: true });
|
||||
}
|
||||
|
||||
private async resolveActiveOrganizationId(options?: {
|
||||
fresh?: boolean;
|
||||
}): Promise<string | undefined> {
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { HubEventEnvelope, HubReplyEnvelope } from "@cline/shared";
|
||||
import type { NodeHubClient } from "../hub/client";
|
||||
import type { CloudSessionRecord } from "./api";
|
||||
import {
|
||||
type CloudHubArgs,
|
||||
type CloudHubOperation,
|
||||
type CloudHubResult,
|
||||
type CloudHubScope,
|
||||
CloudSendError,
|
||||
cloudSendFailureSchema,
|
||||
} from "./hub-protocol";
|
||||
import { immutableCopy } from "./state";
|
||||
import type {
|
||||
CloudSessionEvent,
|
||||
CloudSessionSnapshot,
|
||||
JsonRecord,
|
||||
} from "./types";
|
||||
|
||||
export type CloudHubConnection = Pick<NodeHubClient, "command" | "subscribe">;
|
||||
/** Transport facade for a hub-owned cloud controller. Disposing only releases this viewer. */
|
||||
export class HubCloudSessionClient {
|
||||
private readonly snapshots = new Map<string, CloudSessionSnapshot>();
|
||||
private readonly records = new Map<string, JsonRecord>();
|
||||
private readonly listeners = new Set<(event: CloudSessionEvent) => void>();
|
||||
private unsubscribe?: () => void;
|
||||
private connection?: Promise<CloudHubConnection>;
|
||||
private disposed = false;
|
||||
private serviceId?: string;
|
||||
private scopeGeneration = -1;
|
||||
private revision = -1;
|
||||
private readonly sessionRevisions = new Map<string, number>();
|
||||
constructor(
|
||||
private readonly getConnection: () => Promise<CloudHubConnection>,
|
||||
) {}
|
||||
private async ready(): Promise<CloudHubConnection> {
|
||||
if (this.disposed) throw new Error("Cloud viewer is disposed");
|
||||
if (!this.connection) {
|
||||
const connecting = this.getConnection().then(async (client) => {
|
||||
if (this.disposed) throw new Error("Cloud viewer is disposed");
|
||||
this.unsubscribe = client.subscribe((envelope: HubEventEnvelope) => {
|
||||
if (envelope.event === "cloud.event") this.recover(envelope.payload);
|
||||
});
|
||||
try {
|
||||
const reply = await client.command("cloud.watch");
|
||||
this.checkReply(reply);
|
||||
this.recover(reply.payload);
|
||||
return client;
|
||||
} catch (error) {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = undefined;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
this.connection = connecting;
|
||||
void connecting.catch(() => {
|
||||
if (this.connection === connecting) this.connection = undefined;
|
||||
});
|
||||
}
|
||||
return this.connection;
|
||||
}
|
||||
async connect(): Promise<void> {
|
||||
await this.ready();
|
||||
}
|
||||
get scope(): CloudHubScope | undefined {
|
||||
return this.serviceId
|
||||
? { serviceId: this.serviceId, scopeGeneration: this.scopeGeneration }
|
||||
: undefined;
|
||||
}
|
||||
private recover(payload: Record<string, unknown> | undefined): void {
|
||||
if (this.disposed || !this.acceptVersion(payload)) return;
|
||||
const revision = Number(payload?.revision);
|
||||
if (Array.isArray(payload?.snapshots)) {
|
||||
const snapshots = payload.snapshots as CloudSessionSnapshot[];
|
||||
const present = new Set(snapshots.map((snapshot) => snapshot.sessionId));
|
||||
for (const sessionId of this.snapshots.keys()) {
|
||||
if (
|
||||
(!payload.sessionId || payload.sessionId === sessionId) &&
|
||||
!present.has(sessionId)
|
||||
)
|
||||
this.receive({ type: "removed", sessionId }, revision);
|
||||
}
|
||||
for (const snapshot of snapshots)
|
||||
this.receive(
|
||||
{
|
||||
type: "snapshot",
|
||||
sessionId: snapshot.sessionId,
|
||||
snapshot,
|
||||
replace: true,
|
||||
},
|
||||
revision,
|
||||
);
|
||||
} else this.receive(payload?.event as CloudSessionEvent, revision);
|
||||
}
|
||||
private acceptVersion(payload: Record<string, unknown> | undefined): boolean {
|
||||
if (
|
||||
typeof payload?.serviceId !== "string" ||
|
||||
typeof payload.revision !== "number"
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
payload.serviceId === this.serviceId &&
|
||||
Number(payload.scopeGeneration) < this.scopeGeneration
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
payload.serviceId !== this.serviceId ||
|
||||
payload.scopeGeneration !== this.scopeGeneration
|
||||
) {
|
||||
for (const sessionId of this.snapshots.keys())
|
||||
this.receive({ type: "removed", sessionId });
|
||||
this.records.clear();
|
||||
this.sessionRevisions.clear();
|
||||
this.serviceId = payload.serviceId;
|
||||
this.scopeGeneration = Number(payload.scopeGeneration);
|
||||
this.revision = -1;
|
||||
}
|
||||
this.revision = Math.max(this.revision, payload.revision);
|
||||
return true;
|
||||
}
|
||||
private checkReply(reply: HubReplyEnvelope): void {
|
||||
if (!reply.ok)
|
||||
throw new Error(reply.error?.message ?? "Cloud hub request failed");
|
||||
}
|
||||
private receive(event: CloudSessionEvent, revision = this.revision): void {
|
||||
if (this.disposed || !event) return;
|
||||
if (revision < (this.sessionRevisions.get(event.sessionId) ?? -1)) return;
|
||||
this.sessionRevisions.set(event.sessionId, revision);
|
||||
if (event.type === "snapshot")
|
||||
this.snapshots.set(event.sessionId, structuredClone(event.snapshot));
|
||||
if (event.type === "removed") {
|
||||
this.snapshots.delete(event.sessionId);
|
||||
this.records.delete(event.sessionId);
|
||||
}
|
||||
const delivered = immutableCopy(event);
|
||||
for (const listener of this.listeners) listener(delivered);
|
||||
}
|
||||
subscribe(listener: (event: CloudSessionEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
protected async call<K extends CloudHubOperation>(
|
||||
operation: K,
|
||||
args: CloudHubArgs<K>,
|
||||
expectedScope?: CloudHubScope,
|
||||
): Promise<CloudHubResult<K>> {
|
||||
// Capture an established viewer's scope before yielding to connection recovery.
|
||||
const preparedScope = expectedScope ? { ...expectedScope } : this.scope;
|
||||
const client = await this.ready();
|
||||
if (this.disposed) throw new Error("Cloud viewer is disposed");
|
||||
const scope = preparedScope ?? {
|
||||
serviceId: this.serviceId,
|
||||
scopeGeneration: this.scopeGeneration,
|
||||
};
|
||||
const isCurrentScope = () =>
|
||||
scope.serviceId === this.serviceId &&
|
||||
scope.scopeGeneration === this.scopeGeneration;
|
||||
let dispatched = false;
|
||||
const reply = await client.command(
|
||||
"cloud.command",
|
||||
{ operation, args, scope },
|
||||
undefined,
|
||||
{
|
||||
timeoutMs: ["create", "send", "attach"].includes(operation)
|
||||
? null
|
||||
: undefined,
|
||||
beforeDispatch: () => {
|
||||
if (this.disposed) throw new Error("Cloud viewer is disposed");
|
||||
if (!isCurrentScope())
|
||||
throw new Error("Cloud account changed before request");
|
||||
if (dispatched)
|
||||
throw new Error(
|
||||
"Cloud request outcome is unknown; reconnect before retrying",
|
||||
);
|
||||
},
|
||||
onDispatch: () => {
|
||||
dispatched = true;
|
||||
},
|
||||
},
|
||||
);
|
||||
this.checkReply(reply);
|
||||
if (
|
||||
!isCurrentScope() ||
|
||||
reply.payload?.serviceId !== scope.serviceId ||
|
||||
reply.payload?.scopeGeneration !== scope.scopeGeneration ||
|
||||
!this.acceptVersion(reply.payload)
|
||||
)
|
||||
throw new Error("Cloud account changed during request");
|
||||
if (this.disposed) throw new Error("Cloud viewer is disposed");
|
||||
if (operation === "send" && reply.payload?.sendFailure !== undefined) {
|
||||
const failure = cloudSendFailureSchema.parse(reply.payload.sendFailure);
|
||||
throw new CloudSendError(
|
||||
failure.message,
|
||||
failure.dispatched,
|
||||
failure.detail,
|
||||
);
|
||||
}
|
||||
return reply.payload?.result as CloudHubResult<K>;
|
||||
}
|
||||
create(
|
||||
input: CloudHubArgs<"create">[0],
|
||||
scope?: CloudHubScope,
|
||||
): Promise<CloudHubResult<"create">> {
|
||||
return this.call("create", [input], scope);
|
||||
}
|
||||
attach(...args: CloudHubArgs<"attach">): Promise<CloudHubResult<"attach">> {
|
||||
return this.call("attach", args);
|
||||
}
|
||||
send(
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
delivery?: "queue" | "steer",
|
||||
modelId?: string,
|
||||
images?: string[],
|
||||
scope?: CloudHubScope,
|
||||
): Promise<CloudHubResult<"send">> {
|
||||
return this.call(
|
||||
"send",
|
||||
[sessionId, prompt, delivery, modelId, images],
|
||||
scope,
|
||||
);
|
||||
}
|
||||
prepareHandoffRepository(
|
||||
repoUrl: string,
|
||||
scope?: CloudHubScope,
|
||||
): Promise<CloudHubResult<"prepareHandoffRepository">> {
|
||||
return this.call("prepareHandoffRepository", [repoUrl], scope);
|
||||
}
|
||||
refreshOrganization(): Promise<void> {
|
||||
return this.call("refreshOrganization", []);
|
||||
}
|
||||
abort(...args: CloudHubArgs<"abort">): Promise<CloudHubResult<"abort">> {
|
||||
return this.call("abort", args);
|
||||
}
|
||||
pendingPrompts(
|
||||
...args: CloudHubArgs<"pendingPrompts">
|
||||
): Promise<CloudHubResult<"pendingPrompts">> {
|
||||
return this.call("pendingPrompts", args);
|
||||
}
|
||||
updatePendingPrompt(
|
||||
...args: CloudHubArgs<"updatePendingPrompt">
|
||||
): Promise<CloudHubResult<"updatePendingPrompt">> {
|
||||
return this.call("updatePendingPrompt", args);
|
||||
}
|
||||
removePendingPrompt(
|
||||
...args: CloudHubArgs<"removePendingPrompt">
|
||||
): Promise<CloudHubResult<"removePendingPrompt">> {
|
||||
return this.call("removePendingPrompt", args);
|
||||
}
|
||||
readMessages(
|
||||
...args: CloudHubArgs<"readMessages">
|
||||
): Promise<CloudHubResult<"readMessages">> {
|
||||
return this.call("readMessages", args);
|
||||
}
|
||||
updateTitle(
|
||||
...args: CloudHubArgs<"updateTitle">
|
||||
): Promise<CloudHubResult<"updateTitle">> {
|
||||
return this.call("updateTitle", args);
|
||||
}
|
||||
delete(...args: CloudHubArgs<"delete">): Promise<CloudHubResult<"delete">> {
|
||||
return this.call("delete", args);
|
||||
}
|
||||
listModels(scope?: CloudHubScope): Promise<CloudHubResult<"listModels">> {
|
||||
return this.call("listModels", [], scope);
|
||||
}
|
||||
listRepositories(
|
||||
...args: CloudHubArgs<"listRepositories">
|
||||
): Promise<CloudHubResult<"listRepositories">> {
|
||||
return this.call("listRepositories", args);
|
||||
}
|
||||
listBranches(
|
||||
...args: CloudHubArgs<"listBranches">
|
||||
): Promise<CloudHubResult<"listBranches">> {
|
||||
return this.call("listBranches", args);
|
||||
}
|
||||
respondApproval(
|
||||
...args: CloudHubArgs<"respondApproval">
|
||||
): Promise<CloudHubResult<"respondApproval">> {
|
||||
return this.call("respondApproval", args);
|
||||
}
|
||||
restoreCreationOptions(
|
||||
sessionId: CloudHubArgs<"restoreCreationOptions">[0],
|
||||
options: CloudHubArgs<"restoreCreationOptions">[1],
|
||||
scope?: CloudHubScope,
|
||||
): Promise<CloudHubResult<"restoreCreationOptions">> {
|
||||
return this.call("restoreCreationOptions", [sessionId, options], scope);
|
||||
}
|
||||
getCrossScopeDiscoveryRecord(
|
||||
...args: CloudHubArgs<"getCrossScopeDiscoveryRecord">
|
||||
): Promise<CloudHubResult<"getCrossScopeDiscoveryRecord">> {
|
||||
return this.call("getCrossScopeDiscoveryRecord", args);
|
||||
}
|
||||
async list(): Promise<CloudSessionRecord[]> {
|
||||
const records = await this.call("list", []);
|
||||
return records;
|
||||
}
|
||||
async listForDiscovery(
|
||||
...args: CloudHubArgs<"listForDiscovery">
|
||||
): Promise<JsonRecord[]> {
|
||||
const records = await this.call("listForDiscovery", args);
|
||||
for (const record of records)
|
||||
this.records.set(String(record.sessionId), structuredClone(record));
|
||||
return records;
|
||||
}
|
||||
getCachedDiscoveryRecord(sessionId: string): JsonRecord | undefined {
|
||||
const record = this.records.get(sessionId);
|
||||
return record ? structuredClone(record) : undefined;
|
||||
}
|
||||
getSnapshot(sessionId: string): CloudSessionSnapshot | undefined {
|
||||
const snapshot = this.snapshots.get(sessionId);
|
||||
return snapshot ? structuredClone(snapshot) : undefined;
|
||||
}
|
||||
isCloudSession(sessionId: string): boolean {
|
||||
return (
|
||||
sessionId.trim().startsWith("ses-") ||
|
||||
this.records.has(sessionId) ||
|
||||
this.snapshots.has(sessionId)
|
||||
);
|
||||
}
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true;
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = undefined;
|
||||
for (const sessionId of this.snapshots.keys())
|
||||
for (const listener of this.listeners)
|
||||
listener({ type: "removed", sessionId });
|
||||
this.snapshots.clear();
|
||||
this.records.clear();
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { z } from "zod";
|
||||
import type { CreateCloudSessionInput } from "./api";
|
||||
import type { CloudSessionController } from "./controller";
|
||||
|
||||
/** Operations available to cloud UI clients. Lifecycle callbacks remain host-local. */
|
||||
export const CLOUD_HUB_OPERATIONS = [
|
||||
"create",
|
||||
"attach",
|
||||
"send",
|
||||
"abort",
|
||||
"pendingPrompts",
|
||||
"updatePendingPrompt",
|
||||
"removePendingPrompt",
|
||||
"readMessages",
|
||||
"updateTitle",
|
||||
"delete",
|
||||
"list",
|
||||
"listForDiscovery",
|
||||
"getCrossScopeDiscoveryRecord",
|
||||
"listModels",
|
||||
"listRepositories",
|
||||
"listBranches",
|
||||
"respondApproval",
|
||||
"restoreCreationOptions",
|
||||
"refreshOrganization",
|
||||
"prepareHandoffRepository",
|
||||
] as const;
|
||||
export type CloudHubScope = { serviceId: string; scopeGeneration: number };
|
||||
|
||||
export const cloudSendFailureSchema = z.object({
|
||||
dispatched: z.boolean(),
|
||||
message: z.string(),
|
||||
detail: z.string().optional(),
|
||||
});
|
||||
export type CloudSendFailure = z.infer<typeof cloudSendFailureSchema>;
|
||||
|
||||
export class CloudSendError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly dispatched: boolean,
|
||||
readonly detail?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CloudSendError";
|
||||
}
|
||||
}
|
||||
|
||||
export type CloudHubOperation = (typeof CLOUD_HUB_OPERATIONS)[number];
|
||||
export type CloudHubMethods = Pick<CloudSessionController, CloudHubOperation>;
|
||||
export type CloudHubArgs<K extends CloudHubOperation> = K extends "create"
|
||||
? [input: Omit<CreateCloudSessionInput, "handoff">]
|
||||
: K extends "send"
|
||||
? [
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
delivery?: "queue" | "steer",
|
||||
modelId?: string,
|
||||
images?: string[],
|
||||
]
|
||||
: Parameters<CloudHubMethods[K]>;
|
||||
export type CloudHubResult<K extends CloudHubOperation> = Awaited<
|
||||
ReturnType<CloudHubMethods[K]>
|
||||
>;
|
||||
@@ -3,6 +3,8 @@
|
||||
export * from "../services/cloud-handoff";
|
||||
export * from "./api";
|
||||
export * from "./controller";
|
||||
export * from "./hub-client";
|
||||
export * from "./hub-protocol";
|
||||
export * from "./models";
|
||||
export * from "./repositories";
|
||||
export * from "./snapshots";
|
||||
|
||||
@@ -1556,6 +1556,18 @@ describe("hasActiveHubSessions", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("counts active cloud commands without attached sessions", async () => {
|
||||
const { hasActiveHubSessions, summarizeHubSessionActivity } = await import(
|
||||
"."
|
||||
);
|
||||
expect(hasActiveHubSessions({ sessions: [], activeCloudCommands: 1 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
summarizeHubSessionActivity({ sessions: [], activeCloudCommands: 2 }),
|
||||
).toMatchObject({ activeSessionCount: 0, activeCloudCommands: 2 });
|
||||
});
|
||||
|
||||
const payload = (sessions: unknown[]) => ({ sessions });
|
||||
|
||||
it("is idle for an empty or malformed list", async () => {
|
||||
|
||||
@@ -1221,6 +1221,8 @@ export interface HubSessionActivity {
|
||||
participantClientCount: number;
|
||||
/** Plugin command requests executing even without an attached session. */
|
||||
activePluginRequests: number;
|
||||
/** Cloud session commands, such as provisioning, executing in the hub. */
|
||||
activeCloudCommands: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1267,24 +1269,30 @@ export function summarizeHubSessionActivity(
|
||||
}
|
||||
}
|
||||
}
|
||||
const requests =
|
||||
payload && typeof payload === "object"
|
||||
? (payload as { activePluginRequests?: unknown }).activePluginRequests
|
||||
: undefined;
|
||||
const activePluginRequests =
|
||||
typeof requests === "number" && Number.isInteger(requests) && requests > 0
|
||||
? requests
|
||||
const count = (key: "activePluginRequests" | "activeCloudCommands") => {
|
||||
const value =
|
||||
payload && typeof payload === "object"
|
||||
? (payload as Record<string, unknown>)[key]
|
||||
: undefined;
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0
|
||||
? value
|
||||
: 0;
|
||||
};
|
||||
return {
|
||||
activeSessionCount,
|
||||
participantClientCount: clientIds.size,
|
||||
activePluginRequests,
|
||||
activePluginRequests: count("activePluginRequests"),
|
||||
activeCloudCommands: count("activeCloudCommands"),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasActiveHubSessions(payload: unknown): boolean {
|
||||
const activity = summarizeHubSessionActivity(payload);
|
||||
return activity.activeSessionCount > 0 || activity.activePluginRequests > 0;
|
||||
return (
|
||||
activity.activeSessionCount > 0 ||
|
||||
activity.activePluginRequests > 0 ||
|
||||
activity.activeCloudCommands > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1329,7 +1337,9 @@ export async function localHubHasNoActiveSessions(
|
||||
try {
|
||||
const activity = await queryHubSessionActivity(url, authToken, options);
|
||||
return (
|
||||
activity.activeSessionCount === 0 && activity.activePluginRequests === 0
|
||||
activity.activeSessionCount === 0 &&
|
||||
activity.activePluginRequests === 0 &&
|
||||
activity.activeCloudCommands === 0
|
||||
);
|
||||
} catch {
|
||||
// Recovery callers must not treat a hub they cannot positively observe
|
||||
|
||||
@@ -33,11 +33,18 @@ const {
|
||||
rememberRecoverableLocalHubUrl: vi.fn((url: string) => url),
|
||||
verifyHubConnection: vi.fn(),
|
||||
// Idle by default, so existing replacement cases are unaffected.
|
||||
queryHubSessionActivity: vi.fn(async () => ({
|
||||
activeSessionCount: 0,
|
||||
participantClientCount: 0,
|
||||
activePluginRequests: 0,
|
||||
})),
|
||||
queryHubSessionActivity: vi.fn(
|
||||
async (): Promise<{
|
||||
activeSessionCount: number;
|
||||
participantClientCount: number;
|
||||
activePluginRequests: number;
|
||||
activeCloudCommands?: number;
|
||||
}> => ({
|
||||
activeSessionCount: 0,
|
||||
participantClientCount: 0,
|
||||
activePluginRequests: 0,
|
||||
}),
|
||||
),
|
||||
requestHubDrain: vi.fn(async () => true),
|
||||
resolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
@@ -554,6 +561,35 @@ describe("ensureDetachedHubServer", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("attaches to an older hub that is provisioning a cloud session instead of retiring it", async () => {
|
||||
queryHubSessionActivity.mockResolvedValue({
|
||||
activeSessionCount: 0,
|
||||
participantClientCount: 0,
|
||||
activePluginRequests: 0,
|
||||
activeCloudCommands: 1,
|
||||
});
|
||||
readHubDiscovery.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "busy-token",
|
||||
});
|
||||
probeHubServer.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "old-build",
|
||||
pid: 12345,
|
||||
});
|
||||
verifyHubConnection.mockResolvedValue(true);
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
|
||||
await expect(ensureDetachedHubServer("/workspace")).resolves.toEqual({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "busy-token",
|
||||
});
|
||||
expect(requestHubShutdown).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses a healthy hub from a newer build without retiring it", async () => {
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
@@ -1063,6 +1099,32 @@ describe("upgradeManagedHub", () => {
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a hub provisioning a cloud session running when not forced", async () => {
|
||||
queryHubSessionActivity.mockResolvedValue({
|
||||
activeSessionCount: 0,
|
||||
participantClientCount: 0,
|
||||
activePluginRequests: 0,
|
||||
activeCloudCommands: 1,
|
||||
});
|
||||
readHubDiscovery.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "old-token",
|
||||
});
|
||||
probeHubServer.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "old-build",
|
||||
buildEpochMs: 500,
|
||||
});
|
||||
|
||||
const { upgradeManagedHub } = await import(".");
|
||||
const result = await upgradeManagedHub({ waitForIdleMs: 0 });
|
||||
|
||||
expect(result).toMatchObject({ outcome: "still_busy" });
|
||||
expect(requestHubShutdown).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to replace a hub running a newer build", async () => {
|
||||
readHubDiscovery.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
|
||||
@@ -301,7 +301,8 @@ export async function hubHasLiveSessions(
|
||||
);
|
||||
return (
|
||||
activity.activeSessionCount > 0 ||
|
||||
(activity.activePluginRequests ?? 0) > 0
|
||||
(activity.activePluginRequests ?? 0) > 0 ||
|
||||
(activity.activeCloudCommands ?? 0) > 0
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
@@ -968,7 +969,7 @@ export async function upgradeManagedHub(
|
||||
// Only an observed-zero reading ends the window before the deadline.
|
||||
// (Checks at least once so waitForIdleMs: 0 still observes an idle hub.)
|
||||
let observedSessionCount: number | undefined;
|
||||
let observedPluginRequests: number | undefined;
|
||||
let observedBackgroundRequests: number | undefined;
|
||||
for (;;) {
|
||||
try {
|
||||
const activity = await queryHubSessionActivity(
|
||||
@@ -976,8 +977,10 @@ export async function upgradeManagedHub(
|
||||
record.authToken,
|
||||
);
|
||||
observedSessionCount = activity.activeSessionCount;
|
||||
observedPluginRequests = activity.activePluginRequests ?? 0;
|
||||
if (observedSessionCount === 0 && observedPluginRequests === 0) {
|
||||
observedBackgroundRequests =
|
||||
(activity.activePluginRequests ?? 0) +
|
||||
(activity.activeCloudCommands ?? 0);
|
||||
if (observedSessionCount === 0 && observedBackgroundRequests === 0) {
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
@@ -991,7 +994,7 @@ export async function upgradeManagedHub(
|
||||
);
|
||||
}
|
||||
const confirmedIdle =
|
||||
observedSessionCount === 0 && observedPluginRequests === 0;
|
||||
observedSessionCount === 0 && observedBackgroundRequests === 0;
|
||||
// Without force, only a positively idle hub may be replaced: a busy or
|
||||
// unanswerable hub is handed back un-drained. With force, the user has
|
||||
// already consented to interrupting the sessions the prompt showed them,
|
||||
|
||||
@@ -53,6 +53,7 @@ export * from "./discovery/instance-lock";
|
||||
export * from "./discovery/workspace";
|
||||
export * from "./server";
|
||||
export * from "./server/browser-websocket";
|
||||
export * from "./server/cloud-session-service";
|
||||
export * from "./server/command-transport";
|
||||
export * from "./server/hub-event-log";
|
||||
export * from "./server/hub-run-queue";
|
||||
|
||||
@@ -60,6 +60,48 @@ describe("HubServerTransport boundaries", () => {
|
||||
return (transport as unknown as { ctx: HubTransportContext }).ctx;
|
||||
}
|
||||
|
||||
it("recovers an empty cloud snapshot set from a fresh daemon", async () => {
|
||||
const transport = createTransport({ eventLog: false, runQueue: false });
|
||||
const listener = vi.fn();
|
||||
try {
|
||||
const unsubscribe = transport.subscribe("viewer", listener);
|
||||
await Promise.resolve();
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: "cloud.event",
|
||||
payload: expect.objectContaining({
|
||||
serviceId: expect.any(String),
|
||||
scopeGeneration: 0,
|
||||
snapshots: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
unsubscribe();
|
||||
} finally {
|
||||
await transport.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("requires connection registration before cloud access even when another client id is known", async () => {
|
||||
const transport = createTransport({ eventLog: false, runQueue: false });
|
||||
try {
|
||||
await transport.handleCommand({
|
||||
version: "v1",
|
||||
command: "client.register",
|
||||
clientId: "registered",
|
||||
payload: { clientType: "test" },
|
||||
});
|
||||
const reply = await transport.handleCommand(
|
||||
{ version: "v1", command: "cloud.watch", clientId: "registered" },
|
||||
null,
|
||||
);
|
||||
expect(reply.ok).toBe(false);
|
||||
expect(reply.error?.code).toBe("client_not_registered");
|
||||
} finally {
|
||||
await transport.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("continues publishing when one listener throws", () => {
|
||||
const transport = createTransport();
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
@@ -1962,7 +2004,7 @@ describe("HubServerTransport boundaries", () => {
|
||||
const transport = createTransport();
|
||||
const published: string[] = [];
|
||||
transport.subscribe("test", (event) => {
|
||||
published.push(event.event);
|
||||
if (event.event.startsWith("iteration.")) published.push(event.event);
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
|
||||
@@ -2189,7 +2231,7 @@ describe("HubServerTransport boundaries", () => {
|
||||
});
|
||||
const events: HubEventEnvelope[] = [];
|
||||
transport.subscribe("test", (event) => {
|
||||
events.push(event);
|
||||
if (event.event === "usage.updated") events.push(event);
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
|
||||
@@ -2256,7 +2298,9 @@ describe("HubServerTransport boundaries", () => {
|
||||
it("projects notices with teammate provenance intact", async () => {
|
||||
const transport = createTransport();
|
||||
const events: HubEventEnvelope[] = [];
|
||||
transport.subscribe("test", (event) => events.push(event));
|
||||
transport.subscribe("test", (event) => {
|
||||
if (event.event === "session.notice") events.push(event);
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
|
||||
await projectSessionEvent(ctx, {
|
||||
|
||||
@@ -141,6 +141,7 @@ export class BrowserWebSocketHubAdapter {
|
||||
};
|
||||
|
||||
const onEvent = (envelope: HubEventEnvelope): void => {
|
||||
if (envelope.event === "cloud.event" && !authority) return;
|
||||
sendFrame({ kind: "event", envelope });
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,970 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { HubEventEnvelope } from "@cline/shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ClineAccountService } from "../../account";
|
||||
import { getProviderAuthHandler } from "../../auth/provider-auth-registry";
|
||||
import { CloudSessionError } from "../../cloud/api";
|
||||
import type { CloudSessionController } from "../../cloud/controller";
|
||||
import {
|
||||
type CloudHubConnection,
|
||||
HubCloudSessionClient,
|
||||
} from "../../cloud/hub-client";
|
||||
import { CloudSendError } from "../../cloud/hub-protocol";
|
||||
import type {
|
||||
CloudSessionEvent,
|
||||
CloudSessionSnapshot,
|
||||
} from "../../cloud/types";
|
||||
import {
|
||||
OAuthReauthRequiredError,
|
||||
RuntimeOAuthTokenManager,
|
||||
} from "../../runtime/orchestration/runtime-oauth-token-manager";
|
||||
import { ProviderSettingsManager } from "../../services/storage/provider-settings-manager";
|
||||
import {
|
||||
emptyStoredProviderSettings,
|
||||
ProviderSettingsSchemaTyped,
|
||||
} from "../../types/provider-settings";
|
||||
import { NodeHubClient } from "../client";
|
||||
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
|
||||
import { createInMemoryHubOwnerContext } from "../discovery";
|
||||
import { HubCloudSessionService } from "./cloud-session-service";
|
||||
import { startHubWebSocketServer } from "./hub-websocket-server";
|
||||
|
||||
const services: HubCloudSessionService[] = [];
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(services.splice(0).map((service) => service.dispose()));
|
||||
});
|
||||
function command(
|
||||
service: HubCloudSessionService,
|
||||
operation: string,
|
||||
args: unknown,
|
||||
) {
|
||||
return service.command(operation, args, service.snapshot());
|
||||
}
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T | PromiseLike<T>) => void = () => {
|
||||
throw new Error("Deferred not initialized");
|
||||
};
|
||||
const promise = new Promise<T>((fulfill) => {
|
||||
resolve = fulfill;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
function snapshot(sessionId = "ses-one"): CloudSessionSnapshot {
|
||||
return {
|
||||
sessionId,
|
||||
config: { executionTarget: "cloud" },
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
approvals: [],
|
||||
busy: true,
|
||||
status: "running",
|
||||
startedAt: 1,
|
||||
connectionState: "connected",
|
||||
transcriptKnown: true,
|
||||
};
|
||||
}
|
||||
function fixture() {
|
||||
let scope = "account-one";
|
||||
const listeners = new Set<(event: HubEventEnvelope) => void>();
|
||||
const controllers: ReturnType<typeof makeController>[] = [];
|
||||
function makeController() {
|
||||
let emit: (event: CloudSessionEvent) => void = () => {};
|
||||
const controller = {
|
||||
subscribe: (listener: typeof emit) => {
|
||||
emit = listener;
|
||||
return () => {};
|
||||
},
|
||||
create: vi.fn(async () => ({ sessionId: "ses-one", status: "ready" })),
|
||||
attach: vi.fn(async (sessionId: string) => {
|
||||
emit({
|
||||
type: "snapshot",
|
||||
sessionId,
|
||||
snapshot: snapshot(sessionId),
|
||||
replace: true,
|
||||
});
|
||||
return { sessionId, status: "running" };
|
||||
}),
|
||||
send: vi.fn(
|
||||
async (..._args: Parameters<CloudSessionController["send"]>) => ({
|
||||
sessionId: "ses-one",
|
||||
ok: true,
|
||||
}),
|
||||
),
|
||||
prepareHandoffRepository: vi.fn(async (_repoUrl: string) => ({
|
||||
organizationId: "org-one",
|
||||
})),
|
||||
listModels: vi.fn(async () => []),
|
||||
restoreCreationOptions: vi.fn(async () => {}),
|
||||
listForDiscovery: vi.fn(async () => [{ sessionId: "ses-one" }]),
|
||||
dispose: vi.fn(async () => {}),
|
||||
};
|
||||
return { controller, emit: (event: CloudSessionEvent) => emit(event) };
|
||||
}
|
||||
const cloudSessionOptions = {
|
||||
getScope: () => scope,
|
||||
createController: () => {
|
||||
const current = makeController();
|
||||
controllers.push(current);
|
||||
return current.controller as unknown as CloudSessionController;
|
||||
},
|
||||
};
|
||||
const service = new HubCloudSessionService({
|
||||
...cloudSessionOptions,
|
||||
publish: (event, version) => {
|
||||
for (const listener of listeners)
|
||||
listener({
|
||||
version: "v1",
|
||||
event: "cloud.event",
|
||||
sessionId: event?.sessionId,
|
||||
payload: { event, ...version },
|
||||
});
|
||||
},
|
||||
});
|
||||
services.push(service);
|
||||
const command: CloudHubConnection["command"] = async (
|
||||
name,
|
||||
payload,
|
||||
_session,
|
||||
options,
|
||||
) => {
|
||||
options?.beforeDispatch?.();
|
||||
options?.onDispatch?.("request");
|
||||
const body = JSON.parse(JSON.stringify(payload ?? {}));
|
||||
const result =
|
||||
name === "cloud.watch"
|
||||
? service.watch()
|
||||
: await service.command(body.operation, body.args, body.scope);
|
||||
return JSON.parse(
|
||||
JSON.stringify({ version: "v1", ok: true, payload: result }),
|
||||
);
|
||||
};
|
||||
const connection: CloudHubConnection = {
|
||||
command,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
const viewer = () => new HubCloudSessionClient(async () => connection);
|
||||
return {
|
||||
service,
|
||||
cloudSessionOptions,
|
||||
controllers,
|
||||
connection,
|
||||
viewer,
|
||||
emitRecovery: (payload: Record<string, unknown>) => {
|
||||
for (const listener of listeners)
|
||||
listener({ version: "v1", event: "cloud.event", payload });
|
||||
},
|
||||
changeScope: (value: string) => {
|
||||
scope = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("hub-owned cloud sessions", () => {
|
||||
it("pins every handoff operation to its prepared account scope", async () => {
|
||||
const f = fixture();
|
||||
const viewer = f.viewer();
|
||||
expect(viewer.scope).toBeUndefined();
|
||||
await viewer.prepareHandoffRepository("https://github.com/example/project");
|
||||
const scope = viewer.scope;
|
||||
if (!scope) throw new Error("Missing prepared scope");
|
||||
const copy = viewer.scope;
|
||||
if (copy) copy.scopeGeneration++;
|
||||
expect(viewer.scope).toEqual(scope);
|
||||
f.changeScope("account-two");
|
||||
f.service.watch();
|
||||
for (const operation of [
|
||||
() =>
|
||||
viewer.prepareHandoffRepository(
|
||||
"https://github.com/example/project",
|
||||
scope,
|
||||
),
|
||||
() => viewer.listModels(scope),
|
||||
() => viewer.create({ modelId: "model", repoUrl: "repo" }, scope),
|
||||
() =>
|
||||
viewer.send(
|
||||
"ses-one",
|
||||
"Continue",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
scope,
|
||||
),
|
||||
() => viewer.restoreCreationOptions("ses-one", { thinking: true }, scope),
|
||||
])
|
||||
await expect(operation()).rejects.toThrow("account changed");
|
||||
const current = f.controllers[1]?.controller;
|
||||
if (!current) throw new Error("Missing replacement controller");
|
||||
for (const method of [
|
||||
current.prepareHandoffRepository,
|
||||
current.listModels,
|
||||
current.create,
|
||||
current.send,
|
||||
current.restoreCreationOptions,
|
||||
])
|
||||
expect(method).not.toHaveBeenCalled();
|
||||
await expect(viewer.listModels()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a lost send reply unknown without replaying the accepted input", async () => {
|
||||
const f = fixture();
|
||||
const viewer = f.viewer();
|
||||
await viewer.connect();
|
||||
const original = f.connection.command;
|
||||
f.connection.command = async (...args) => {
|
||||
await original(...args);
|
||||
throw new Error("Reply lost");
|
||||
};
|
||||
const failure = await viewer
|
||||
.send("ses-one", "Once")
|
||||
.catch((error: unknown) => error);
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
expect(failure).not.toBeInstanceOf(CloudSendError);
|
||||
expect(f.controllers[0]?.controller.send).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not rebind a prepared request when account recovery runs before dispatch", async () => {
|
||||
const f = fixture();
|
||||
const viewer = f.viewer();
|
||||
await viewer.connect();
|
||||
const creating = viewer.create({ modelId: "model", repoUrl: "repo" });
|
||||
f.changeScope("account-two");
|
||||
f.service.watch();
|
||||
await expect(creating).rejects.toThrow("account changed");
|
||||
for (const { controller } of f.controllers)
|
||||
expect(controller.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"account",
|
||||
"daemon",
|
||||
])("rejects a delayed create after the %s changes before admission", async (change) => {
|
||||
const f = fixture();
|
||||
const viewer = f.viewer();
|
||||
await viewer.connect();
|
||||
const original = f.connection.command;
|
||||
f.connection.command = async (name, payload, ...rest) => {
|
||||
if (name === "cloud.command") {
|
||||
if (change === "account") f.changeScope("account-two");
|
||||
else
|
||||
payload = {
|
||||
...payload,
|
||||
scope: { ...(payload?.scope as object), serviceId: "old-daemon" },
|
||||
};
|
||||
}
|
||||
return original(name, payload, ...rest);
|
||||
};
|
||||
await expect(
|
||||
viewer.create({ modelId: "model", repoUrl: "repo" }),
|
||||
).rejects.toThrow("account changed");
|
||||
expect(f.controllers).toHaveLength(1);
|
||||
expect(f.controllers[0]?.controller.create).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
"plan",
|
||||
"zen",
|
||||
] as const)("preserves %s mode and workspace paths at the RPC boundary", async (mode) => {
|
||||
const f = fixture();
|
||||
const viewer = f.viewer();
|
||||
const options = {
|
||||
mode,
|
||||
workspaceRelativePath: "packages/app",
|
||||
thinking: true,
|
||||
autoApproveTools: false,
|
||||
reasoningEffort: "high" as const,
|
||||
};
|
||||
await viewer.create({ modelId: "model", repoUrl: "repo", ...options });
|
||||
await viewer.attach("ses-one", options);
|
||||
await viewer.restoreCreationOptions("ses-one", options);
|
||||
expect(f.controllers[0]?.controller.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining(options),
|
||||
);
|
||||
expect(f.controllers[0]?.controller.attach).toHaveBeenCalledWith(
|
||||
"ses-one",
|
||||
options,
|
||||
);
|
||||
expect(
|
||||
f.controllers[0]?.controller.restoreCreationOptions,
|
||||
).toHaveBeenCalledWith("ses-one", options);
|
||||
});
|
||||
it.each([
|
||||
"same",
|
||||
"new",
|
||||
])("clears missed deletions on recovery from the %s daemon, including an empty set", async (daemon) => {
|
||||
const f = fixture();
|
||||
const viewer = f.viewer();
|
||||
await viewer.attach("ses-one");
|
||||
const removed = vi.fn();
|
||||
viewer.subscribe(removed);
|
||||
const state = f.service.snapshot();
|
||||
f.emitRecovery({
|
||||
...state,
|
||||
serviceId: daemon === "new" ? "new-daemon" : state.serviceId,
|
||||
snapshots: [],
|
||||
});
|
||||
expect(viewer.getSnapshot("ses-one")).toBeUndefined();
|
||||
expect(removed).toHaveBeenCalledWith({
|
||||
type: "removed",
|
||||
sessionId: "ses-one",
|
||||
});
|
||||
});
|
||||
|
||||
it("shares a controller across viewers and retains work after viewer disposal", async () => {
|
||||
const f = fixture();
|
||||
const first = f.viewer();
|
||||
const second = f.viewer();
|
||||
await first.attach("ses-one");
|
||||
await second.connect();
|
||||
expect(second.getSnapshot("ses-one")?.status).toBe("running");
|
||||
await first.dispose();
|
||||
expect(f.controllers).toHaveLength(1);
|
||||
expect(f.controllers[0]!.controller.dispose).not.toHaveBeenCalled();
|
||||
await second.send("ses-one", "Continue", undefined, undefined, ["image"]);
|
||||
expect(f.controllers[0]!.controller.send).toHaveBeenCalledWith(
|
||||
"ses-one",
|
||||
"Continue",
|
||||
undefined,
|
||||
undefined,
|
||||
["image"],
|
||||
{ beforeDispatch: expect.any(Function) },
|
||||
);
|
||||
await f.service.dispose();
|
||||
expect(f.controllers[0]!.controller.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("keeps an admitted send alive when its viewer disconnects", async () => {
|
||||
const f = fixture();
|
||||
const viewer = f.viewer();
|
||||
await viewer.connect();
|
||||
const sending = deferred<{ sessionId: string; ok: boolean }>();
|
||||
f.controllers[0]!.controller.send.mockReturnValue(sending.promise);
|
||||
const result = viewer
|
||||
.send("ses-one", "Continue")
|
||||
.catch((error: unknown) => error);
|
||||
await vi.waitFor(() => expect(f.service.activeCommands).toBe(1));
|
||||
await viewer.dispose();
|
||||
expect(f.controllers[0]!.controller.dispose).not.toHaveBeenCalled();
|
||||
sending.resolve({ sessionId: "ses-one", ok: true });
|
||||
expect(await result).toBeInstanceOf(Error);
|
||||
expect(f.service.activeCommands).toBe(0);
|
||||
});
|
||||
it("retires old account state and ignores late events and replies", async () => {
|
||||
const f = fixture();
|
||||
const viewer = f.viewer();
|
||||
await viewer.attach("ses-one");
|
||||
const old = f.controllers[0]!;
|
||||
const pending = deferred<{ sessionId: string; ok: boolean }>();
|
||||
old.controller.send.mockReturnValue(pending.promise);
|
||||
const result = viewer
|
||||
.send("ses-one", "Continue")
|
||||
.catch((error: unknown) => error);
|
||||
await vi.waitFor(() => expect(old.controller.send).toHaveBeenCalledOnce());
|
||||
f.changeScope("account-two");
|
||||
await command(f.service, "listForDiscovery", []);
|
||||
expect(viewer.getSnapshot("ses-one")).toBeUndefined();
|
||||
expect(old.controller.dispose).toHaveBeenCalledOnce();
|
||||
old.emit({
|
||||
type: "snapshot",
|
||||
sessionId: "ses-one",
|
||||
snapshot: snapshot(),
|
||||
replace: true,
|
||||
});
|
||||
expect(viewer.getSnapshot("ses-one")).toBeUndefined();
|
||||
pending.resolve({ sessionId: "ses-one", ok: true });
|
||||
expect(String(await result)).toContain("account changed");
|
||||
});
|
||||
it("invalidates discovery-only caches on account change without another client command", async () => {
|
||||
vi.useFakeTimers();
|
||||
const f = fixture();
|
||||
const viewer = f.viewer();
|
||||
await viewer.listForDiscovery();
|
||||
expect(viewer.getCachedDiscoveryRecord("ses-one")).toBeDefined();
|
||||
f.changeScope("account-two");
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(viewer.getCachedDiscoveryRecord("ses-one")).toBeUndefined();
|
||||
});
|
||||
it("validates operations and provisioning payloads before invoking the controller", async () => {
|
||||
const f = fixture();
|
||||
await expect(command(f.service, "dispose", [])).rejects.toThrow(
|
||||
"Unknown cloud operation",
|
||||
);
|
||||
await expect(command(f.service, "__proto__", [])).rejects.toThrow(
|
||||
"Unknown cloud operation",
|
||||
);
|
||||
await expect(
|
||||
command(f.service, "create", [
|
||||
{ modelId: "model", repoUrl: "repo", handoff: {} },
|
||||
]),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
command(f.service, "respondApproval", [
|
||||
"ses-one",
|
||||
"approval",
|
||||
{ approved: "yes" },
|
||||
]),
|
||||
).rejects.toThrow();
|
||||
expect(f.controllers).toHaveLength(0);
|
||||
await command(f.service, "create", [{ modelId: "model", repoUrl: "repo" }]);
|
||||
expect(f.controllers[0]!.controller.create).toHaveBeenCalledWith({
|
||||
modelId: "model",
|
||||
repoUrl: "repo",
|
||||
sandboxType: "resumable",
|
||||
});
|
||||
});
|
||||
it("does not resend a dispatched action when transport recovery retries", async () => {
|
||||
const f = fixture();
|
||||
const viewer = f.viewer();
|
||||
await viewer.connect();
|
||||
f.connection.command = async (_name, _payload, _session, options) => {
|
||||
options?.beforeDispatch?.();
|
||||
options?.onDispatch?.("first");
|
||||
options?.beforeDispatch?.();
|
||||
throw new Error("This must not be reached");
|
||||
};
|
||||
await expect(viewer.send("ses-one", "Once")).rejects.toThrow(
|
||||
"outcome is unknown",
|
||||
);
|
||||
});
|
||||
it("ignores a hydration reply older than a live snapshot", async () => {
|
||||
const f = fixture();
|
||||
await command(f.service, "attach", ["ses-one"]);
|
||||
const ready = deferred<void>();
|
||||
const original = f.connection.command;
|
||||
f.connection.command = async (name, ...args) => {
|
||||
const reply = await original(name, ...args);
|
||||
if (name === "cloud.watch") await ready.promise;
|
||||
return reply;
|
||||
};
|
||||
const viewer = f.viewer();
|
||||
const connecting = viewer.connect();
|
||||
await vi.waitFor(() => expect(f.controllers).toHaveLength(1));
|
||||
const current = { ...snapshot(), status: "completed", busy: false };
|
||||
f.controllers[0]!.emit({
|
||||
type: "snapshot",
|
||||
sessionId: "ses-one",
|
||||
snapshot: current,
|
||||
replace: true,
|
||||
});
|
||||
ready.resolve(undefined);
|
||||
await connecting;
|
||||
expect(viewer.getSnapshot("ses-one")?.status).toBe("completed");
|
||||
});
|
||||
it("hydrates untouched sessions when another session receives a newer live event", async () => {
|
||||
const f = fixture();
|
||||
await command(f.service, "attach", ["ses-one"]);
|
||||
const ready = deferred<void>();
|
||||
const original = f.connection.command;
|
||||
let watching = false;
|
||||
f.connection.command = async (name, ...args) => {
|
||||
const reply = await original(name, ...args);
|
||||
if (name === "cloud.watch") {
|
||||
watching = true;
|
||||
await ready.promise;
|
||||
}
|
||||
return reply;
|
||||
};
|
||||
const viewer = f.viewer();
|
||||
const connecting = viewer.connect();
|
||||
await vi.waitFor(() => expect(watching).toBe(true));
|
||||
f.controllers[0]?.emit({
|
||||
type: "snapshot",
|
||||
sessionId: "ses-two",
|
||||
snapshot: snapshot("ses-two"),
|
||||
replace: true,
|
||||
});
|
||||
ready.resolve(undefined);
|
||||
await connecting;
|
||||
expect(viewer.getSnapshot("ses-one")).toBeDefined();
|
||||
expect(viewer.getSnapshot("ses-two")).toBeDefined();
|
||||
});
|
||||
it("does not dispatch queued work after the viewer is disposed during hydration", async () => {
|
||||
const f = fixture();
|
||||
const ready = deferred<void>();
|
||||
const original = f.connection.command;
|
||||
let watching = false;
|
||||
f.connection.command = async (name, ...args) => {
|
||||
const reply = await original(name, ...args);
|
||||
if (name === "cloud.watch") {
|
||||
watching = true;
|
||||
await ready.promise;
|
||||
}
|
||||
return reply;
|
||||
};
|
||||
const viewer = f.viewer();
|
||||
const sending = viewer
|
||||
.send("ses-one", "Never dispatch")
|
||||
.catch((error: unknown) => error);
|
||||
await vi.waitFor(() => expect(watching).toBe(true));
|
||||
await viewer.dispose();
|
||||
ready.resolve(undefined);
|
||||
expect(String(await sending)).toContain("disposed");
|
||||
expect(f.controllers[0]?.controller.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloud RPC over a shared hub socket", () => {
|
||||
async function startCloudHub(f: ReturnType<typeof fixture>) {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-cloud-hub-"));
|
||||
const server = await startHubWebSocketServer({
|
||||
owner: createInMemoryHubOwnerContext("cloud-wire"),
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
workspaceRoot: root,
|
||||
sessionHost: {
|
||||
subscribe: () => () => {},
|
||||
dispose: async () => {},
|
||||
listSessions: async () => [],
|
||||
} as never,
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
scheduleOptions: { dbPath: ":memory:" },
|
||||
taskOptions: {
|
||||
dbPath: join(root, "tasks.db"),
|
||||
globalSpecsDir: join(root, "specs"),
|
||||
watchFiles: false,
|
||||
},
|
||||
eventLog: { dbPath: ":memory:" },
|
||||
runQueue: false,
|
||||
cloudSessionOptions: f.cloudSessionOptions,
|
||||
});
|
||||
return { root, server };
|
||||
}
|
||||
|
||||
it("preserves repository preparation, send success, and typed delivery failures over the socket", async () => {
|
||||
const f = fixture();
|
||||
const { root, server } = await startCloudHub(f);
|
||||
const client = new NodeHubClient({
|
||||
url: server.url,
|
||||
authToken: server.authToken,
|
||||
clientId: "cloud-outcomes",
|
||||
workspaceRoot: root,
|
||||
});
|
||||
const viewer = new HubCloudSessionClient(async () => client);
|
||||
try {
|
||||
const repo = "https://github.com/example/project";
|
||||
await expect(viewer.prepareHandoffRepository(repo)).resolves.toEqual({
|
||||
organizationId: "org-one",
|
||||
});
|
||||
const controller = f.controllers[0]?.controller;
|
||||
if (!controller) throw new Error("Missing cloud controller");
|
||||
expect(
|
||||
controller.prepareHandoffRepository,
|
||||
).toHaveBeenCalledExactlyOnceWith(repo);
|
||||
await expect(
|
||||
viewer.send("ses-one", "Continue", undefined, "model", ["image"]),
|
||||
).resolves.toEqual({ sessionId: "ses-one", ok: true });
|
||||
expect(controller.send).toHaveBeenLastCalledWith(
|
||||
"ses-one",
|
||||
"Continue",
|
||||
undefined,
|
||||
"model",
|
||||
["image"],
|
||||
{ beforeDispatch: expect.any(Function) },
|
||||
);
|
||||
for (const dispatched of [false, true]) {
|
||||
const original = new CloudSessionError(
|
||||
"session_failed",
|
||||
"Repository clone failed.",
|
||||
);
|
||||
const thrown = dispatched
|
||||
? Object.assign(new Error(original.message), {
|
||||
detail: original.detail,
|
||||
})
|
||||
: original;
|
||||
controller.send.mockImplementationOnce(async (...args) => {
|
||||
if (dispatched) args[5]?.beforeDispatch?.();
|
||||
throw thrown;
|
||||
});
|
||||
const error = await viewer
|
||||
.send("ses-one", "Continue")
|
||||
.catch((error: unknown) => error);
|
||||
expect(error).toBeInstanceOf(CloudSendError);
|
||||
expect(error).toMatchObject({
|
||||
dispatched,
|
||||
message: original.message,
|
||||
detail: original.detail,
|
||||
});
|
||||
}
|
||||
await client.command("cloud.command", {
|
||||
operation: "send",
|
||||
args: ["ses-one", "Short arguments"],
|
||||
scope: viewer.scope,
|
||||
});
|
||||
expect(controller.send).toHaveBeenLastCalledWith(
|
||||
"ses-one",
|
||||
"Short arguments",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ beforeDispatch: expect.any(Function) },
|
||||
);
|
||||
} finally {
|
||||
await viewer.dispose();
|
||||
await client.dispose();
|
||||
await server.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reports in-flight cloud commands as hub activity", async () => {
|
||||
const f = fixture();
|
||||
const { root, server } = await startCloudHub(f);
|
||||
const client = new NodeHubClient({
|
||||
url: server.url,
|
||||
authToken: server.authToken,
|
||||
clientId: "cloud-activity",
|
||||
workspaceRoot: root,
|
||||
});
|
||||
const viewer = new HubCloudSessionClient(async () => {
|
||||
await client.connect();
|
||||
return client;
|
||||
});
|
||||
try {
|
||||
await viewer.connect();
|
||||
const provisioning = deferred<{ sessionId: string; status: string }>();
|
||||
f.controllers[0]!.controller.create.mockReturnValueOnce(
|
||||
provisioning.promise,
|
||||
);
|
||||
const created = viewer.create({
|
||||
modelId: "model",
|
||||
repoUrl: "https://github.com/cline/cline",
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(f.controllers[0]!.controller.create).toHaveBeenCalled(),
|
||||
);
|
||||
expect((await client.command("session.list")).payload).toMatchObject({
|
||||
activeCloudCommands: 1,
|
||||
});
|
||||
expect((await client.command("hub.status")).payload).toMatchObject({
|
||||
idle: false,
|
||||
});
|
||||
provisioning.resolve({ sessionId: "ses-one", status: "ready" });
|
||||
await created;
|
||||
expect((await client.command("session.list")).payload).toMatchObject({
|
||||
activeCloudCommands: 0,
|
||||
});
|
||||
} finally {
|
||||
await viewer.dispose();
|
||||
await client.dispose();
|
||||
await server.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("hydrates another desktop client, preserves ownership on disconnect, and refuses new work while draining", async () => {
|
||||
const f = fixture();
|
||||
const { root, server } = await startCloudHub(f);
|
||||
const first = new NodeHubClient({
|
||||
url: server.url,
|
||||
authToken: server.authToken,
|
||||
clientId: "cloud-first",
|
||||
workspaceRoot: root,
|
||||
});
|
||||
const second = new NodeHubClient({
|
||||
url: server.url,
|
||||
authToken: server.authToken,
|
||||
clientId: "cloud-second",
|
||||
workspaceRoot: root,
|
||||
});
|
||||
try {
|
||||
const viewer = new HubCloudSessionClient(async () => {
|
||||
await first.connect();
|
||||
return first;
|
||||
});
|
||||
await viewer.attach("ses-one");
|
||||
expect(viewer.getSnapshot("ses-one")?.status).toBe("running");
|
||||
await viewer.dispose();
|
||||
await first.dispose();
|
||||
expect(f.controllers[0]?.controller.dispose).not.toHaveBeenCalled();
|
||||
const next = new HubCloudSessionClient(async () => {
|
||||
await second.connect();
|
||||
return second;
|
||||
});
|
||||
await next.connect();
|
||||
expect(next.getSnapshot("ses-one")?.status).toBe("running");
|
||||
const events: HubEventEnvelope[] = [];
|
||||
second.subscribe((event) => events.push(event));
|
||||
f.controllers[0]?.emit({
|
||||
type: "snapshot",
|
||||
sessionId: "ses-one",
|
||||
snapshot: snapshot(),
|
||||
replace: true,
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(events.some((event) => event.event === "cloud.event")).toBe(
|
||||
true,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
events
|
||||
.filter((event) => event.event === "cloud.event")
|
||||
.every((event) => event.sequence === undefined),
|
||||
).toBe(true);
|
||||
await second.command("hub.drain");
|
||||
await expect(next.send("ses-one", "New work")).rejects.toThrow(
|
||||
/draining/,
|
||||
);
|
||||
await next.dispose();
|
||||
} finally {
|
||||
await first.dispose();
|
||||
await second.dispose();
|
||||
await server.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
expect(f.controllers[0]?.controller.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hub cloud credentials", () => {
|
||||
it.each([
|
||||
"failed refresh",
|
||||
"returning viewer",
|
||||
])("discards the previous organization after a %s", async (reason) => {
|
||||
vi.spyOn(
|
||||
ProviderSettingsManager.prototype,
|
||||
"getProviderSettings",
|
||||
).mockReturnValue({ provider: "cline", apiKey: "key" });
|
||||
vi.spyOn(
|
||||
RuntimeOAuthTokenManager.prototype,
|
||||
"resolveProviderApiKey",
|
||||
).mockResolvedValue(null);
|
||||
const fetch = vi
|
||||
.spyOn(ClineAccountService.prototype, "fetchUserOrganizations")
|
||||
.mockResolvedValueOnce([
|
||||
{ organizationId: "org-one", active: true },
|
||||
] as never);
|
||||
const service = new HubCloudSessionService({ publish: () => {} });
|
||||
services.push(service);
|
||||
service.watch();
|
||||
const controller = service["controller"]!;
|
||||
const getOrganization = controller["options"].getActiveOrganizationId!;
|
||||
expect(await getOrganization()).toBe("org-one");
|
||||
if (reason === "failed refresh") {
|
||||
fetch.mockRejectedValueOnce(new Error("organization request failed"));
|
||||
await expect(command(service, "refreshOrganization", [])).rejects.toThrow(
|
||||
"organization request failed",
|
||||
);
|
||||
} else service.watch();
|
||||
fetch.mockResolvedValueOnce([
|
||||
{ organizationId: "org-two", active: true },
|
||||
] as never);
|
||||
expect(await getOrganization()).toBe("org-two");
|
||||
expect(service["controller"]).toBe(controller);
|
||||
});
|
||||
|
||||
it("refreshes the daemon organization cache through RPC without disposing cloud work", async () => {
|
||||
vi.spyOn(
|
||||
ProviderSettingsManager.prototype,
|
||||
"getProviderSettings",
|
||||
).mockReturnValue({ provider: "cline", apiKey: "key" });
|
||||
vi.spyOn(
|
||||
RuntimeOAuthTokenManager.prototype,
|
||||
"resolveProviderApiKey",
|
||||
).mockResolvedValue(null);
|
||||
const fetch = vi
|
||||
.spyOn(ClineAccountService.prototype, "fetchUserOrganizations")
|
||||
.mockResolvedValueOnce([
|
||||
{ organizationId: "org-one", active: true },
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{ organizationId: "org-two", active: true },
|
||||
] as never);
|
||||
const service = new HubCloudSessionService({ publish: () => {} });
|
||||
services.push(service);
|
||||
service.watch();
|
||||
const controller = service["controller"]!;
|
||||
const getOrganization = controller["options"].getActiveOrganizationId!;
|
||||
expect(await getOrganization()).toBe("org-one");
|
||||
expect(await getOrganization()).toBe("org-one");
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
await command(service, "refreshOrganization", []);
|
||||
expect(await getOrganization()).toBe("org-two");
|
||||
expect(service["controller"]).toBe(controller);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retains the controller when its OAuth refresh rotates tokens without an account id", async () => {
|
||||
let settings: NonNullable<
|
||||
ReturnType<ProviderSettingsManager["getProviderSettings"]>
|
||||
> = {
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
expiresAt: 1,
|
||||
},
|
||||
};
|
||||
vi.spyOn(
|
||||
ProviderSettingsManager.prototype,
|
||||
"getProviderSettings",
|
||||
).mockImplementation(() => settings);
|
||||
vi.spyOn(
|
||||
ProviderSettingsManager.prototype,
|
||||
"saveProviderSettings",
|
||||
).mockImplementation((next) => {
|
||||
settings = ProviderSettingsSchemaTyped.parse(next);
|
||||
return emptyStoredProviderSettings();
|
||||
});
|
||||
const handler = getProviderAuthHandler("cline");
|
||||
if (!handler) throw new Error("Missing Cline auth handler");
|
||||
vi.spyOn(handler, "refresh").mockResolvedValue({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: Date.now() + 3600000,
|
||||
});
|
||||
const service = new HubCloudSessionService({ publish: () => {} });
|
||||
services.push(service);
|
||||
service.watch();
|
||||
const controller = service["controller"]!;
|
||||
const dispose = vi.spyOn(controller, "dispose");
|
||||
expect(await controller["options"].getAuthToken()).toContain("new-access");
|
||||
service.watch();
|
||||
expect(service["controller"]).toBe(controller);
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not read provider settings for every streamed event", () => {
|
||||
const reads = vi
|
||||
.spyOn(ProviderSettingsManager.prototype, "getProviderSettings")
|
||||
.mockReturnValue({ provider: "cline", apiKey: "key" });
|
||||
let emit: (event: CloudSessionEvent) => void = () => {};
|
||||
const publish = vi.fn();
|
||||
const service = new HubCloudSessionService({
|
||||
publish,
|
||||
createController: () =>
|
||||
({
|
||||
subscribe: (listener: typeof emit) => {
|
||||
emit = listener;
|
||||
},
|
||||
dispose: async () => {},
|
||||
}) as unknown as CloudSessionController,
|
||||
});
|
||||
services.push(service);
|
||||
service.watch();
|
||||
reads.mockClear();
|
||||
for (let i = 0; i < 100; i++)
|
||||
emit({
|
||||
type: "snapshot",
|
||||
sessionId: "ses-one",
|
||||
snapshot: snapshot(),
|
||||
replace: false,
|
||||
});
|
||||
expect(reads).not.toHaveBeenCalled();
|
||||
expect(publish).toHaveBeenCalledTimes(100);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"manual",
|
||||
"refresh",
|
||||
"transient",
|
||||
"reauth",
|
||||
])("resolves %s credentials inside the daemon", async (mode) => {
|
||||
vi.spyOn(
|
||||
ProviderSettingsManager.prototype,
|
||||
"getProviderSettings",
|
||||
).mockReturnValue({ provider: "cline", apiKey: "persisted-key" });
|
||||
const resolve = vi.spyOn(
|
||||
RuntimeOAuthTokenManager.prototype,
|
||||
"resolveProviderApiKey",
|
||||
);
|
||||
if (mode === "refresh")
|
||||
resolve.mockResolvedValue({ apiKey: "fresh-key" } as never);
|
||||
else if (mode === "transient")
|
||||
resolve.mockRejectedValue(new Error("temporarily unavailable"));
|
||||
else if (mode === "reauth")
|
||||
resolve.mockRejectedValue(new OAuthReauthRequiredError("cline"));
|
||||
else resolve.mockResolvedValue(null);
|
||||
const service = new HubCloudSessionService({ publish: () => {} });
|
||||
services.push(service);
|
||||
service.watch();
|
||||
const controller = service["controller"];
|
||||
if (!controller) throw new Error("Controller missing");
|
||||
expect(await controller["options"].getAuthToken()).toBe(
|
||||
mode === "reauth"
|
||||
? undefined
|
||||
: mode === "refresh"
|
||||
? "fresh-key"
|
||||
: "persisted-key",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("persisted cloud account scope", () => {
|
||||
it.each([
|
||||
"key",
|
||||
"account",
|
||||
"endpoint",
|
||||
"signout",
|
||||
])("retires its controller after a %s change", (change) => {
|
||||
let settings: ReturnType<ProviderSettingsManager["getProviderSettings"]> = {
|
||||
provider: "cline",
|
||||
apiKey: "first-key",
|
||||
};
|
||||
if (change === "account")
|
||||
settings.auth = { accountId: "account-one", accessToken: "access-one" };
|
||||
vi.spyOn(
|
||||
ProviderSettingsManager.prototype,
|
||||
"getProviderSettings",
|
||||
).mockImplementation(() => settings);
|
||||
const created: Array<{
|
||||
subscribe: ReturnType<typeof vi.fn>;
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
}> = [];
|
||||
const service = new HubCloudSessionService({
|
||||
publish: () => {},
|
||||
createController: () => {
|
||||
const controller = {
|
||||
subscribe: vi.fn(),
|
||||
dispose: vi.fn(async () => {}),
|
||||
};
|
||||
created.push(controller);
|
||||
return controller as unknown as CloudSessionController;
|
||||
},
|
||||
});
|
||||
services.push(service);
|
||||
service.watch();
|
||||
service.watch();
|
||||
expect(created).toHaveLength(1);
|
||||
if (change === "key") settings = { ...settings, apiKey: "second-key" };
|
||||
else if (change === "account")
|
||||
settings = {
|
||||
...settings,
|
||||
auth: { accountId: "account-two", accessToken: "access-two" },
|
||||
};
|
||||
else if (change === "endpoint")
|
||||
settings = { ...settings, baseUrl: "https://other.example" };
|
||||
else settings = undefined;
|
||||
service.watch();
|
||||
expect(created).toHaveLength(2);
|
||||
expect(created[0]?.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("keeps the same controller when a token refresh preserves account identity", () => {
|
||||
let accessToken = "first-access";
|
||||
vi.spyOn(
|
||||
ProviderSettingsManager.prototype,
|
||||
"getProviderSettings",
|
||||
).mockImplementation(() => ({
|
||||
provider: "cline",
|
||||
auth: { accountId: "same-account", accessToken },
|
||||
}));
|
||||
const controller = { subscribe: vi.fn(), dispose: vi.fn(async () => {}) };
|
||||
const createController = vi.fn(
|
||||
() => controller as unknown as CloudSessionController,
|
||||
);
|
||||
const service = new HubCloudSessionService({
|
||||
publish: () => {},
|
||||
createController,
|
||||
});
|
||||
services.push(service);
|
||||
service.watch();
|
||||
accessToken = "refreshed-access";
|
||||
service.watch();
|
||||
expect(createController).toHaveBeenCalledOnce();
|
||||
expect(controller.dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { BasicLogger, ITelemetryService } from "@cline/shared";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { z } from "zod";
|
||||
import { ClineAccountService } from "../../account";
|
||||
import { getProviderAuthHandler } from "../../auth/provider-auth-registry";
|
||||
import { CloudSessionApi } from "../../cloud/api";
|
||||
import { CloudSessionController } from "../../cloud/controller";
|
||||
import type {
|
||||
CloudHubOperation,
|
||||
CloudHubScope,
|
||||
CloudSendFailure,
|
||||
} from "../../cloud/hub-protocol";
|
||||
import type {
|
||||
CloudSessionEvent,
|
||||
CloudSessionSnapshot,
|
||||
} from "../../cloud/types";
|
||||
import {
|
||||
OAuthReauthRequiredError,
|
||||
RuntimeOAuthTokenManager,
|
||||
} from "../../runtime/orchestration/runtime-oauth-token-manager";
|
||||
import { ProviderSettingsManager } from "../../services/storage/provider-settings-manager";
|
||||
import { captureAuthRefreshSoftFailure } from "../../services/telemetry/core-events";
|
||||
|
||||
const id = z.string().trim().min(1);
|
||||
const optionalString = z.string().nullish();
|
||||
const creationOptions = z.object({
|
||||
mode: z.enum(["act", "plan", "yolo", "zen"]).optional(),
|
||||
workspaceRelativePath: z.string().optional(),
|
||||
autoApproveTools: z.boolean().optional(),
|
||||
thinking: z.boolean().optional(),
|
||||
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
|
||||
});
|
||||
const schemas = {
|
||||
create: z.tuple([
|
||||
creationOptions
|
||||
.extend({
|
||||
sandboxType: z.enum(["standard", "resumable"]).optional(),
|
||||
requestId: id.optional(),
|
||||
modelId: id,
|
||||
repoUrl: id,
|
||||
initialPrompt: z.string().optional(),
|
||||
branch: id.optional(),
|
||||
organizationId: id.nullable().optional(),
|
||||
})
|
||||
.strict(),
|
||||
]),
|
||||
attach: z.tuple([id, creationOptions.nullish()]),
|
||||
send: z.tuple([
|
||||
id,
|
||||
z.string(),
|
||||
z.enum(["queue", "steer"]).nullish(),
|
||||
optionalString,
|
||||
z.array(z.string()).nullish(),
|
||||
]),
|
||||
abort: z.tuple([id]),
|
||||
pendingPrompts: z.tuple([id]),
|
||||
updatePendingPrompt: z.tuple([
|
||||
id,
|
||||
id,
|
||||
z
|
||||
.object({
|
||||
prompt: z.string().optional(),
|
||||
delivery: z.enum(["queue", "steer"]).optional(),
|
||||
})
|
||||
.strict(),
|
||||
]),
|
||||
removePendingPrompt: z.tuple([id, id]),
|
||||
readMessages: z.tuple([id]),
|
||||
updateTitle: z.tuple([id, z.string()]),
|
||||
delete: z.tuple([id]),
|
||||
list: z.tuple([]),
|
||||
listForDiscovery: z.tuple([
|
||||
z
|
||||
.object({ timeoutMs: z.number().positive().max(30000).optional() })
|
||||
.nullish(),
|
||||
]),
|
||||
getCrossScopeDiscoveryRecord: z.tuple([id]),
|
||||
listModels: z.tuple([]),
|
||||
listRepositories: z.tuple([]),
|
||||
listBranches: z.tuple([
|
||||
z.number().int().positive(),
|
||||
z
|
||||
.object({ cursor: z.string().optional(), query: z.string().optional() })
|
||||
.nullish(),
|
||||
]),
|
||||
respondApproval: z.tuple([
|
||||
id,
|
||||
id,
|
||||
z.object({ approved: z.boolean(), reason: z.string().optional() }).strict(),
|
||||
]),
|
||||
refreshOrganization: z.tuple([]),
|
||||
prepareHandoffRepository: z.tuple([id]),
|
||||
restoreCreationOptions: z.tuple([id, creationOptions.strict()]),
|
||||
} satisfies Record<CloudHubOperation, z.ZodType>;
|
||||
|
||||
export interface HubCloudSessionServiceOptions {
|
||||
publish: (
|
||||
event: CloudSessionEvent | undefined,
|
||||
version: { serviceId: string; revision: number; scopeGeneration: number },
|
||||
) => void;
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
/** Test/embedding seams; the daemon uses persisted account credentials. */
|
||||
getScope?: () => string | undefined;
|
||||
createController?: () => CloudSessionController;
|
||||
}
|
||||
|
||||
/** Private change-detection state; never serialized or logged. */
|
||||
type CloudAccountScope = {
|
||||
kind: "account" | "credential" | "custom";
|
||||
identity: string;
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
|
||||
/** One account-scoped controller per hub, independent of connected viewers. */
|
||||
export class HubCloudSessionService {
|
||||
private readonly serviceId = randomUUID();
|
||||
private scopeGeneration = 0;
|
||||
private revision = 0;
|
||||
private organization?: { id: string | undefined; at: number };
|
||||
private organizationGeneration = 0;
|
||||
private controller?: CloudSessionController;
|
||||
private scope?: CloudAccountScope;
|
||||
private closed = false;
|
||||
private readonly snapshots = new Map<string, CloudSessionSnapshot>();
|
||||
private readonly tokenManager: RuntimeOAuthTokenManager;
|
||||
private readonly settings = new ProviderSettingsManager();
|
||||
private readonly scopeTimer: ReturnType<typeof setInterval>;
|
||||
private readonly retiring = new Set<Promise<void>>();
|
||||
activeCommands = 0;
|
||||
constructor(private readonly options: HubCloudSessionServiceOptions) {
|
||||
this.tokenManager = new RuntimeOAuthTokenManager({
|
||||
providerSettingsManager: this.settings,
|
||||
telemetry: options.telemetry,
|
||||
onCredentialsRefreshed: (previous, next) => {
|
||||
const scope = this.scope;
|
||||
// A refresh rotates credentials without changing this controller's account.
|
||||
if (
|
||||
previous.provider !== "cline" ||
|
||||
!scope ||
|
||||
scope.kind !== "credential" ||
|
||||
scope.identity !== (previous.auth?.accessToken || previous.apiKey) ||
|
||||
scope.apiBaseUrl !== previous.baseUrl
|
||||
)
|
||||
return;
|
||||
const identity =
|
||||
next.auth?.accountId || next.auth?.accessToken || next.apiKey;
|
||||
if (identity)
|
||||
Object.assign(scope, {
|
||||
kind: next.auth?.accountId ? "account" : "credential",
|
||||
identity,
|
||||
apiBaseUrl: next.baseUrl,
|
||||
});
|
||||
},
|
||||
});
|
||||
this.scopeTimer = setInterval(() => this.checkScope(), 1000);
|
||||
this.scopeTimer.unref?.();
|
||||
}
|
||||
private currentScope(): CloudAccountScope | undefined {
|
||||
if (this.options.getScope) {
|
||||
const identity = this.options.getScope();
|
||||
return identity ? { kind: "custom", identity } : undefined;
|
||||
}
|
||||
const settings = this.settings.getProviderSettings("cline");
|
||||
const accountId = settings?.auth?.accountId;
|
||||
const identity =
|
||||
accountId || settings?.auth?.accessToken || settings?.apiKey;
|
||||
return identity
|
||||
? {
|
||||
kind: accountId ? "account" : "credential",
|
||||
identity,
|
||||
apiBaseUrl: settings?.baseUrl,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
private isCurrentScope(scope: CloudAccountScope | undefined): boolean {
|
||||
const current = this.currentScope();
|
||||
return (
|
||||
scope?.kind === current?.kind &&
|
||||
scope?.identity === current?.identity &&
|
||||
scope?.apiBaseUrl === current?.apiBaseUrl
|
||||
);
|
||||
}
|
||||
private checkScope(): void {
|
||||
if (!this.controller || this.isCurrentScope(this.scope)) return;
|
||||
const old = this.controller;
|
||||
this.controller = undefined;
|
||||
this.scope = undefined;
|
||||
for (const sessionId of this.snapshots.keys())
|
||||
this.publish({ type: "removed", sessionId });
|
||||
this.snapshots.clear();
|
||||
this.scopeGeneration++;
|
||||
this.options.publish(undefined, {
|
||||
serviceId: this.serviceId,
|
||||
revision: ++this.revision,
|
||||
scopeGeneration: this.scopeGeneration,
|
||||
});
|
||||
const disposing = old
|
||||
.dispose()
|
||||
.catch((error) =>
|
||||
this.options.logger?.error?.("Cloud controller teardown failed", {
|
||||
error,
|
||||
}),
|
||||
)
|
||||
.finally(() => this.retiring.delete(disposing));
|
||||
this.retiring.add(disposing);
|
||||
}
|
||||
private getController(): CloudSessionController {
|
||||
if (this.closed) throw new Error("Hub cloud service is closed");
|
||||
this.checkScope();
|
||||
if (this.controller) return this.controller;
|
||||
this.invalidateOrganization();
|
||||
this.scope = this.currentScope();
|
||||
const controller =
|
||||
this.options.createController?.() ?? this.createController();
|
||||
this.controller = controller;
|
||||
controller.subscribe((event) => {
|
||||
// Scope changes are checked periodically and at command/auth boundaries.
|
||||
if (this.controller !== controller || this.closed) return;
|
||||
if (event.type === "snapshot")
|
||||
this.snapshots.set(event.sessionId, event.snapshot);
|
||||
if (event.type === "removed") this.snapshots.delete(event.sessionId);
|
||||
this.publish(event);
|
||||
});
|
||||
return controller;
|
||||
}
|
||||
private createController(): CloudSessionController {
|
||||
const environment = getClineEnvironmentConfig();
|
||||
const scope = this.scope;
|
||||
const apiBaseUrl =
|
||||
this.settings.getProviderSettings("cline")?.baseUrl?.trim() ||
|
||||
environment.apiBaseUrl;
|
||||
const getAuthToken = async () => {
|
||||
if (!this.isCurrentScope(scope)) throw new Error("Cline account changed");
|
||||
let token: string | undefined;
|
||||
let refreshError: unknown;
|
||||
try {
|
||||
token = (
|
||||
await this.tokenManager.resolveProviderApiKey({ providerId: "cline" })
|
||||
)?.apiKey;
|
||||
} catch (error) {
|
||||
refreshError = error;
|
||||
}
|
||||
if (!token && !(refreshError instanceof OAuthReauthRequiredError)) {
|
||||
token = getProviderAuthHandler("cline")?.getApiKey(
|
||||
this.settings.getProviderSettings("cline"),
|
||||
);
|
||||
}
|
||||
if (!token && refreshError) {
|
||||
this.options.logger?.error?.("Cloud account token refresh failed", {
|
||||
error: refreshError,
|
||||
});
|
||||
captureAuthRefreshSoftFailure(this.options.telemetry, "cline", {
|
||||
errorName:
|
||||
refreshError instanceof Error ? refreshError.name : "Error",
|
||||
errorCode: "hub_cloud_refresh_failed_no_fallback_token",
|
||||
});
|
||||
}
|
||||
if (!this.isCurrentScope(scope)) throw new Error("Cline account changed");
|
||||
return token;
|
||||
};
|
||||
const account = new ClineAccountService({ apiBaseUrl, getAuthToken });
|
||||
return new CloudSessionController({
|
||||
api: new CloudSessionApi({
|
||||
apiBaseUrl,
|
||||
appBaseUrl: environment.appBaseUrl,
|
||||
getAuthToken,
|
||||
}),
|
||||
apiBaseUrl,
|
||||
getAuthToken,
|
||||
logger: this.options.logger,
|
||||
clientIdentity: {
|
||||
prefix: "hub-cloud",
|
||||
type: "hub-cloud",
|
||||
displayName: "Cline Hub cloud session",
|
||||
source: "desktop",
|
||||
},
|
||||
lateCreateDisposition: "preserve",
|
||||
getActiveOrganizationId: async (options) => {
|
||||
if (options?.fresh) this.invalidateOrganization();
|
||||
const organization = this.organization;
|
||||
if (
|
||||
!options?.fresh &&
|
||||
organization &&
|
||||
Date.now() - organization.at < 60000
|
||||
)
|
||||
return organization.id;
|
||||
const generation = this.organizationGeneration;
|
||||
if (!(await getAuthToken())) return undefined;
|
||||
const organizations = await account.fetchUserOrganizations();
|
||||
const id = organizations?.find((org) => org.active)?.organizationId;
|
||||
if (generation === this.organizationGeneration)
|
||||
this.organization = { id, at: Date.now() };
|
||||
return id;
|
||||
},
|
||||
});
|
||||
}
|
||||
private publish(event: CloudSessionEvent): void {
|
||||
this.options.publish(event, {
|
||||
serviceId: this.serviceId,
|
||||
revision: ++this.revision,
|
||||
scopeGeneration: this.scopeGeneration,
|
||||
});
|
||||
}
|
||||
snapshot(): {
|
||||
serviceId: string;
|
||||
revision: number;
|
||||
scopeGeneration: number;
|
||||
snapshots: CloudSessionSnapshot[];
|
||||
} {
|
||||
this.checkScope();
|
||||
return {
|
||||
serviceId: this.serviceId,
|
||||
revision: this.revision,
|
||||
scopeGeneration: this.scopeGeneration,
|
||||
snapshots: [...this.snapshots.values()],
|
||||
};
|
||||
}
|
||||
private invalidateOrganization(): void {
|
||||
this.organization = undefined;
|
||||
this.organizationGeneration++;
|
||||
}
|
||||
watch(): ReturnType<HubCloudSessionService["snapshot"]> {
|
||||
// A returning viewer may have switched organization while the Hub was unreachable.
|
||||
this.invalidateOrganization();
|
||||
this.getController();
|
||||
return this.snapshot();
|
||||
}
|
||||
async command(
|
||||
operation: string,
|
||||
rawArgs: unknown,
|
||||
expectedScope: unknown,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!Object.hasOwn(schemas, operation))
|
||||
throw new Error(`Unknown cloud operation: ${operation}`);
|
||||
const name = operation as CloudHubOperation;
|
||||
const args = schemas[name].parse(rawArgs) as unknown[];
|
||||
// JSON arrays encode omitted optional arguments as null.
|
||||
const normalized = args.map((value) =>
|
||||
value === null ? undefined : value,
|
||||
);
|
||||
if (name === "create") {
|
||||
const input = normalized[0] as { sandboxType?: string };
|
||||
input.sandboxType ??= "resumable";
|
||||
}
|
||||
this.checkScope();
|
||||
const expected = z
|
||||
.object({ serviceId: z.string(), scopeGeneration: z.number().int() })
|
||||
.parse(expectedScope) satisfies CloudHubScope;
|
||||
if (
|
||||
expected.serviceId !== this.serviceId ||
|
||||
expected.scopeGeneration !== this.scopeGeneration
|
||||
)
|
||||
throw new Error("Cloud account changed before request");
|
||||
const controller = this.getController();
|
||||
this.activeCommands++;
|
||||
try {
|
||||
const method = controller[name] as (...args: unknown[]) => unknown;
|
||||
let dispatched = false;
|
||||
if (name === "send")
|
||||
normalized[5] = {
|
||||
beforeDispatch: () => {
|
||||
dispatched = true;
|
||||
},
|
||||
};
|
||||
let outcome: { result: unknown } | { sendFailure: CloudSendFailure };
|
||||
try {
|
||||
outcome = { result: await method.apply(controller, normalized) };
|
||||
} catch (error) {
|
||||
if (name !== "send") throw error;
|
||||
outcome = {
|
||||
sendFailure: {
|
||||
dispatched,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
...(error instanceof Error &&
|
||||
"detail" in error &&
|
||||
typeof error.detail === "string"
|
||||
? { detail: error.detail }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
this.checkScope();
|
||||
if (this.controller !== controller)
|
||||
throw new Error("Cline account changed during cloud operation");
|
||||
return {
|
||||
serviceId: this.serviceId,
|
||||
revision: this.revision,
|
||||
scopeGeneration: this.scopeGeneration,
|
||||
...outcome,
|
||||
};
|
||||
} finally {
|
||||
this.activeCommands--;
|
||||
}
|
||||
}
|
||||
async dispose(): Promise<void> {
|
||||
this.closed = true;
|
||||
clearInterval(this.scopeTimer);
|
||||
const controller = this.controller;
|
||||
this.controller = undefined;
|
||||
this.snapshots.clear();
|
||||
await Promise.all([controller?.dispose(), ...this.retiring]);
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,13 @@ import type { SessionHistorySearchOptions } from "../../session/search";
|
||||
import type { CoreSettingsService } from "../../settings";
|
||||
import type { AgendaTaskManagerOptions } from "../../tasks";
|
||||
import type { HubOwnerContext } from "../discovery";
|
||||
import type { HubCloudSessionServiceOptions } from "./cloud-session-service";
|
||||
import type { HubEventLogOptions } from "./hub-event-log";
|
||||
import type { HubRunQueueOptions } from "./hub-run-queue";
|
||||
|
||||
export interface HubWebSocketServerOptions {
|
||||
/** Override cloud credentials/controller construction for embedded hubs. */
|
||||
cloudSessionOptions?: Omit<HubCloudSessionServiceOptions, "publish">;
|
||||
/** Hub-owned full-text session history index configuration. */
|
||||
sessionSearchOptions?: SessionHistorySearchOptions;
|
||||
/** Workspace authority assigned by the Hub to authenticated clients. */
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from "../../tasks";
|
||||
import { SessionSource } from "../../types/common";
|
||||
import type { CoreSessionEvent } from "../../types/events";
|
||||
import { HubCloudSessionService } from "./cloud-session-service";
|
||||
import type { HubConnectionAuthority } from "./command-transport";
|
||||
import {
|
||||
handleApprovalRespond,
|
||||
@@ -296,7 +297,18 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
private readonly plugins: PluginRegistry = getProcessPluginRegistry();
|
||||
private unsubscribePluginStatus?: () => void;
|
||||
|
||||
private readonly cloud: HubCloudSessionService;
|
||||
|
||||
constructor(readonly options: HubWebSocketServerOptions) {
|
||||
this.cloud = new HubCloudSessionService({
|
||||
...options.cloudSessionOptions,
|
||||
logger: options.logger,
|
||||
telemetry: options.telemetry,
|
||||
publish: (event, version) =>
|
||||
this.publish(
|
||||
buildHubEvent("cloud.event", { event, ...version }, event?.sessionId),
|
||||
),
|
||||
});
|
||||
this.plugins.setLogger(hubPluginLogger);
|
||||
this.unsubscribePluginStatus = this.plugins.subscribe((status) => {
|
||||
this.publish(
|
||||
@@ -762,6 +774,7 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
() => true,
|
||||
"Hub shutting down before capability request was resolved.",
|
||||
);
|
||||
await this.cloud.dispose();
|
||||
await this.sessionSearch.dispose();
|
||||
await this.tasks.dispose();
|
||||
await this.sessionHost.dispose("hub_server_stop");
|
||||
@@ -785,6 +798,16 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
authority?: HubConnectionAuthority | null,
|
||||
): Promise<HubReplyEnvelope> {
|
||||
try {
|
||||
if (
|
||||
authority === null &&
|
||||
(envelope.command === "cloud.command" ||
|
||||
envelope.command === "cloud.watch")
|
||||
)
|
||||
return errorReply(
|
||||
envelope,
|
||||
"client_not_registered",
|
||||
"Register before accessing cloud sessions",
|
||||
);
|
||||
const clientId = envelope.clientId?.trim();
|
||||
// Omitted authority is reserved for trusted in-process callers. A remote
|
||||
// transport passes null until registration so caller-controlled envelope
|
||||
@@ -827,6 +850,47 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
if (isAgendaTaskCommand(envelope.command)) {
|
||||
return await this.taskCommands.handleCommand(envelope, authority);
|
||||
}
|
||||
if (
|
||||
envelope.command === "cloud.command" ||
|
||||
envelope.command === "cloud.watch"
|
||||
) {
|
||||
const clientId = envelope.clientId?.trim();
|
||||
if (
|
||||
!clientId ||
|
||||
!this.clients.has(clientId) ||
|
||||
(authority && authority.clientId !== clientId)
|
||||
) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"client_not_registered",
|
||||
"Register before accessing cloud sessions",
|
||||
);
|
||||
}
|
||||
if (
|
||||
this.draining &&
|
||||
envelope.command === "cloud.command" &&
|
||||
["create", "attach", "send"].includes(
|
||||
String(envelope.payload?.operation),
|
||||
)
|
||||
)
|
||||
return drainingReply(envelope);
|
||||
try {
|
||||
if (envelope.command === "cloud.watch")
|
||||
return okReply(envelope, this.cloud.watch());
|
||||
const result = await this.cloud.command(
|
||||
String(envelope.payload?.operation ?? ""),
|
||||
envelope.payload?.args,
|
||||
envelope.payload?.scope,
|
||||
);
|
||||
return okReply(envelope, result);
|
||||
} catch (error) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"cloud_command_failed",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
switch (envelope.command) {
|
||||
case "client.register": {
|
||||
const reply = handleClientRegister(this.ctx, envelope);
|
||||
@@ -879,6 +943,7 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
payload: {
|
||||
...reply.payload,
|
||||
activePluginRequests: this.pendingPluginCommands.size,
|
||||
activeCloudCommands: this.cloud.activeCommands,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1215,6 +1280,7 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
hubId: this.hubId,
|
||||
draining: this.draining,
|
||||
activeRpcTurns,
|
||||
activeCloudCommands: this.cloud.activeCommands,
|
||||
activePluginRequests: this.pendingPluginCommands.size,
|
||||
pendingRuns: this.runQueue?.countPending() ?? 0,
|
||||
eventLog: this.eventLog
|
||||
@@ -1223,6 +1289,7 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
// Idle = safe to stop: nothing executing and nothing accepted-but-unstarted.
|
||||
idle:
|
||||
activeRpcTurns === 0 &&
|
||||
this.cloud.activeCommands === 0 &&
|
||||
this.pendingPluginCommands.size === 0 &&
|
||||
(this.runQueue?.countPending() ?? 0) === 0,
|
||||
};
|
||||
@@ -1263,6 +1330,32 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
this.listeners.set(clientId, current);
|
||||
// Re-issue pending approvals so a (re)connecting client can answer a
|
||||
// request raised while it was away instead of leaving the turn parked.
|
||||
const cloud = this.cloud.snapshot();
|
||||
queueMicrotask(() => {
|
||||
if (!this.listeners.get(clientId)?.has(entry)) return;
|
||||
try {
|
||||
entry.listener(
|
||||
buildHubEvent(
|
||||
"cloud.event",
|
||||
{
|
||||
...cloud,
|
||||
snapshots: entry.sessionId
|
||||
? cloud.snapshots.filter(
|
||||
(snapshot) => snapshot.sessionId === entry.sessionId,
|
||||
)
|
||||
: cloud.snapshots,
|
||||
...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
|
||||
},
|
||||
entry.sessionId,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
logHubBoundaryError(
|
||||
"listener threw while recovering cloud state",
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
const pending = pendingApprovalEvents(this.ctx, options?.sessionId);
|
||||
if (pending.length > 0) {
|
||||
queueMicrotask(() => {
|
||||
@@ -1315,7 +1408,8 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
// Durability before delivery: append to the event log and fan out the
|
||||
// sequence-stamped envelope, so live listeners and replaying clients
|
||||
// observe identical frames and the cursor is always meaningful.
|
||||
if (this.eventLog) {
|
||||
// Cloud snapshots are recovered with cloud.watch, never from an old account event log.
|
||||
if (this.eventLog && event.event !== "cloud.event") {
|
||||
try {
|
||||
event = this.eventLog.append(event);
|
||||
} catch (error) {
|
||||
|
||||
@@ -134,7 +134,13 @@ describe("Hub app-server upgrades", () => {
|
||||
await transport.start();
|
||||
try {
|
||||
const seen: HubEventEnvelope[] = [];
|
||||
transport.subscribe("observer", (event) => seen.push(event));
|
||||
transport.subscribe("observer", (event) => {
|
||||
if (event.event === "cloud.event") {
|
||||
expect(event.sequence).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
seen.push(event);
|
||||
});
|
||||
await transport.handleCommand({
|
||||
version: "v1",
|
||||
command: "session.create",
|
||||
|
||||
@@ -50,6 +50,10 @@ export type RuntimeOAuthResolution = {
|
||||
export class RuntimeOAuthTokenManager {
|
||||
private readonly providerSettingsManager: ProviderSettingsManager;
|
||||
private readonly telemetry?: ITelemetryService;
|
||||
private readonly onCredentialsRefreshed?: (
|
||||
previous: ProviderSettings,
|
||||
next: ProviderSettings,
|
||||
) => void;
|
||||
private readonly refreshInFlight = new Map<
|
||||
ManagedOAuthProviderId,
|
||||
Promise<RuntimeOAuthResolution | null>
|
||||
@@ -58,10 +62,15 @@ export class RuntimeOAuthTokenManager {
|
||||
constructor(options?: {
|
||||
providerSettingsManager?: ProviderSettingsManager;
|
||||
telemetry?: ITelemetryService;
|
||||
onCredentialsRefreshed?: (
|
||||
previous: ProviderSettings,
|
||||
next: ProviderSettings,
|
||||
) => void;
|
||||
}) {
|
||||
this.providerSettingsManager =
|
||||
options?.providerSettingsManager ?? new ProviderSettingsManager();
|
||||
this.telemetry = options?.telemetry;
|
||||
this.onCredentialsRefreshed = options?.onCredentialsRefreshed;
|
||||
}
|
||||
|
||||
public async resolveProviderApiKey(input: {
|
||||
@@ -191,6 +200,7 @@ export class RuntimeOAuthTokenManager {
|
||||
setLastUsed: false,
|
||||
tokenSource: "oauth",
|
||||
});
|
||||
this.onCredentialsRefreshed?.(settings, nextSettings);
|
||||
} else {
|
||||
sdkDebug(`oauth.resolve providerId=${providerId} outcome=not_refreshed`);
|
||||
}
|
||||
|
||||
@@ -621,6 +621,8 @@ export type HubCommandOutput<TCommand extends HubTypedCommandName> =
|
||||
HubCommandOutputMap[TCommand];
|
||||
|
||||
export type HubCommandName =
|
||||
| "cloud.command"
|
||||
| "cloud.watch"
|
||||
| "client.register"
|
||||
| "client.update"
|
||||
| "client.unregister"
|
||||
@@ -757,6 +759,7 @@ export interface HubReplyEnvelope {
|
||||
}
|
||||
|
||||
export type HubEventName =
|
||||
| "cloud.event"
|
||||
| "hub.client.registered"
|
||||
| "hub.client.disconnected"
|
||||
| "session.created"
|
||||
|
||||
Reference in New Issue
Block a user