mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
feat(desktop): add cloud session lifecycle (#13856)
* feat(sdk): support authenticated remote Hub connections * fix(sdk): pin compatible SAP connectivity * chore(sdk): defer SAP smoke fix to main * fix(sdk): preserve hub connection failures * feat(hub): recover pending session approvals * fix(hub): scope approval recovery to attached clients * feat(desktop): add cloud session foundations * fix(desktop): require boolean cloud rollout flag * test(desktop): run sidecar suite in pull requests * ci: test desktop changes in stacked pull requests * fix(desktop): scope cloud model catalog rollout * fix(sdk): clear feature flags on identity change * test(hub): reconnect approval session owner * feat(desktop): add cloud session REST client * feat(desktop): add cloud transcript reconciliation * feat(desktop): add cloud session lifecycle * test(desktop): cover cloud session REST client * test(desktop): cover cloud session lifecycle * test(desktop): keep lifecycle fixture layer-local * docs(sdk): clarify remote Hub connection headers * docs(hub): describe approval recovery * docs(llms): describe cloud catalog opt-in * test(sdk): tighten hub header coverage * test(hub): remove redundant approval setup * chore(desktop): trim cloud foundation scaffolding * test(desktop): trim cloud REST coverage * test(desktop): consolidate cloud API cases * test(desktop): remove unused lifecycle fixtures * test(desktop): complete lifecycle context fixture * fix(hub): preserve approval recovery for existing clients * test(core): batch root history fixture inserts * test(desktop): align cloud fixtures with stream context * test(sdk): await daemon health after discovery publication * feat(hub): recover pending session approvals * fix(hub): scope approval recovery to attached clients * test(hub): reconnect approval session owner * docs(hub): describe approval recovery * test(hub): remove redundant approval setup * fix(hub): preserve approval recovery for existing clients * refactor(hub): drop cloud-only pending approval API * chore(desktop): trim redundant cloud session comments * chore(desktop): trim redundant cloud session comments * chore(desktop): trim redundant cloud session comments * refactor(desktop): defer cloud interaction helpers to their owning layers * fix(desktop): preserve cached flags and share account auth * fix(desktop): return cloud session identity before provisioning completes * test(desktop): simplify cloud create recovery fixtures * fix(desktop): reject invalid cloud history snapshots * fix(desktop): refresh expired cloud sessions without a Hub connection * refactor(desktop): rely on backend GitHub token retries * refactor(desktop): require the cloud status API * fix(desktop): reconcile failed cloud session status * fix(desktop): use conversation activity for failed cloud history
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type CloudSessionApi,
|
||||
CloudSessionError,
|
||||
CloudSessionManager,
|
||||
type CloudSessionRecord,
|
||||
cloudSessionToDiscoveryRecord,
|
||||
} from "./cloud-sessions";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
const REMOTE_SESSION: CloudSessionRecord = {
|
||||
id: "ses-outer",
|
||||
status: "ready",
|
||||
sandboxUrl: "https://pod.example/hub",
|
||||
repoContext: { repoUrl: "https://github.com/cline/test" },
|
||||
metadata: { modelId: "anthropic/claude-sonnet-5" },
|
||||
createdAt: "2026-08-05T10:00:00.000Z",
|
||||
updatedAt: "2026-08-05T10:01:00.000Z",
|
||||
};
|
||||
|
||||
function createContext(): { ctx: SidecarContext } {
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
coreStreamActivity: new Map(),
|
||||
bootId: "cloud-test-boot",
|
||||
wsClients: new Set([
|
||||
{
|
||||
data: { canApproveTools: true },
|
||||
send() {},
|
||||
},
|
||||
]),
|
||||
pendingApprovals: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
cloudSessionManager: null,
|
||||
hubClient: null,
|
||||
workspaceRoot: "/local/workspace",
|
||||
unsubscribeSessionEvents: null,
|
||||
hubBuildMismatch: null,
|
||||
} as SidecarContext;
|
||||
return { ctx };
|
||||
}
|
||||
|
||||
describe("CloudSessionManager lifecycle", () => {
|
||||
it.each([
|
||||
"upstream request failed",
|
||||
"couldn't authenticate with GitHub; try reconnecting the integration",
|
||||
])("surfaces create failure without retrying: %s", async (message) => {
|
||||
const { ctx } = createContext();
|
||||
const create = vi.fn(async () => {
|
||||
throw new CloudSessionError("request_failed", message, undefined, 502);
|
||||
});
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: { create } as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.create({
|
||||
modelId: "model",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
}),
|
||||
).rejects.toThrow(message);
|
||||
expect(create).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("projects the outer remote-session id as the desktop session id", () => {
|
||||
expect(
|
||||
cloudSessionToDiscoveryRecord({
|
||||
...REMOTE_SESSION,
|
||||
repoContext: {
|
||||
...REMOTE_SESSION.repoContext,
|
||||
branch: "feature/cloud",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
sessionId: "ses-outer",
|
||||
origin: "cloud",
|
||||
executionTarget: "cloud",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
workspaceRoot: "/workspace",
|
||||
branch: "feature/cloud",
|
||||
metadata: {
|
||||
git: {
|
||||
url: "https://github.com/cline/test",
|
||||
branch: "feature/cloud",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a live session's future expiredAt as a TTL, not an end time", () => {
|
||||
const alive = cloudSessionToDiscoveryRecord({
|
||||
...REMOTE_SESSION,
|
||||
expiredAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
expect(alive.endedAt).toBeUndefined();
|
||||
|
||||
const expired = cloudSessionToDiscoveryRecord({
|
||||
...REMOTE_SESSION,
|
||||
expiredAt: "2026-08-01T00:00:00.000Z",
|
||||
});
|
||||
expect(expired.endedAt).toBe("2026-08-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("overlays live status and prompt-derived title on refreshed REST rows", async () => {
|
||||
const { ctx } = createContext();
|
||||
ctx.liveSessions.set("ses-outer", {
|
||||
config: { executionTarget: "cloud" },
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
prompt: "Fix reconnect behavior\nwith a regression test",
|
||||
attachedViaHub: true,
|
||||
});
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: { list: async () => [REMOTE_SESSION] } as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
});
|
||||
|
||||
const [session] = await manager.listForDiscovery();
|
||||
|
||||
expect(session).toMatchObject({
|
||||
sessionId: "ses-outer",
|
||||
origin: "cloud",
|
||||
status: "running",
|
||||
prompt: "Fix reconnect behavior\nwith a regression test",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
metadata: {
|
||||
title: "Fix reconnect behavior",
|
||||
origin: "cloud",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"expired",
|
||||
"failed",
|
||||
] 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, {
|
||||
api: {
|
||||
create: async () => ({
|
||||
sessionId: remote.id,
|
||||
status: "provisioning",
|
||||
sandboxUrl: "",
|
||||
}),
|
||||
list: async () => [remote],
|
||||
} as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
});
|
||||
await manager.create({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
});
|
||||
expect((await manager.listForDiscovery())[0].status).toBe("ready");
|
||||
|
||||
const endedAt = new Date(Date.now() - 1_000).toISOString();
|
||||
if (status === "expired") {
|
||||
remote.expiredAt = endedAt;
|
||||
} else {
|
||||
remote.status = "failed";
|
||||
remote.lastActivityAt = endedAt;
|
||||
}
|
||||
expect((await manager.listForDiscovery())[0]).toMatchObject({
|
||||
status,
|
||||
endedAt,
|
||||
});
|
||||
expect(ctx.liveSessions.get(remote.id)).toMatchObject({
|
||||
status,
|
||||
busy: false,
|
||||
endedAt: Date.parse(endedAt),
|
||||
});
|
||||
if (status === "failed") {
|
||||
remote.title = "Renamed after failure";
|
||||
remote.updatedAt = new Date().toISOString();
|
||||
expect((await manager.listForDiscovery())[0].endedAt).toBe(endedAt);
|
||||
const live = ctx.liveSessions.get(remote.id)!;
|
||||
ctx.liveSessions.clear();
|
||||
expect((await manager.listForDiscovery())[0].endedAt).toBe(endedAt);
|
||||
|
||||
const hubEndedAt = Date.parse(endedAt) + 500;
|
||||
live.endedAt = hubEndedAt;
|
||||
ctx.liveSessions.set(remote.id, live);
|
||||
expect((await manager.listForDiscovery())[0].endedAt).toBe(
|
||||
new Date(hubEndedAt).toISOString(),
|
||||
);
|
||||
ctx.liveSessions.clear();
|
||||
delete remote.lastActivityAt;
|
||||
expect((await manager.listForDiscovery())[0].endedAt).toBe(
|
||||
remote.createdAt,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("single-flights repeated starts for the same client request", async () => {
|
||||
const { ctx } = createContext();
|
||||
let createCalls = 0;
|
||||
let finishCreate:
|
||||
| ((value: {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
sandboxUrl: string;
|
||||
}) => void)
|
||||
| undefined;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: {
|
||||
list: async () => [],
|
||||
create: () => {
|
||||
createCalls += 1;
|
||||
return new Promise((resolve) => {
|
||||
finishCreate = resolve;
|
||||
});
|
||||
},
|
||||
} as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
});
|
||||
const input = {
|
||||
requestId: "client-start-1",
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
};
|
||||
|
||||
const first = manager.create(input);
|
||||
const second = manager.create(input);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(createCalls).toBe(1);
|
||||
expect(await manager.listForDiscovery()).toEqual([]);
|
||||
|
||||
finishCreate?.({
|
||||
sessionId: "ses-created",
|
||||
status: "provisioning",
|
||||
sandboxUrl: "",
|
||||
});
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
expect.objectContaining({ sessionId: "ses-created" }),
|
||||
expect.objectContaining({ sessionId: "ses-created" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps identical starts from separate chats independent", async () => {
|
||||
const { ctx } = createContext();
|
||||
let createCalls = 0;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: {
|
||||
list: async () => [],
|
||||
create: async () => {
|
||||
createCalls += 1;
|
||||
return {
|
||||
sessionId: `ses-created-${createCalls}`,
|
||||
status: "provisioning",
|
||||
sandboxUrl: "pod",
|
||||
};
|
||||
},
|
||||
} as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
});
|
||||
const input = {
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
};
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
manager.create({ ...input, requestId: "chat-a" }),
|
||||
manager.create({ ...input, requestId: "chat-b" }),
|
||||
]);
|
||||
|
||||
expect(createCalls).toBe(2);
|
||||
expect(first.sessionId).not.toBe(second.sessionId);
|
||||
});
|
||||
|
||||
it("returns cached cloud discovery promptly while a refresh is slow", async () => {
|
||||
const { ctx } = createContext();
|
||||
let listCalls = 0;
|
||||
let finishRefresh: ((value: CloudSessionRecord[]) => void) | undefined;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: {
|
||||
list: async () => {
|
||||
listCalls += 1;
|
||||
if (listCalls === 1) return [REMOTE_SESSION];
|
||||
return await new Promise((resolve) => {
|
||||
finishRefresh = resolve;
|
||||
});
|
||||
},
|
||||
} as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
});
|
||||
await manager.listForDiscovery();
|
||||
|
||||
const cached = await manager.listForDiscovery({ timeoutMs: 1 });
|
||||
|
||||
expect(cached).toEqual([
|
||||
expect.objectContaining({ sessionId: "ses-outer", origin: "cloud" }),
|
||||
]);
|
||||
finishRefresh?.([]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
it("uses only the active organization for billing and session listing", async () => {
|
||||
const { ctx } = createContext();
|
||||
const listCalls: Array<string | undefined> = [];
|
||||
const repositoryScopes: Array<string | undefined> = [];
|
||||
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, {
|
||||
api: {
|
||||
list: async (organizationId?: string) => {
|
||||
listCalls.push(organizationId);
|
||||
return organizationId
|
||||
? [orgSession]
|
||||
: [{ ...REMOTE_SESSION, title: undefined }];
|
||||
},
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
createInput = input;
|
||||
return {
|
||||
sessionId: "ses-created",
|
||||
status: "provisioning",
|
||||
sandboxUrl: "",
|
||||
};
|
||||
},
|
||||
listRepositories: async (organizationId?: string) => {
|
||||
repositoryScopes.push(organizationId);
|
||||
return { connected: true, connectUrl: "", repositories: [] };
|
||||
},
|
||||
listBranches: async (
|
||||
_repositoryId: number,
|
||||
organizationId?: string,
|
||||
) => {
|
||||
branchScopes.push(organizationId);
|
||||
return { available: true, branches: [] };
|
||||
},
|
||||
} as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
getActiveOrganizationId: async () => "org-cline-bot",
|
||||
});
|
||||
const scoped = await manager.list();
|
||||
expect(listCalls).toEqual(["org-cline-bot"]);
|
||||
expect(scoped.map((session) => session.id)).toEqual(["ses-org"]);
|
||||
await manager.listRepositories();
|
||||
await manager.listBranches(42);
|
||||
expect(repositoryScopes).toEqual(["org-cline-bot"]);
|
||||
expect(branchScopes).toEqual(["org-cline-bot"]);
|
||||
|
||||
await manager.create({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
});
|
||||
expect(createInput).toMatchObject({ organizationId: "org-cline-bot" });
|
||||
});
|
||||
|
||||
it("refreshes the active organization before creating a session", async () => {
|
||||
const { ctx } = createContext();
|
||||
let serverScope = "org-a";
|
||||
let cachedScope = serverScope;
|
||||
const lookupOptions: Array<{ fresh?: boolean } | undefined> = [];
|
||||
let createInput: Record<string, unknown> | undefined;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: {
|
||||
list: async () => [],
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
createInput = input;
|
||||
return {
|
||||
sessionId: "ses-created",
|
||||
status: "provisioning",
|
||||
sandboxUrl: "",
|
||||
};
|
||||
},
|
||||
} as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
getActiveOrganizationId: async (options) => {
|
||||
lookupOptions.push(options);
|
||||
if (options?.fresh) cachedScope = serverScope;
|
||||
return cachedScope;
|
||||
},
|
||||
});
|
||||
|
||||
await manager.list();
|
||||
serverScope = "org-b";
|
||||
await manager.create({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
});
|
||||
|
||||
expect(lookupOptions).toEqual([undefined, { fresh: true }]);
|
||||
expect(createInput).toMatchObject({ organizationId: "org-b" });
|
||||
});
|
||||
|
||||
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, {
|
||||
api: {
|
||||
list: async () => [],
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
createInput = input;
|
||||
return {
|
||||
sessionId: "ses-created",
|
||||
status: "provisioning",
|
||||
sandboxUrl: "",
|
||||
};
|
||||
},
|
||||
} as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
getActiveOrganizationId: async () => {
|
||||
throw new Error("account endpoint down");
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
manager.create({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
}),
|
||||
).rejects.toThrow("account endpoint down");
|
||||
expect(createInput?.organizationId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,21 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { decodeJwtPayload } from "@cline/shared";
|
||||
import { NodeHubClient } from "@cline/core";
|
||||
import { decodeJwtPayload, type HubEventEnvelope } from "@cline/shared";
|
||||
import type {
|
||||
CloudBranchListOptions,
|
||||
CloudBranchListResult,
|
||||
CloudRepositoryListResult,
|
||||
} from "../webview/lib/cloud-repositories";
|
||||
import { sendEvent } from "./context";
|
||||
import { resolveSessionListTitle } from "./session-data/common";
|
||||
import type {
|
||||
JsonRecord,
|
||||
LiveSession,
|
||||
PromptInQueue,
|
||||
SidecarContext,
|
||||
} from "./types";
|
||||
|
||||
const CLOUD_WORKSPACE_ROOT = "/workspace";
|
||||
const CREATE_TIMEOUT_MS = 610_000;
|
||||
const PROVISIONING_POLL_MS = 3_000;
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
@@ -29,15 +39,11 @@ export type CloudSessionRecord = {
|
||||
createRequestTitle?: string;
|
||||
};
|
||||
expiredAt?: string | null;
|
||||
lastActivityAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CloudProvisioningOutcome =
|
||||
| { status: "provisioning" }
|
||||
| { status: "ready"; sessionId: string }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
export function deriveCloudSessionTitle(prompt: string): string {
|
||||
return (prompt.trim().split("\n")[0] ?? "").trim().slice(0, 72);
|
||||
}
|
||||
@@ -710,3 +716,471 @@ export class CloudSessionApi {
|
||||
return payload.messages;
|
||||
}
|
||||
}
|
||||
function isExpiredRecord(record: CloudSessionRecord): boolean {
|
||||
const expiredAt = record.expiredAt
|
||||
? Date.parse(record.expiredAt)
|
||||
: Number.NaN;
|
||||
return Number.isFinite(expiredAt) && expiredAt <= Date.now();
|
||||
}
|
||||
|
||||
type CloudHubClient = Pick<
|
||||
NodeHubClient,
|
||||
"command" | "connect" | "dispose" | "getClientId" | "subscribe"
|
||||
>;
|
||||
|
||||
type CloudRehydrationSnapshot = {
|
||||
status: string;
|
||||
messages: unknown[];
|
||||
prompts?: PromptInQueue[];
|
||||
submittedPrompts: PromptInQueue[];
|
||||
};
|
||||
|
||||
type CloudConnection = {
|
||||
remote: CloudSessionRecord;
|
||||
client: CloudHubClient;
|
||||
innerSessionId?: string;
|
||||
rehydrationPromise?: Promise<CloudRehydrationSnapshot>;
|
||||
rehydrationRerunRequested?: boolean;
|
||||
bufferingEvents?: boolean;
|
||||
bufferedEvents: HubEventEnvelope[];
|
||||
rehydrationGeneration: number;
|
||||
transcriptKnown: boolean;
|
||||
seenEventIds: Set<string>;
|
||||
seenEventIdOrder: string[];
|
||||
/** Prevents concurrent sends from creating competing inner sessions. */
|
||||
innerSessionCreation?: Promise<void>;
|
||||
/** Set by disposeConnection; late timers and approval callbacks must not
|
||||
* command (and thereby resurrect) a disposed client. */
|
||||
disposed?: boolean;
|
||||
/** Rate-limits cloud_session_sync_failed to state transitions so a
|
||||
* reconnect loop cannot spam the UI on every attempt. */
|
||||
syncFailureNotified?: boolean;
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
type CloudSessionManagerOptions = {
|
||||
api: Pick<
|
||||
CloudSessionApi,
|
||||
| "create"
|
||||
| "delete"
|
||||
| "list"
|
||||
| "status"
|
||||
| "history"
|
||||
| "updateTitle"
|
||||
| "listRepositories"
|
||||
| "listBranches"
|
||||
>;
|
||||
getAuthToken: () => Promise<string | undefined>;
|
||||
apiBaseUrl: string;
|
||||
getActiveOrganizationId?: (options?: {
|
||||
fresh?: boolean;
|
||||
}) => Promise<string | undefined>;
|
||||
createHubClient?: (
|
||||
options: ConstructorParameters<typeof NodeHubClient>[0],
|
||||
) => CloudHubClient;
|
||||
};
|
||||
|
||||
export function isCloudOuterSessionId(sessionId: string): boolean {
|
||||
return sessionId.trim().startsWith("ses-");
|
||||
}
|
||||
|
||||
function recordToLiveSession(record: CloudSessionRecord): LiveSession {
|
||||
return {
|
||||
config: {
|
||||
executionTarget: "cloud",
|
||||
provider: "cline",
|
||||
providerId: "cline",
|
||||
model: record.metadata.modelId ?? "",
|
||||
modelId: record.metadata.modelId ?? "",
|
||||
repoUrl: record.repoContext.repoUrl ?? "",
|
||||
branch: record.repoContext.branch ?? "",
|
||||
cwd: CLOUD_WORKSPACE_ROOT,
|
||||
workspaceRoot: CLOUD_WORKSPACE_ROOT,
|
||||
},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
// REST "active" means a proxy WebSocket is open, not that the agent is
|
||||
// running. Hub attach/events provide the authoritative busy state.
|
||||
busy: false,
|
||||
startedAt: Date.parse(record.createdAt) || Date.now(),
|
||||
// A future TTL is not an end time.
|
||||
endedAt:
|
||||
isExpiredRecord(record) && record.expiredAt
|
||||
? Date.parse(record.expiredAt)
|
||||
: undefined,
|
||||
status: record.status,
|
||||
attachedViaHub: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloudSessionToDiscoveryRecord(
|
||||
record: CloudSessionRecord,
|
||||
): JsonRecord {
|
||||
return {
|
||||
sessionId: record.id,
|
||||
origin: "cloud",
|
||||
executionTarget: "cloud",
|
||||
status: record.status,
|
||||
provider: "cline",
|
||||
model: record.metadata.modelId ?? "",
|
||||
cwd: CLOUD_WORKSPACE_ROOT,
|
||||
workspaceRoot: CLOUD_WORKSPACE_ROOT,
|
||||
repoUrl: record.repoContext.repoUrl ?? "",
|
||||
branch: record.repoContext.branch ?? "",
|
||||
// updatedAt changes on every reconnect, so it is not a stable start time.
|
||||
startedAt: record.createdAt,
|
||||
endedAt: isExpiredRecord(record)
|
||||
? (record.expiredAt ?? undefined)
|
||||
: record.status === "failed"
|
||||
? (record.lastActivityAt ?? record.createdAt)
|
||||
: undefined,
|
||||
updatedAt: record.updatedAt,
|
||||
...(record.title?.trim() ? { title: record.title.trim() } : {}),
|
||||
metadata: {
|
||||
...(record.title?.trim() ? { title: record.title.trim() } : {}),
|
||||
origin: "cloud",
|
||||
repoUrl: record.repoContext.repoUrl ?? "",
|
||||
git: {
|
||||
url: record.repoContext.repoUrl ?? "",
|
||||
branch: record.repoContext.branch ?? "",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
export class CloudSessionManager {
|
||||
private disposed = false;
|
||||
private readonly connections = new Map<string, CloudConnection>();
|
||||
private readonly connectionPromises = new Map<
|
||||
string,
|
||||
Promise<CloudConnection>
|
||||
>();
|
||||
private readonly knownSessions = new Map<string, CloudSessionRecord>();
|
||||
private lastListedSessions: CloudSessionRecord[] = [];
|
||||
private discoveryRefresh?: Promise<CloudSessionRecord[]>;
|
||||
private readonly createRequests = new Map<string, Promise<JsonRecord>>();
|
||||
private readonly deletingSessions = new Set<string>();
|
||||
private readonly createHubClient: NonNullable<
|
||||
CloudSessionManagerOptions["createHubClient"]
|
||||
>;
|
||||
|
||||
constructor(
|
||||
private readonly ctx: SidecarContext,
|
||||
private readonly options: CloudSessionManagerOptions,
|
||||
) {
|
||||
this.createHubClient =
|
||||
options.createHubClient ??
|
||||
((clientOptions) => new NodeHubClient(clientOptions));
|
||||
}
|
||||
|
||||
isCloudSession(sessionId: string): boolean {
|
||||
return (
|
||||
isCloudOuterSessionId(sessionId) ||
|
||||
this.knownSessions.has(sessionId) ||
|
||||
this.connections.has(sessionId) ||
|
||||
this.ctx.liveSessions.get(sessionId)?.config.executionTarget === "cloud"
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns a session this process already created or discovered without
|
||||
* making account/environment availability a prerequisite for opening it. */
|
||||
getCachedDiscoveryRecord(sessionId: string): JsonRecord | undefined {
|
||||
const record = this.knownSessions.get(sessionId);
|
||||
return record ? cloudSessionToDiscoveryRecord(record) : undefined;
|
||||
}
|
||||
|
||||
/** Revalidates a cached row by id when the active-scope list does not include it. */
|
||||
async getCrossScopeDiscoveryRecord(
|
||||
sessionId: string,
|
||||
): Promise<JsonRecord | undefined> {
|
||||
const cached = this.getCachedDiscoveryRecord(sessionId);
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const status = await this.options.api.status(sessionId);
|
||||
const value = status.status?.trim();
|
||||
return value ? { ...cached, status: value } : cached;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof CloudSessionError &&
|
||||
(error.code === "session_not_found" || error.code === "session_expired")
|
||||
) {
|
||||
this.knownSessions.delete(sessionId);
|
||||
return undefined;
|
||||
}
|
||||
// A scope/auth/network failure cannot prove the cached session is gone.
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
async list(): Promise<CloudSessionRecord[]> {
|
||||
const organizationId = await this.resolveActiveOrganizationId();
|
||||
const listed = (await this.options.api.list(organizationId)).map(
|
||||
(session) => this.preserveConnectedRuntimeModel(session),
|
||||
);
|
||||
// Keep canonical rows available while their status checks run.
|
||||
this.lastListedSessions = listed;
|
||||
for (const session of listed) {
|
||||
this.knownSessions.set(session.id, session);
|
||||
}
|
||||
const scoped = await Promise.all(
|
||||
listed.map(async (session) => {
|
||||
if (session.status !== "provisioning") {
|
||||
return session;
|
||||
}
|
||||
const result = await this.options.api
|
||||
.status(session.id)
|
||||
.catch(() => undefined);
|
||||
const status = result?.status?.trim();
|
||||
if (!status) return session;
|
||||
return {
|
||||
...session,
|
||||
status,
|
||||
metadata: {
|
||||
...session.metadata,
|
||||
...(result?.statusReason?.trim()
|
||||
? { statusReason: result.statusReason.trim() }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
// Retain other scopes for routing; only lastListedSessions drives the sidebar.
|
||||
for (const session of scoped) {
|
||||
this.knownSessions.set(session.id, session);
|
||||
const live = this.ctx.liveSessions.get(session.id);
|
||||
if (
|
||||
live?.status === "provisioning" &&
|
||||
session.status !== "provisioning"
|
||||
) {
|
||||
live.status = session.status;
|
||||
}
|
||||
const connection = this.connections.get(session.id);
|
||||
if (connection) {
|
||||
connection.remote = session;
|
||||
}
|
||||
const expired = isExpiredRecord(session);
|
||||
if (expired || session.status === "failed") {
|
||||
if (live) {
|
||||
live.busy = false;
|
||||
live.status = expired ? "expired" : "failed";
|
||||
live.endedAt = expired
|
||||
? Date.parse(session.expiredAt ?? "") || Date.now()
|
||||
: Math.max(
|
||||
live.endedAt ?? 0,
|
||||
Date.parse(session.lastActivityAt ?? session.createdAt) || 0,
|
||||
) || undefined;
|
||||
}
|
||||
if (connection) {
|
||||
// Unavailable sandboxes must stop reconnecting.
|
||||
void this.disposeConnection(session.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.lastListedSessions = scoped;
|
||||
return scoped;
|
||||
}
|
||||
|
||||
private preserveConnectedRuntimeModel(
|
||||
session: CloudSessionRecord,
|
||||
): CloudSessionRecord {
|
||||
const runtimeModel = this.connections
|
||||
.get(session.id)
|
||||
?.remote.metadata.modelId?.trim();
|
||||
if (!runtimeModel || runtimeModel === session.metadata.modelId) {
|
||||
return session;
|
||||
}
|
||||
return {
|
||||
...session,
|
||||
metadata: { ...session.metadata, modelId: runtimeModel },
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveActiveOrganizationId(options?: {
|
||||
fresh?: boolean;
|
||||
}): Promise<string | undefined> {
|
||||
return await this.options.getActiveOrganizationId?.(options);
|
||||
}
|
||||
|
||||
async listRepositories(): Promise<CloudRepositoryListResult> {
|
||||
return await this.options.api.listRepositories(
|
||||
await this.resolveActiveOrganizationId(),
|
||||
);
|
||||
}
|
||||
|
||||
async listBranches(
|
||||
repositoryId: number,
|
||||
options: CloudBranchListOptions = {},
|
||||
): Promise<CloudBranchListResult> {
|
||||
return await this.options.api.listBranches(
|
||||
repositoryId,
|
||||
await this.resolveActiveOrganizationId(),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
async listForDiscovery(
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<JsonRecord[]> {
|
||||
const refresh =
|
||||
this.discoveryRefresh ??
|
||||
this.list().finally(() => {
|
||||
if (this.discoveryRefresh === refresh) {
|
||||
this.discoveryRefresh = undefined;
|
||||
}
|
||||
});
|
||||
this.discoveryRefresh = refresh;
|
||||
|
||||
let records = this.lastListedSessions;
|
||||
if (options.timeoutMs === undefined) {
|
||||
records = await refresh;
|
||||
} else {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const result = await Promise.race([
|
||||
refresh.then(
|
||||
(value) => ({ value }),
|
||||
(error) => {
|
||||
this.ctx.logger?.error?.("Cloud session discovery failed", {
|
||||
error,
|
||||
});
|
||||
return { value: this.lastListedSessions };
|
||||
},
|
||||
),
|
||||
new Promise<{ value: CloudSessionRecord[] }>((resolve) => {
|
||||
timeout = setTimeout(
|
||||
() => resolve({ value: this.lastListedSessions }),
|
||||
Math.max(0, options.timeoutMs ?? 0),
|
||||
);
|
||||
}),
|
||||
]);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
records = result.value;
|
||||
}
|
||||
|
||||
const listed = records.map((record) => {
|
||||
const projected = cloudSessionToDiscoveryRecord(record);
|
||||
const live = this.ctx.liveSessions.get(record.id);
|
||||
if (!live) {
|
||||
return projected;
|
||||
}
|
||||
const title = live.title?.trim() || record.title?.trim();
|
||||
return {
|
||||
...projected,
|
||||
status: live.status,
|
||||
prompt: live.prompt,
|
||||
endedAt:
|
||||
live.endedAt !== undefined
|
||||
? new Date(live.endedAt).toISOString()
|
||||
: projected.endedAt,
|
||||
metadata: {
|
||||
...((projected.metadata ?? {}) as JsonRecord),
|
||||
title: resolveSessionListTitle({
|
||||
sessionId: record.id,
|
||||
metadata: title ? { title } : undefined,
|
||||
prompt: live.prompt,
|
||||
messages: live.messages,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
return listed;
|
||||
}
|
||||
|
||||
async create(input: CreateCloudSessionInput): Promise<JsonRecord> {
|
||||
const key = input.requestId?.trim();
|
||||
if (!key) return await this.createOnce(input);
|
||||
const existing = this.createRequests.get(key);
|
||||
if (existing) return await existing;
|
||||
const creating = this.createOnce(input).finally(() => {
|
||||
if (this.createRequests.get(key) === creating) {
|
||||
this.createRequests.delete(key);
|
||||
}
|
||||
});
|
||||
this.createRequests.set(key, creating);
|
||||
return await creating;
|
||||
}
|
||||
|
||||
private async createOnce(
|
||||
input: CreateCloudSessionInput,
|
||||
): Promise<JsonRecord> {
|
||||
if (this.disposed) throw new Error("Cloud session manager was disposed");
|
||||
const organizationId =
|
||||
input.organizationId ??
|
||||
(await this.resolveActiveOrganizationId({ fresh: true }));
|
||||
const created = await this.options.api.create({ ...input, organizationId });
|
||||
if (!created?.sessionId?.trim()) {
|
||||
throw new CloudSessionError(
|
||||
"request_failed",
|
||||
"The cloud session service returned an unexpected response; please try again.",
|
||||
);
|
||||
}
|
||||
if (this.disposed) {
|
||||
await this.deleteProvisionedSessionAfterDispose(
|
||||
created.sessionId,
|
||||
created.cleanupAuthToken,
|
||||
);
|
||||
throw new Error(
|
||||
"Cline account changed while the cloud session was starting",
|
||||
);
|
||||
}
|
||||
const record: CloudSessionRecord = {
|
||||
id: created.sessionId,
|
||||
status: created.status,
|
||||
sandboxUrl: created.sandboxUrl,
|
||||
repoContext: {
|
||||
repoUrl: input.repoUrl,
|
||||
...(input.branch?.trim() ? { branch: input.branch.trim() } : {}),
|
||||
},
|
||||
metadata: { modelId: input.modelId },
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.knownSessions.set(record.id, record);
|
||||
const live = recordToLiveSession(record);
|
||||
live.prompt = input.initialPrompt?.trim() || undefined;
|
||||
// REST does not round-trip the client-side approval preference.
|
||||
if (typeof input.autoApproveTools === "boolean") {
|
||||
live.config.autoApproveTools = input.autoApproveTools;
|
||||
}
|
||||
if (typeof input.thinking === "boolean") {
|
||||
live.config.thinking = input.thinking;
|
||||
}
|
||||
if (input.reasoningEffort) {
|
||||
live.config.reasoningEffort = input.reasoningEffort;
|
||||
}
|
||||
this.ctx.liveSessions.set(record.id, live);
|
||||
sendEvent(this.ctx, "chat_session_status", {
|
||||
sessionId: record.id,
|
||||
status: live.status,
|
||||
});
|
||||
return {
|
||||
sessionId: record.id,
|
||||
origin: "cloud",
|
||||
executionTarget: "cloud",
|
||||
status: record.status,
|
||||
provider: "cline",
|
||||
model: input.modelId,
|
||||
repoUrl: input.repoUrl,
|
||||
branch: input.branch ?? "",
|
||||
cwd: CLOUD_WORKSPACE_ROOT,
|
||||
workspaceRoot: CLOUD_WORKSPACE_ROOT,
|
||||
...(live.prompt ? { prompt: live.prompt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async deleteProvisionedSessionAfterDispose(
|
||||
outerSessionId: string,
|
||||
authToken?: string,
|
||||
): Promise<void> {
|
||||
this.knownSessions.delete(outerSessionId);
|
||||
this.ctx.liveSessions.delete(outerSessionId);
|
||||
await this.options.api.delete(outerSessionId, authToken).catch((error) => {
|
||||
this.ctx.logger?.log(
|
||||
"Failed to clean up a cloud session created during an account change",
|
||||
{ sessionId: outerSessionId, error },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async disposeConnection(_outerSessionId: string): Promise<void> {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user