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:
John Choi
2026-09-16 12:05:14 -07:00
committed by GitHub
parent 964a0fc36d
commit d6d4566451
2 changed files with 911 additions and 6 deletions
@@ -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> {}
}