diff --git a/packages/agent/src/adapters/claude/session/model-config.test.ts b/packages/agent/src/adapters/claude/session/model-config.test.ts index 22fd0bcf69..5561454baa 100644 --- a/packages/agent/src/adapters/claude/session/model-config.test.ts +++ b/packages/agent/src/adapters/claude/session/model-config.test.ts @@ -26,8 +26,8 @@ describe("applyAvailableModelsAllowlist", () => { "claude-opus-4-8", ]).options, ).toEqual([ - { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, { value: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, ]); }); diff --git a/packages/agent/src/adapters/reasoning-effort.ts b/packages/agent/src/adapters/reasoning-effort.ts index 590bb3be86..2cc1da50f8 100644 --- a/packages/agent/src/adapters/reasoning-effort.ts +++ b/packages/agent/src/adapters/reasoning-effort.ts @@ -1,39 +1,6 @@ -import type { Adapter } from "@posthog/shared"; -import { getEffortOptions as getClaudeEffortOptions } from "./claude/session/models"; -import { getReasoningEffortOptions as getCodexReasoningEffortOptions } from "./codex-app-server/models"; - -export type SupportedReasoningEffort = - | "low" - | "medium" - | "high" - | "xhigh" - | "max"; - -export interface ReasoningEffortOption { - value: SupportedReasoningEffort; - name: string; -} - -export function getReasoningEffortOptions( - adapter: Adapter, - modelId: string, -): ReasoningEffortOption[] | null { - const options = - adapter === "codex" - ? getCodexReasoningEffortOptions(modelId) - : getClaudeEffortOptions(modelId); - - return options as ReasoningEffortOption[] | null; -} - -export function isSupportedReasoningEffort( - adapter: Adapter, - modelId: string, - value: string, -): value is SupportedReasoningEffort { - return ( - getReasoningEffortOptions(adapter, modelId)?.some( - (option) => option.value === value, - ) ?? false - ); -} +export { + getReasoningEffortOptions, + isSupportedReasoningEffort, + type ReasoningEffortOption, + type SupportedReasoningEffort, +} from "@posthog/shared"; diff --git a/packages/agent/src/gateway-models.test.ts b/packages/agent/src/gateway-models.test.ts index 22272975d4..8c988ec1e9 100644 --- a/packages/agent/src/gateway-models.test.ts +++ b/packages/agent/src/gateway-models.test.ts @@ -1,172 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { - compareModelsForPicker, - fetchGatewayModels, - fetchModelsList, - formatGatewayModelName, - type GatewayModel, - getClaudeModelRecency, - isAnthropicModel, - isBlockedModelId, - isCloudflareModel, - isModalModel, - isModalModelId, - pickAllowedModel, -} from "./gateway-models"; - -const model = (id: string, owned_by = ""): GatewayModel => ({ - id, - owned_by, - context_window: 128000, - supports_streaming: true, - supports_vision: false, - allowed: true, -}); - -describe("formatGatewayModelName", () => { - it("keeps Claude models in friendly title case", () => { - expect( - formatGatewayModelName({ - id: "claude-opus-4-8", - owned_by: "anthropic", - context_window: 200000, - supports_streaming: true, - supports_vision: true, - allowed: true, - }), - ).toBe("Claude Opus 4.8"); - }); - - it("uppercases the GPT acronym in OpenAI model ids", () => { - expect( - formatGatewayModelName({ - id: "GPT-5.5", - owned_by: "openai", - context_window: 200000, - supports_streaming: true, - supports_vision: true, - allowed: true, - }), - ).toBe("GPT-5.5"); - }); - - it("strips the openai/ prefix, uppercases GPT, and title-cases the suffix", () => { - expect( - formatGatewayModelName({ - id: "openai/gpt-5.6-sol", - owned_by: "openai", - context_window: 200000, - supports_streaming: true, - supports_vision: true, - allowed: true, - }), - ).toBe("GPT-5.6 Sol"); - }); - - it("formats Cloudflare models as the final path segment with GLM uppercased", () => { - expect( - formatGatewayModelName({ - id: "@cf/zai-org/glm-5.2", - owned_by: "cloudflare", - context_window: 128000, - supports_streaming: true, - supports_vision: false, - allowed: true, - }), - ).toBe("GLM-5.2"); - }); - - it("leaves non-acronym Cloudflare models lowercase", () => { - expect( - formatGatewayModelName({ - id: "@cf/meta/llama-3.1-8b-instruct", - owned_by: "cloudflare", - context_window: 128000, - supports_streaming: true, - supports_vision: false, - allowed: true, - }), - ).toBe("llama-3.1-8b-instruct"); - }); - - it("formats Kimi K3 for the model picker", () => { - const kimi = model("moonshotai/kimi-k3", "modal"); - expect(formatGatewayModelName(kimi)).toBe("Kimi K3"); - expect(isModalModel(kimi)).toBe(true); - expect(isModalModelId(kimi.id)).toBe(true); - }); - - it("blocks deprecated Claude gateway models", () => { - expect(isBlockedModelId("claude-opus-4-5")).toBe(true); - expect(isBlockedModelId("claude-opus-4-6")).toBe(true); - expect(isBlockedModelId("claude-sonnet-4-5")).toBe(true); - expect(isBlockedModelId("claude-haiku-4-5")).toBe(true); - expect(isBlockedModelId("ANTHROPIC/CLAUDE-HAIKU-4-5")).toBe(true); - }); - - it("blocks deprecated Codex gateway models", () => { - expect(isBlockedModelId("gpt-5.2")).toBe(true); - expect(isBlockedModelId("gpt-5.3")).toBe(true); - expect(isBlockedModelId("gpt-5.3-codex")).toBe(true); - expect(isBlockedModelId("openai/gpt-5.2")).toBe(true); - expect(isBlockedModelId("OPENAI/GPT-5.3")).toBe(true); - expect(isBlockedModelId("OPENAI/GPT-5.3-CODEX")).toBe(true); - }); -}); - -describe("getClaudeModelRecency", () => { - it.each([ - ["claude-haiku-4-5", 4005], - ["claude-sonnet-4-6", 4006], - ["claude-opus-4-7", 4007], - ["claude-opus-4-8", 4008], - ["claude-opus-5", 5000], - ["claude-sonnet-5", 5000], - ["claude-fable-5", 5000], - ])("ranks %s by its embedded version (%i)", (modelId, rank) => { - expect(getClaudeModelRecency(modelId)).toBe(rank); - }); - - it("ignores a trailing date suffix when reading the version", () => { - expect(getClaudeModelRecency("claude-haiku-4-5-20251001")).toBe(4005); - }); - - it("ranks a model with no recognisable version as newest", () => { - expect(getClaudeModelRecency("claude-mystery")).toBe( - Number.MAX_SAFE_INTEGER, - ); - expect(getClaudeModelRecency("claude-mystery")).toBeGreaterThan( - getClaudeModelRecency("claude-fable-5"), - ); - }); -}); - -describe("compareModelsForPicker", () => { - it("groups models by family least capable first, newest version first", () => { - // The picker opens upward, so least-capable-first DOM order puts the most - // capable family (Fable) nearest the trigger — the visual top of the menu. - // Models as the gateway might return them — arbitrary order. - const gatewayOrder = [ - "claude-fable-5", - "claude-opus-4-7", - "claude-mystery", - "claude-sonnet-5", - "claude-haiku-4-5", - "claude-sonnet-4-6", - "claude-opus-4-8", - ]; - const displayed = [...gatewayOrder].sort(compareModelsForPicker); - expect(displayed).toEqual([ - "claude-haiku-4-5", - "claude-sonnet-5", - "claude-sonnet-4-6", - "claude-opus-4-8", - "claude-opus-4-7", - "claude-fable-5", - "claude-mystery", - ]); - }); -}); +import { fetchGatewayModels, fetchModelsList } from "./gateway-models"; describe("gateway model fetch timeout", () => { afterEach(() => { @@ -251,96 +84,4 @@ describe("gateway models cache", () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(cached[0]?.allowed).toBe(false); }); - - it("corrects stale GLM 5.2 context-window metadata", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - JSON.stringify({ - object: "list", - data: [ - { - id: "@cf/zai-org/glm-5.2", - owned_by: "cloudflare", - context_window: 128_000, - supports_streaming: true, - supports_vision: false, - }, - ], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - ); - - const models = await fetchGatewayModels({ - gatewayUrl: "https://gateway.glm-context-test", - }); - - expect(models[0]?.context_window).toBe(1_000_000); - }); -}); - -describe("isCloudflareModel", () => { - it.each([ - { id: "@cf/zai-org/glm-5.2", owned_by: "cloudflare", expected: true }, - { id: "claude-opus-4-8", owned_by: "anthropic", expected: false }, - { id: "@cf/zai-org/glm-5.2", owned_by: "", expected: true }, - { id: "gpt-5.5", owned_by: "", expected: false }, - // A Cloudflare-served model can report an upstream owner; the `@cf/` prefix still wins. - { id: "@cf/openai/gpt-oss", owned_by: "openai", expected: true }, - ])( - "isCloudflareModel($id, owned_by=$owned_by) → $expected", - ({ id, owned_by, expected }) => { - expect(isCloudflareModel(model(id, owned_by))).toBe(expected); - }, - ); - - it("does not classify Cloudflare models as Anthropic", () => { - // The Claude adapter accepts both, but they must stay distinguishable. - const glm = model("@cf/zai-org/glm-5.2", "cloudflare"); - expect(isCloudflareModel(glm)).toBe(true); - expect(isAnthropicModel(glm)).toBe(false); - }); -}); - -describe("pickAllowedModel", () => { - const entry = (id: string, allowed: boolean) => ({ id, allowed }); - - it.each([ - [ - "keeps an allowed preferred model", - [entry("claude-opus-4-8", true)], - "claude-opus-4-8", - "claude-opus-4-8", - ], - [ - "keeps a preferred model absent from the list", - [entry("claude-opus-4-8", true)], - "claude-sonnet-5", - "claude-sonnet-5", - ], - [ - "moves a restricted preferred model to the newest allowed one", - [ - entry("claude-opus-4-8", false), - entry("claude-sonnet-4-6", true), - entry("@cf/zai-org/glm-5.2", true), - ], - "claude-opus-4-8", - "@cf/zai-org/glm-5.2", - ], - [ - "keeps the preferred model when everything is restricted", - [entry("claude-opus-4-8", false)], - "claude-opus-4-8", - "claude-opus-4-8", - ], - [ - "keeps the preferred model when the list is empty", - [], - "claude-opus-4-8", - "claude-opus-4-8", - ], - ] as const)("%s", (_name, models, preferred, expected) => { - expect(pickAllowedModel(models, preferred)).toBe(expected); - }); }); diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts index 940c28a4d5..af0b60267f 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -1,20 +1,30 @@ -export interface GatewayModel { - id: string; - owned_by: string; - context_window: number; - supports_streaming: boolean; - supports_vision: boolean; - // Free-tier model gate: authenticated fetches mark models outside the - // caller's plan `allowed: false`. Anonymous fetches and older gateways - // don't mark, so absence means allowed. - allowed: boolean; - restriction_reason?: string | null; -} - -interface GatewayModelsResponse { - object: "list"; - data: Array & { allowed?: boolean }>; -} +import { + type GatewayModel, + normalizeGatewayModelsResponse, +} from "@posthog/shared"; + +export { + BLOCKED_GATEWAY_MODEL_IDS, + buildCloudTaskConfigOptions, + type CloudTaskConfigOption, + type CloudTaskConfigSelectOption, + compareModelsForPicker, + DEFAULT_CODEX_MODEL, + DEFAULT_GATEWAY_MODEL, + formatGatewayModelName, + formatModelId, + type GatewayModel, + getClaudeModelRecency, + getProviderName, + isAnthropicModel, + isBlockedModelId, + isCloudflareModel, + isCloudflareModelId, + isModalModel, + isModalModelId, + isOpenAIModel, + pickAllowedModel, +} from "@posthog/shared"; export interface FetchGatewayModelsOptions { gatewayUrl: string; @@ -22,47 +32,6 @@ export interface FetchGatewayModelsOptions { authToken?: string; } -export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-8"; - -export const DEFAULT_CODEX_MODEL = "gpt-5.5"; - -const BLOCKED_MODELS = new Set([ - "gpt-5-mini", - "openai/gpt-5-mini", - "gpt-5.2", - "openai/gpt-5.2", - "gpt-5.3", - "openai/gpt-5.3", - "gpt-5.3-codex", - "openai/gpt-5.3-codex", - "claude-opus-4-5", - "anthropic/claude-opus-4-5", - "claude-opus-4-6", - "anthropic/claude-opus-4-6", - "claude-sonnet-4-5", - "anthropic/claude-sonnet-4-5", - "claude-haiku-4-5", - "anthropic/claude-haiku-4-5", -]); - -export function isBlockedModelId(modelId: string): boolean { - return BLOCKED_MODELS.has(modelId.toLowerCase()); -} - -interface ModelsListEntry { - id?: string; - owned_by?: string; - allowed?: boolean; - restriction_reason?: string | null; -} - -type ModelsListResponse = - | { - data?: ModelsListEntry[]; - models?: ModelsListEntry[]; - } - | ModelsListEntry[]; - const CACHE_TTL = 10 * 60 * 1000; // 10 minutes // Bound the gateway /v1/models request so a stalled connection cannot hold up @@ -71,10 +40,6 @@ const CACHE_TTL = 10 * 60 * 1000; // 10 minutes // the callers fall through to `return []`. const GATEWAY_FETCH_TIMEOUT_MS = 10_000; -const MODEL_CONTEXT_WINDOW_OVERRIDES: Readonly> = { - "@cf/zai-org/glm-5.2": 1_000_000, -}; - // Restriction marks are identity-scoped (free-tier marks are authed-only and // differ per org), so cache entries are keyed on the exact token — an org // switch in the same process must never be served the old org's marks. A @@ -125,17 +90,7 @@ export async function fetchGatewayModels( return []; } - const data = (await response.json()) as GatewayModelsResponse; - const models = (data.data ?? []) - .filter((m) => !isBlockedModelId(m.id)) - .map((m) => ({ - ...m, - context_window: Math.max( - m.context_window, - MODEL_CONTEXT_WINDOW_OVERRIDES[m.id] ?? 0, - ), - allowed: m.allowed !== false, - })); + const models = normalizeGatewayModelsResponse(await response.json()); gatewayModelsCache = { models, expiry: Date.now() + CACHE_TTL, @@ -148,44 +103,6 @@ export async function fetchGatewayModels( } } -export function isAnthropicModel(model: GatewayModel): boolean { - if (model.owned_by) { - return model.owned_by === "anthropic"; - } - return model.id.startsWith("claude-") || model.id.startsWith("anthropic/"); -} - -export function isOpenAIModel(model: GatewayModel): boolean { - if (model.owned_by) { - return model.owned_by === "openai"; - } - return model.id.startsWith("gpt-") || model.id.startsWith("openai/"); -} - -// Cloudflare Workers AI model ids carry the `@cf/` path prefix (e.g. `@cf/zai-org/glm-5.2`). Kept as -// a standalone id-only check so callers that only have a model id (not a full GatewayModel) — like the -// Claude adapter's desync guard — share one source of truth with `isCloudflareModel`. -export function isCloudflareModelId(modelId: string): boolean { - return modelId.startsWith("@cf/"); -} - -// Cloudflare Workers AI models (e.g. `@cf/zai-org/glm-5.2`). The gateway serves these over both its -// OpenAI and Anthropic-Messages surfaces (it translates the `@cf/` path), so the Claude adapter can -// drive them just like an Anthropic model. The `@cf/` path prefix is the structural, always-present -// signal, so honour it regardless of `owned_by` — a Cloudflare-served model can report an upstream -// owner (e.g. `@cf/openai/...` with `owned_by: "openai"`) and must still classify as Cloudflare. -export function isCloudflareModel(model: GatewayModel): boolean { - return isCloudflareModelId(model.id) || model.owned_by === "cloudflare"; -} - -export function isModalModel(model: GatewayModel): boolean { - return isModalModelId(model.id) || model.owned_by === "modal"; -} - -export function isModalModelId(modelId: string): boolean { - return modelId === "moonshotai/kimi-k3"; -} - export interface ModelInfo { id: string; owned_by?: string; @@ -216,22 +133,14 @@ export async function fetchModelsList( if (!response.ok) { return []; } - const data = (await response.json()) as ModelsListResponse; - const models = Array.isArray(data) - ? data - : (data.data ?? data.models ?? []); - const results: ModelInfo[] = []; - for (const model of models) { - const id = model?.id ? String(model.id) : ""; - if (!id) continue; - if (isBlockedModelId(id)) continue; - results.push({ - id, - owned_by: model?.owned_by, - allowed: model?.allowed !== false, - restriction_reason: model?.restriction_reason ?? null, - }); - } + const results = normalizeGatewayModelsResponse(await response.json()).map( + (model) => ({ + id: model.id, + owned_by: model.owned_by || undefined, + allowed: model.allowed, + restriction_reason: model.restriction_reason, + }), + ); modelsListCache = { models: results, expiry: Date.now() + CACHE_TTL, @@ -243,128 +152,3 @@ export async function fetchModelsList( return []; } } - -/** - * The model a session should start on: the preferred id when present and - * allowed, else the newest allowed model — a free-tier org must not default - * onto a model that 403s its first message. Falls back to the preferred id - * when the list is empty (fetch failed) or nothing is allowed (all locked — - * the picker gate communicates that state better than a silent swap). - */ -export function pickAllowedModel( - models: ReadonlyArray>, - preferred: string, -): string { - if (models.length === 0) return preferred; - const preferredEntry = models.find((m) => m.id === preferred); - if (!preferredEntry || preferredEntry.allowed) return preferred; - const allowed = models.filter((m) => m.allowed); - if (allowed.length === 0) return preferred; - return allowed.reduce((best, candidate) => - getClaudeModelRecency(candidate.id) >= getClaudeModelRecency(best.id) - ? candidate - : best, - ).id; -} - -const PROVIDER_NAMES: Record = { - anthropic: "Anthropic", - openai: "OpenAI", - "google-vertex": "Gemini", -}; - -export function getProviderName(ownedBy: string): string { - return PROVIDER_NAMES[ownedBy] ?? ownedBy; -} - -// Version embedded in the model id, e.g. "claude-opus-4-8" -> 4008. Ids with no -// recognisable version rank newest. A trailing date suffix is ignored. -export function getClaudeModelRecency(modelId: string): number { - const match = modelId.toLowerCase().match(/-(\d+)(?:[-.](\d+))?/); - if (!match) return Number.MAX_SAFE_INTEGER; - const major = Number(match[1]); - const minor = match[2] ? Number(match[2]) : 0; - return major * 1000 + minor; -} - -// Families ordered least-capable first. The picker opens upward (side="top") -// from the composer, so items later in this list render nearer the trigger and -// read as the top of the menu — this puts the most-capable family (Fable) on -// top. Unknown families sort after all known ones. -const MODEL_FAMILY_ORDER = ["haiku", "sonnet", "opus", "fable"]; - -function getModelFamilyRank(modelId: string): number { - const id = modelId.toLowerCase(); - const index = MODEL_FAMILY_ORDER.findIndex((family) => id.includes(family)); - return index === -1 ? MODEL_FAMILY_ORDER.length : index; -} - -// Group by family, then newest version first within each family. -export function compareModelsForPicker(a: string, b: string): number { - const familyDiff = getModelFamilyRank(a) - getModelFamilyRank(b); - if (familyDiff !== 0) return familyDiff; - return getClaudeModelRecency(b) - getClaudeModelRecency(a); -} - -const PROVIDER_PREFIXES = ["anthropic/", "openai/", "google-vertex/"]; - -const KNOWN_ACRONYMS = new Set(["gpt", "glm"]); - -// For a known acronym, uppercase it, keep the version attached, and title-case -// any suffix: "gpt-5.6-sol" -> "GPT-5.6 Sol", "glm-5.2" -> "GLM-5.2". Other ids -// stay lowercase to avoid mangling ordinary names (e.g. "llama-3.1-8b"). -function formatProviderModelName(modelId: string): string { - const [acronym, version, ...suffix] = modelId.split("-"); - if (!KNOWN_ACRONYMS.has(acronym.toLowerCase())) return modelId.toLowerCase(); - const head = version - ? `${acronym.toUpperCase()}-${version}` - : acronym.toUpperCase(); - const tail = suffix.map( - (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), - ); - return [head, ...tail].join(" "); -} - -export function formatGatewayModelName(model: GatewayModel): string { - if (isCloudflareModel(model)) { - return formatProviderModelName(model.id.split("/").pop() ?? model.id); - } - - if (isModalModel(model)) { - return formatModelId(model.id.split("/").pop() ?? model.id); - } - - if (isOpenAIModel(model)) { - return formatProviderModelName(stripProviderPrefix(model.id)); - } - - return formatModelId(model.id); -} - -function stripProviderPrefix(modelId: string): string { - for (const prefix of PROVIDER_PREFIXES) { - if (modelId.startsWith(prefix)) { - return modelId.slice(prefix.length); - } - } - return modelId; -} - -export function formatModelId(modelId: string): string { - let cleanId = modelId; - for (const prefix of PROVIDER_PREFIXES) { - if (cleanId.startsWith(prefix)) { - cleanId = cleanId.slice(prefix.length); - break; - } - } - - cleanId = cleanId.replace(/(\d)-(\d)/g, "$1.$2"); - - const words = cleanId.split(/[-_]/).map((word) => { - if (word.match(/^[0-9.]+$/)) return word; - return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); - }); - - return words.join(" "); -} diff --git a/packages/agent/src/utils/gateway.ts b/packages/agent/src/utils/gateway.ts index c9ef17df70..5ba218a4f0 100644 --- a/packages/agent/src/utils/gateway.ts +++ b/packages/agent/src/utils/gateway.ts @@ -1,3 +1,5 @@ +import { getCloudTaskGatewayUrl } from "@posthog/shared"; + export type GatewayProduct = | "posthog_code" | "background_agents" @@ -41,26 +43,7 @@ export { } from "@posthog/shared/posthog-property-headers"; function getGatewayBaseUrl(posthogHost: string): string { - const url = new URL(posthogHost); - const hostname = url.hostname; - - if (hostname === "localhost" || hostname === "127.0.0.1") { - return `${url.protocol}//localhost:3308`; - } - - if (hostname === "host.docker.internal") { - return `${url.protocol}//host.docker.internal:3308`; - } - - // The hosted dev environment runs its own LLM gateway with its own auth DB, - // so a dev-minted `pha_` token can't be routed to the US gateway — that's - // a different DB and returns 401 Authentication required. - if (hostname === "app.dev.posthog.dev") { - return "https://gateway.dev.posthog.dev"; - } - - const region = hostname.match(/^(us|eu)\.posthog\.com$/)?.[1] ?? "us"; - return `https://gateway.${region}.posthog.com`; + return getCloudTaskGatewayUrl(posthogHost).replace(/\/posthog_code$/, ""); } export function getLlmGatewayUrl( diff --git a/packages/api-client/package.json b/packages/api-client/package.json index bee0ebc547..3a592020a4 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -25,7 +25,6 @@ "src/**/*" ], "dependencies": { - "@posthog/agent": "workspace:*", "@posthog/shared": "workspace:*" } } diff --git a/packages/api-client/src/fetcher.test.ts b/packages/api-client/src/fetcher.test.ts index c205949418..6e1e17a351 100644 --- a/packages/api-client/src/fetcher.test.ts +++ b/packages/api-client/src/fetcher.test.ts @@ -53,6 +53,45 @@ describe("buildApiFetcher", () => { expect(mockFetch.mock.calls[0][1].headers.get("Authorization")).toBe( "Bearer my-token", ); + expect(mockFetch.mock.calls[0][1].headers.get("User-Agent")).toBe( + "posthog/desktop.hog.dev; version: test", + ); + }); + + it("uses an injected fetch implementation and custom user agent", async () => { + const injectedFetch = vi.fn().mockResolvedValueOnce(ok()); + const fetcher = buildApiFetcher({ + getAccessToken: vi.fn().mockResolvedValue("token"), + refreshAccessToken: vi.fn().mockResolvedValue("new-token"), + appVersion: "1.2.3", + fetch: injectedFetch, + userAgent: "posthog/mobile; version: 1.2.3", + }); + + await fetcher.fetch(mockInput); + + expect(injectedFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + expect(injectedFetch.mock.calls[0][1].headers.get("User-Agent")).toBe( + "posthog/mobile; version: 1.2.3", + ); + }); + + it("omits the user agent when explicitly disabled", async () => { + const injectedFetch = vi.fn().mockResolvedValueOnce(ok()); + const fetcher = buildApiFetcher({ + getAccessToken: vi.fn().mockResolvedValue("token"), + refreshAccessToken: vi.fn().mockResolvedValue("new-token"), + appVersion: "1.2.3", + fetch: injectedFetch, + userAgent: null, + }); + + await fetcher.fetch(mockInput); + + expect(injectedFetch.mock.calls[0][1].headers.has("User-Agent")).toBe( + false, + ); }); it("retries once with a freshly fetched token on 401", async () => { diff --git a/packages/api-client/src/fetcher.ts b/packages/api-client/src/fetcher.ts index 6bf59aa9f8..bc061a3030 100644 --- a/packages/api-client/src/fetcher.ts +++ b/packages/api-client/src/fetcher.ts @@ -1,9 +1,16 @@ import type { createApiClient } from "./generated"; +export type FetchImplementation = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + export type ApiFetcherConfig = { getAccessToken: () => Promise; refreshAccessToken: () => Promise; appVersion: string; + fetch?: FetchImplementation; + userAgent?: string | null; }; /** @@ -13,11 +20,13 @@ export type ApiFetcherConfig = { */ export class ApiRequestError extends Error { readonly status: number; + readonly body: unknown; - constructor(status: number, serializedBody: string) { + constructor(status: number, serializedBody: string, body?: unknown) { super(`Failed request: [${status}] ${serializedBody}`); this.name = "ApiRequestError"; this.status = status; + this.body = body; } } @@ -29,7 +38,11 @@ export function requestErrorStatus(error: unknown): number | undefined { export const buildApiFetcher: ( config: ApiFetcherConfig, ) => Parameters[0] = (config) => { - const userAgent = `posthog/desktop.hog.dev; version: ${config.appVersion}`; + const fetchImpl = config.fetch ?? globalThis.fetch; + const userAgent = + config.userAgent === undefined + ? `posthog/desktop.hog.dev; version: ${config.appVersion}` + : config.userAgent; const makeRequest = async ( input: Parameters[0]["fetch"]>[0], @@ -38,7 +51,9 @@ export const buildApiFetcher: ( const headers = new Headers(); headers.set("Authorization", `Bearer ${token}`); headers.set("Content-Type", "application/json"); - headers.set("User-Agent", userAgent); + if (userAgent) { + headers.set("User-Agent", userAgent); + } if (input.urlSearchParams) { input.url.search = input.urlSearchParams.toString(); @@ -59,7 +74,7 @@ export const buildApiFetcher: ( } try { - const response = await fetch(input.url, { + const response = await fetchImpl(input.url, { method: input.method.toUpperCase(), ...(body && { body }), headers, @@ -114,6 +129,7 @@ export const buildApiFetcher: ( throw new ApiRequestError( response.status, JSON.stringify(errorResponse), + errorResponse, ); } } @@ -128,6 +144,7 @@ export const buildApiFetcher: ( throw new ApiRequestError( response.status, JSON.stringify(errorResponse), + errorResponse, ); } diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index e6b7c3c639..a18d6ff4b0 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,6 +1,10 @@ import "./generated.augment"; -export { type ApiFetcherConfig, buildApiFetcher } from "./fetcher"; +export { + type ApiFetcherConfig, + buildApiFetcher, + type FetchImplementation, +} from "./fetcher"; export { createApiClient, type Schemas } from "./generated"; export { createLoop, diff --git a/packages/api-client/src/posthog-client.automations.test.ts b/packages/api-client/src/posthog-client.automations.test.ts new file mode 100644 index 0000000000..3d3386ef3a --- /dev/null +++ b/packages/api-client/src/posthog-client.automations.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + PostHogAPIClient, + TaskAutomationValidationError, +} from "./posthog-client"; + +const automationPayload = { + id: "automation-1", + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + template_id: "llm-skill:daily-prs", + enabled: true, + last_run_at: null, + last_run_status: null, + last_task_id: null, + last_task_run_id: null, + last_error: null, + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:00:00Z", +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("PostHogAPIClient task automations", () => { + const fetch = vi.fn(); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "access-token", + async () => "refreshed-token", + 42, + { appVersion: "test", fetch }, + ); + + beforeEach(() => { + fetch.mockReset(); + }); + + it("lists automations and normalizes optional response fields", async () => { + const minimalPayload = { + ...automationPayload, + github_integration: undefined, + timezone: undefined, + template_id: undefined, + enabled: undefined, + }; + fetch.mockResolvedValueOnce( + jsonResponse({ + count: 1, + next: null, + previous: null, + results: [minimalPayload], + }), + ); + + await expect(client.listTaskAutomations()).resolves.toEqual([ + expect.objectContaining({ + id: "automation-1", + github_integration: null, + timezone: null, + template_id: null, + enabled: true, + }), + ]); + expect(fetch).toHaveBeenCalledWith( + new URL( + "https://app.posthog.test/api/projects/42/task_automations/?limit=500", + ), + expect.objectContaining({ method: "GET" }), + ); + }); + + it("gets and creates automations through generated endpoints", async () => { + fetch + .mockResolvedValueOnce(jsonResponse(automationPayload)) + .mockResolvedValueOnce(jsonResponse(automationPayload, 201)); + + await expect(client.getTaskAutomation("automation-1")).resolves.toEqual( + automationPayload, + ); + await expect( + client.createTaskAutomation({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + template_id: "llm-skill:daily-prs", + enabled: true, + }), + ).resolves.toEqual(automationPayload); + + expect(fetch).toHaveBeenNthCalledWith( + 2, + new URL("https://app.posthog.test/api/projects/42/task_automations/"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + template_id: "llm-skill:daily-prs", + enabled: true, + }), + }), + ); + }); + + it("updates, deletes, and runs automations", async () => { + fetch + .mockResolvedValueOnce( + jsonResponse({ ...automationPayload, enabled: false }), + ) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(jsonResponse(automationPayload)); + + await expect( + client.updateTaskAutomation("automation-1", { enabled: false }), + ).resolves.toMatchObject({ enabled: false }); + await expect( + client.deleteTaskAutomation("automation-1"), + ).resolves.toBeUndefined(); + await expect(client.runTaskAutomation("automation-1")).resolves.toEqual( + automationPayload, + ); + + expect(fetch).toHaveBeenNthCalledWith( + 1, + new URL( + "https://app.posthog.test/api/projects/42/task_automations/automation-1/", + ), + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ enabled: false }), + }), + ); + expect(fetch).toHaveBeenNthCalledWith( + 3, + new URL( + "https://app.posthog.test/api/projects/42/task_automations/automation-1/run/", + ), + expect.objectContaining({ method: "POST" }), + ); + expect(fetch.mock.calls[2]?.[1]?.body).toBeUndefined(); + }); + + it("preserves validation detail, code, and field attribution", async () => { + fetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + type: "validation_error", + code: "invalid_input", + detail: "Enter a valid cron expression.", + attr: "cron_expression", + }), + { + status: 400, + statusText: "Bad Request", + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + const request = client.createTaskAutomation({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + cron_expression: "not a cron", + timezone: "Europe/London", + }); + + await expect(request).rejects.toBeInstanceOf(TaskAutomationValidationError); + await expect(request).rejects.toMatchObject({ + status: 400, + code: "invalid_input", + attr: "cron_expression", + message: "Enter a valid cron expression.", + }); + }); +}); diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index 42a36681cb..da93bc7960 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -1,8 +1,337 @@ import { describe, expect, it, vi } from "vitest"; import { ApiRequestError } from "./fetcher"; -import { PostHogAPIClient } from "./posthog-client"; +import { CloudCommandError, PostHogAPIClient } from "./posthog-client"; describe("PostHogAPIClient", () => { + it.each([ + "user_message", + "permission_response", + "set_config_option", + "cancel", + ] as const)("sends the %s cloud run command", async (method) => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ result: { accepted: true } }), { + status: 200, + }), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect( + client.sendCloudRunCommand("task-1", "run-1", method, { + value: "payload", + }), + ).resolves.toEqual({ accepted: true }); + + expect(fetch).toHaveBeenCalledWith( + new URL( + "https://app.posthog.test/api/projects/42/tasks/task-1/runs/run-1/command/", + ), + expect.objectContaining({ + method: "POST", + body: expect.any(String), + }), + ); + const request = fetch.mock.calls[0][1] as RequestInit; + expect(JSON.parse(request.body as string)).toMatchObject({ + jsonrpc: "2.0", + method, + params: { value: "payload" }, + }); + }); + + it("throws structured cloud command errors for HTTP failures", async () => { + const fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ error: "No active sandbox for this run" }), + { + status: 409, + statusText: "Conflict", + }, + ), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + const error = await client + .sendCloudRunCommand("task-1", "run-1", "user_message") + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + name: "CloudCommandError", + method: "user_message", + status: 409, + backendError: "No active sandbox for this run", + }); + expect(error).toBeInstanceOf(CloudCommandError); + expect((error as CloudCommandError).isSandboxInactive()).toBe(true); + }); + + it("throws structured cloud command errors for JSON-RPC failures", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ error: { message: "Permission request expired" } }), + { status: 200 }, + ), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect( + client.sendCloudRunCommand("task-1", "run-1", "permission_response"), + ).rejects.toMatchObject({ + method: "permission_response", + status: 200, + backendError: "Permission request expired", + }); + }); + + it("preserves the legacy sendRunCommand result contract", async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "Run is unavailable" }), { + status: 503, + }), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect( + client.sendRunCommand("task-1", "run-1", "set_config_option"), + ).resolves.toEqual({ + success: false, + error: "Cloud command 'set_config_option' failed: 503 Run is unavailable", + }); + }); + + it("cancels a cloud task run with an optional reason", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ status: "cancelled" }), { status: 200 }), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect( + client.cancelTaskRun("task-1", "run-1", "user requested"), + ).resolves.toEqual({ status: "cancelled" }); + + expect(fetch).toHaveBeenCalledWith( + new URL( + "https://app.posthog.test/api/projects/42/tasks/task-1/runs/run-1/cancel/", + ), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ reason: "user requested" }), + }), + ); + }); + + it("cancels a cloud task run with an empty body by default", async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect(client.cancelTaskRun("task-1", "run-1")).resolves.toEqual({}); + + const request = fetch.mock.calls[0][1] as RequestInit; + expect(request.body).toBe(JSON.stringify({})); + }); + + it("builds cloud task config from the authenticated gateway catalog", async () => { + const fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: [ + { + id: "claude-opus-4-8", + owned_by: "anthropic", + context_window: 200000, + supports_streaming: true, + supports_vision: true, + allowed: true, + }, + { + id: "claude-fable-5", + owned_by: "anthropic", + context_window: 200000, + supports_streaming: true, + supports_vision: true, + allowed: false, + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + const client = new PostHogAPIClient( + "https://eu.posthog.com", + async () => "token", + async () => "token", + 123, + { fetch }, + ); + + const options = await client.getCloudTaskConfigOptions("claude"); + + expect(fetch).toHaveBeenCalledWith( + new URL("https://gateway.eu.posthog.com/posthog_code/v1/models"), + expect.objectContaining({ method: "GET" }), + ); + expect(options.find((option) => option.category === "model")).toMatchObject( + { + currentValue: "claude-opus-4-8", + options: [ + expect.objectContaining({ value: "claude-opus-4-8" }), + expect.objectContaining({ + value: "claude-fable-5", + _meta: expect.any(Object), + }), + ], + }, + ); + }); + + it("uses the configured fetch implementation for task log URLs", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response( + '{"type":"notification","timestamp":"2026-07-21T00:00:00Z"}\n', + { status: 200 }, + ), + ); + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + { fetch }, + ); + vi.spyOn(client, "getTask").mockResolvedValue({ + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Task", + description: "Task", + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:00:00Z", + origin_product: "user_created", + latest_run: { + id: "run-1", + task: "task-1", + team: 123, + branch: null, + status: "in_progress", + log_url: "https://logs.posthog.test/run-1.jsonl", + error_message: null, + output: null, + state: {}, + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:00:00Z", + completed_at: null, + }, + }); + + await expect(client.getTaskLogs("task-1")).resolves.toHaveLength(1); + expect(fetch).toHaveBeenCalledWith("https://logs.posthog.test/run-1.jsonl"); + }); + + it.each([ + { + label: "desktop default", + options: undefined, + expectedConnectFrom: "posthog_code", + expectedUserAgent: "posthog/desktop.hog.dev; version: unknown", + }, + { + label: "mobile configuration", + options: { + appVersion: "1.2.3", + userAgent: "posthog/mobile; version: 1.2.3", + githubConnectFrom: "posthog_mobile", + }, + expectedConnectFrom: "posthog_mobile", + expectedUserAgent: "posthog/mobile; version: 1.2.3", + }, + ])( + "uses $label identity for GitHub connections", + async ({ options, expectedConnectFrom, expectedUserAgent }) => { + const fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ install_url: "https://github.com/login/oauth" }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + { ...options, fetch }, + ); + + await expect(client.startGithubUserIntegrationConnect()).resolves.toEqual( + { + install_url: "https://github.com/login/oauth", + }, + ); + + expect(fetch).toHaveBeenCalledWith( + new URL( + "http://localhost:8000/api/users/@me/integrations/github/start/", + ), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + team_id: 123, + connect_from: expectedConnectFrom, + }), + }), + ); + expect(fetch.mock.calls[0][1].headers.get("User-Agent")).toBe( + expectedUserAgent, + ); + }, + ); + it("sends supported reasoning effort for cloud Codex runs", async () => { const client = new PostHogAPIClient( "http://localhost:8000", @@ -257,7 +586,13 @@ describe("PostHogAPIClient", () => { reasoningLevel: "high", initialPermissionMode: "auto", }), - ).resolves.toEqual({ id: "run-123", environment: "cloud" }); + ).resolves.toMatchObject({ + id: "run-123", + task: "task-123", + team: 123, + environment: "cloud", + status: "not_started", + }); expect(fetch).toHaveBeenCalledWith( expect.objectContaining({ @@ -435,7 +770,15 @@ describe("PostHogAPIClient", () => { pendingUserMessage: "Read the attached file first", pendingUserArtifactIds: ["artifact-1"], }), - ).resolves.toEqual({ id: "task-123", latest_run: { id: "run-123" } }); + ).resolves.toMatchObject({ + id: "task-123", + latest_run: { + id: "run-123", + task: "task-123", + team: 123, + status: "not_started", + }, + }); expect(fetch).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 00bbbc7700..b2ef9fa8a8 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -1,21 +1,33 @@ import "./generated.augment"; -import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort"; import type { Adapter, CloudMcpServerImport, CloudMcpServerRelayDesignation, CloudRunSource, + CreateTaskAutomationOptions, ExecutionMode, PrAuthorshipMode, SourceProduct, SourceType, StoredLogEntry, + TaskAutomation, TaskRunArtifactMetadata, + UpdateTaskAutomationOptions, } from "@posthog/shared"; import { + buildCloudTaskConfigOptions, + type CloudTaskConfigOption, + createTaskAutomationSchema, DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, + getCloudTaskGatewayUrl, + isSupportedReasoningEffort, + normalizeGatewayModelsResponse, resolveCloudInitialPermissionMode, + taskAutomationListSchema, + taskAutomationSchema, + taskAutomationValidationErrorSchema, + updateTaskAutomationSchema, } from "@posthog/shared"; import type { AgentAnalyticsData, @@ -101,9 +113,18 @@ import { type HogQLGrid, shapeAgentAnalytics, } from "./agent-analytics"; -import { buildApiFetcher, requestErrorStatus } from "./fetcher"; +import { + ApiRequestError, + buildApiFetcher, + type FetchImplementation, + requestErrorStatus, +} from "./fetcher"; import { createApiClient, type Schemas } from "./generated"; import type { SpendAnalysisResponse } from "./spend-analysis"; +import { + normalizeTaskResponse, + normalizeTaskRunResponse, +} from "./task-normalization"; export interface ApiClientLogger { warn(...args: unknown[]): void; } @@ -122,6 +143,13 @@ export function setPosthogApiClientAppVersion(version: string): void { clientAppVersion = version; } +export interface PostHogAPIClientOptions { + fetch?: FetchImplementation; + appVersion?: string; + userAgent?: string | null; + githubConnectFrom?: string; +} + export function getPosthogApiClientAppVersion(): string { return clientAppVersion; } @@ -163,6 +191,36 @@ export class CloudUsageLimitError extends Error { } } +export class TaskAutomationValidationError extends Error { + readonly status = 400; + readonly code: string; + readonly attr: string | null; + + constructor(details: { + detail: string; + code: string; + attr: string | null; + }) { + super(details.detail); + this.name = "TaskAutomationValidationError"; + this.code = details.code; + this.attr = details.attr; + } +} + +function rethrowTaskAutomationError(error: unknown): never { + if (error instanceof ApiRequestError && error.status === 400) { + const validationError = taskAutomationValidationErrorSchema.safeParse( + error.body, + ); + if (validationError.success) { + throw new TaskAutomationValidationError(validationError.data); + } + } + + throw error; +} + export const MCP_CATEGORIES = [ { id: "all", label: "All" }, { id: "business", label: "Business Operations" }, @@ -581,7 +639,7 @@ export interface FinalizedTaskArtifactUpload { uploaded_at?: string; } -interface CloudRunOptions { +export interface CloudRunOptions { adapter?: Adapter; model?: string; reasoningLevel?: string; @@ -602,6 +660,56 @@ interface CloudRunOptions { relayedMcpServers?: CloudMcpServerRelayDesignation[]; } +export type CloudRunCommandMethod = + | "user_message" + | "permission_response" + | "set_config_option" + | "cancel" + | "close"; + +export class CloudCommandError extends Error { + readonly status: number; + readonly backendError: string | null; + readonly method: CloudRunCommandMethod; + + constructor( + method: CloudRunCommandMethod, + status: number, + backendError: string | null, + message: string, + ) { + super(message); + this.name = "CloudCommandError"; + this.method = method; + this.status = status; + this.backendError = backendError; + } + + isSandboxInactive(): boolean { + const backendError = this.backendError?.toLowerCase(); + return ( + this.status === 404 || + backendError?.includes("no active sandbox") === true || + backendError?.includes("returned 404") === true + ); + } +} + +function cloudCommandBackendError(payload: unknown): string | null { + if (typeof payload === "string") return payload || null; + if (!payload || typeof payload !== "object") return null; + + const error = "error" in payload ? payload.error : null; + if (typeof error === "string") return error || null; + if (error && typeof error === "object" && "message" in error) { + return typeof error.message === "string" ? error.message : null; + } + if ("message" in payload && typeof payload.message === "string") { + return payload.message; + } + return null; +} + interface CreateTaskRunOptions extends CloudRunOptions { environment?: "local" | "cloud"; mode?: "interactive" | "background"; @@ -1342,19 +1450,28 @@ function previewTokenHeader( export class PostHogAPIClient { private api: ReturnType; private _teamId: number | null = null; + private githubConnectFrom: string; + private readonly fetch: FetchImplementation; + private readonly apiHost: string; constructor( apiHost: string, getAccessToken: () => Promise, refreshAccessToken: () => Promise, teamId?: number, + options: PostHogAPIClientOptions = {}, ) { const baseUrl = apiHost.endsWith("/") ? apiHost.slice(0, -1) : apiHost; + this.apiHost = baseUrl; + this.githubConnectFrom = options.githubConnectFrom ?? "posthog_code"; + this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis); this.api = createApiClient( buildApiFetcher({ getAccessToken, refreshAccessToken, - appVersion: clientAppVersion, + appVersion: options.appVersion ?? clientAppVersion, + fetch: options.fetch, + userAgent: options.userAgent, }), baseUrl, ); @@ -1391,6 +1508,21 @@ export class PostHogAPIClient { return data; } + async getCloudTaskConfigOptions( + adapter: Adapter = "claude", + ): Promise { + const url = new URL(`${getCloudTaskGatewayUrl(this.apiHost)}/v1/models`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: url.pathname, + }); + return buildCloudTaskConfigOptions( + normalizeGatewayModelsResponse(await response.json()), + adapter, + ); + } + // Desktop file system — the backend surface that backs canvas channels // (top-level folders) and dashboards. These routes aren't in the generated // OpenAPI client, so we use the raw fetcher. @@ -1755,7 +1887,10 @@ export class PostHogAPIClient { url, path: urlPath, overrides: { - body: JSON.stringify({ team_id: id, connect_from: "posthog_code" }), + body: JSON.stringify({ + team_id: id, + connect_from: this.githubConnectFrom, + }), }, }); if (!response.ok) { @@ -2257,7 +2392,7 @@ export class PostHogAPIClient { originProduct?: string; internal?: boolean; channel?: string; - }) { + }): Promise { const teamId = await this.getTeamId(); const params: Record = { limit: 500, @@ -2288,7 +2423,9 @@ export class PostHogAPIClient { query: params, }); - return data.results ?? []; + return (data.results ?? []).map((task) => + normalizeTaskResponse(task, { teamId }), + ); } async getTaskSummaries(ids: string[]) { @@ -2330,7 +2467,102 @@ export class PostHogAPIClient { const data = await this.api.get(`/api/projects/{project_id}/tasks/{id}/`, { path: { project_id: teamId.toString(), id: taskId }, }); - return data as unknown as Task; + return normalizeTaskResponse(data, { teamId }); + } + + async listTaskAutomations(options?: { + limit?: number; + offset?: number; + }): Promise { + const teamId = await this.getTeamId(); + const data = await this.api.get( + `/api/projects/{project_id}/task_automations/`, + { + path: { project_id: teamId.toString() }, + query: { + limit: options?.limit ?? 500, + ...(options?.offset === undefined ? {} : { offset: options.offset }), + }, + }, + ); + + return taskAutomationListSchema.parse(data).results; + } + + async getTaskAutomation(automationId: string): Promise { + const teamId = await this.getTeamId(); + const data = await this.api.get( + `/api/projects/{project_id}/task_automations/{id}/`, + { + path: { project_id: teamId.toString(), id: automationId }, + }, + ); + + return taskAutomationSchema.parse(data); + } + + async createTaskAutomation( + options: CreateTaskAutomationOptions, + ): Promise { + const teamId = await this.getTeamId(); + const body = createTaskAutomationSchema.parse(options); + + try { + const data = await this.api.post( + `/api/projects/{project_id}/task_automations/`, + { + path: { project_id: teamId.toString() }, + body: body as Schemas.TaskAutomation, + }, + ); + return taskAutomationSchema.parse(data); + } catch (error) { + rethrowTaskAutomationError(error); + } + } + + async updateTaskAutomation( + automationId: string, + updates: UpdateTaskAutomationOptions, + ): Promise { + const teamId = await this.getTeamId(); + const body = updateTaskAutomationSchema.parse(updates); + + try { + const data = await this.api.patch( + `/api/projects/{project_id}/task_automations/{id}/`, + { + path: { project_id: teamId.toString(), id: automationId }, + body, + }, + ); + return taskAutomationSchema.parse(data); + } catch (error) { + rethrowTaskAutomationError(error); + } + } + + async deleteTaskAutomation(automationId: string): Promise { + const teamId = await this.getTeamId(); + await this.api.delete(`/api/projects/{project_id}/task_automations/{id}/`, { + path: { project_id: teamId.toString(), id: automationId }, + }); + } + + async runTaskAutomation(automationId: string): Promise { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/task_automations/${automationId}/run/`; + + try { + const response = await this.api.fetcher.fetch({ + method: "post", + path, + url: new URL(`${this.api.baseUrl}${path}`), + }); + return taskAutomationSchema.parse(await response.json()); + } catch (error) { + rethrowTaskAutomationError(error); + } } async createTask( @@ -2357,7 +2589,7 @@ export class PostHogAPIClient { pending_user_artifact_ids?: string[]; auto_publish?: boolean; }, - ) { + ): Promise { const teamId = await this.getTeamId(); const { origin_product: originProduct, ...taskOptions } = options; @@ -2369,10 +2601,13 @@ export class PostHogAPIClient { } as unknown as Schemas.Task, }); - return data; + return normalizeTaskResponse(data, { teamId }); } - async updateTask(taskId: string, updates: Partial) { + async updateTask( + taskId: string, + updates: Partial, + ): Promise { const teamId = await this.getTeamId(); const data = await this.api.patch( `/api/projects/{project_id}/tasks/{id}/`, @@ -2382,7 +2617,7 @@ export class PostHogAPIClient { }, ); - return data; + return normalizeTaskResponse(data, { teamId }); } async deleteTask(taskId: string) { @@ -2681,9 +2916,28 @@ export class PostHogAPIClient { async sendRunCommand( taskId: string, runId: string, - method: "user_message" | "cancel" | "close", + method: CloudRunCommandMethod, params?: Record, ): Promise<{ success: boolean; result?: unknown; error?: string }> { + try { + return { + success: true, + result: await this.sendCloudRunCommand(taskId, runId, method, params), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + async sendCloudRunCommand( + taskId: string, + runId: string, + method: CloudRunCommandMethod, + params: Record = {}, + ): Promise { const teamId = await this.getTeamId(); const url = new URL( `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/command/`, @@ -2691,7 +2945,7 @@ export class PostHogAPIClient { const body = { jsonrpc: "2.0", method, - params: params ?? {}, + params, id: `posthog-code-${Date.now()}`, }; @@ -2705,39 +2959,54 @@ export class PostHogAPIClient { }, }); - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - let errorMessage = `Command failed: ${response.statusText}`; - try { - const errorJson = JSON.parse(errorText); - errorMessage = - errorJson.error?.message ?? errorJson.error ?? errorMessage; - } catch { - if (errorText) errorMessage = errorText; - } - return { success: false, error: errorMessage }; - } - const data = (await response.json()) as { - error?: { message?: string }; + error?: unknown; result?: unknown; }; if (data.error) { - return { - success: false, - error: data.error.message ?? JSON.stringify(data.error), - }; + const backendError = cloudCommandBackendError(data); + throw new CloudCommandError( + method, + response.status, + backendError, + `Cloud command '${method}' error: ${backendError ?? JSON.stringify(data.error)}`, + ); } - return { success: true, result: data.result }; + return data.result; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; + if (error instanceof CloudCommandError) throw error; + if (error instanceof ApiRequestError) { + const backendError = cloudCommandBackendError(error.body); + throw new CloudCommandError( + method, + error.status, + backendError, + `Cloud command '${method}' failed: ${error.status}${backendError ? ` ${backendError}` : ""}`, + ); + } + throw error; } } + async cancelTaskRun( + taskId: string, + runId: string, + reason?: string, + ): Promise<{ status?: string }> { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/cancel/`; + const response = await this.api.fetcher.fetch({ + method: "post", + url: new URL(`${this.api.baseUrl}${path}`), + path, + overrides: { + body: JSON.stringify(reason ? { reason } : {}), + }, + }); + return (await response.json().catch(() => ({}))) as { status?: string }; + } + async runTaskInCloud( taskId: string, branch?: string | null, @@ -2761,7 +3030,7 @@ export class PostHogAPIClient { }), ); - return data as unknown as Task; + return normalizeTaskResponse(data, { teamId }); } async warmTask(options: { @@ -3001,7 +3270,8 @@ export class PostHogAPIClient { throw new Error(`Failed to resume run in cloud: ${response.statusText}`); } - return (await response.json()) as TaskRun; + const data = (await response.json()) as Schemas.TaskRunDetail; + return normalizeTaskRunResponse(data, { teamId, taskId }); } async listTaskRuns(taskId: string): Promise { @@ -3019,8 +3289,11 @@ export class PostHogAPIClient { throw new Error(`Failed to fetch task runs: ${response.statusText}`); } - const data = (await response.json()) as { results?: TaskRun[] }; - return data.results ?? []; + const data = + (await response.json()) as Partial; + return (data.results ?? []).map((run) => + normalizeTaskRunResponse(run, { teamId, taskId }), + ); } async getTaskRun(taskId: string, runId: string): Promise { @@ -3038,7 +3311,8 @@ export class PostHogAPIClient { throw new Error(`Failed to fetch task run: ${response.statusText}`); } - return (await response.json()) as TaskRun; + const data = (await response.json()) as Schemas.TaskRunDetail; + return normalizeTaskRunResponse(data, { teamId, taskId }); } async createTaskRun( @@ -3070,7 +3344,8 @@ export class PostHogAPIClient { throw new Error(`Failed to create task run: ${response.statusText}`); } - return (await response.json()) as TaskRun; + const data = (await response.json()) as Schemas.TaskRunDetail; + return normalizeTaskRunResponse(data, { teamId, taskId }); } async startTaskRun( @@ -3100,7 +3375,8 @@ export class PostHogAPIClient { throw new Error(`Failed to start task run: ${response.statusText}`); } - return (await response.json()) as Task; + const data = (await response.json()) as Schemas.Task; + return normalizeTaskResponse(data, { teamId }); } async updateTaskRun( @@ -3125,7 +3401,7 @@ export class PostHogAPIClient { body: updates as Record, }, ); - return data as unknown as TaskRun; + return normalizeTaskRunResponse(data, { teamId, taskId }); } /** @@ -3215,14 +3491,14 @@ export class PostHogAPIClient { async getTaskLogs(taskId: string): Promise { try { - const task = (await this.getTask(taskId)) as unknown as Task; + const task = await this.getTask(taskId); const logUrl = task?.latest_run?.log_url; if (!logUrl) { return []; } - const response = await fetch(logUrl); + const response = await this.fetch(logUrl); if (!response.ok) { log.warn( diff --git a/packages/api-client/src/task-normalization.test.ts b/packages/api-client/src/task-normalization.test.ts new file mode 100644 index 0000000000..9c9739a51f --- /dev/null +++ b/packages/api-client/src/task-normalization.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeTaskResponse, + normalizeTaskRunResponse, +} from "./task-normalization"; + +describe("task response normalization", () => { + it("normalizes legacy started runs and nullable generated fields", () => { + expect( + normalizeTaskRunResponse( + { + id: "run-1", + task: "task-1", + status: "started", + branch: null, + stage: null, + runtime_adapter: null, + model: null, + reasoning_effort: null, + log_url: null, + error_message: null, + output: null, + state: null, + artifacts: [ + { + id: "artifact-1", + name: "result.txt", + type: "legacy_type", + storage_path: "tasks/result.txt", + uploaded_at: "2026-07-21T00:00:00Z", + }, + ], + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:01:00Z", + completed_at: null, + }, + { teamId: 123 }, + ), + ).toEqual({ + id: "run-1", + task: "task-1", + team: 123, + branch: null, + stage: null, + runtime_adapter: null, + model: null, + reasoning_effort: null, + status: "in_progress", + log_url: "", + error_message: null, + output: null, + state: {}, + artifacts: [ + { + id: "artifact-1", + name: "result.txt", + type: "artifact", + storage_path: "tasks/result.txt", + uploaded_at: "2026-07-21T00:00:00Z", + }, + ], + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:01:00Z", + completed_at: null, + }); + }); + + it("normalizes task responses and their generated latest-run records", () => { + expect( + normalizeTaskResponse( + { + id: "task-1", + task_number: null, + slug: "task-1", + repository: null, + github_integration: null, + github_user_integration: null, + json_schema: null, + signal_report: null, + channel: null, + latest_run: { + id: "run-1", + status: "started", + log_url: null, + }, + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:01:00Z", + }, + { teamId: 123 }, + ), + ).toMatchObject({ + id: "task-1", + task_number: null, + slug: "task-1", + title: "", + description: "", + origin_product: "", + repository: null, + github_integration: null, + github_user_integration: null, + json_schema: null, + signal_report: null, + channel: null, + latest_run: { + id: "run-1", + task: "task-1", + team: 123, + status: "in_progress", + log_url: "", + output: null, + state: {}, + }, + }); + }); +}); diff --git a/packages/api-client/src/task-normalization.ts b/packages/api-client/src/task-normalization.ts new file mode 100644 index 0000000000..9571a5b27e --- /dev/null +++ b/packages/api-client/src/task-normalization.ts @@ -0,0 +1,203 @@ +import type { + ArtifactType, + Task, + TaskRun, + TaskRunArtifact, + TaskRunArtifactMetadata, + TaskRunStatus, +} from "@posthog/shared/domain-types"; +import type { Schemas } from "./generated"; + +type TaskRunResponseDTO = Partial< + Omit +> & { + id: string; + artifacts?: Array< + Schemas.TaskRunArtifactResponse & { metadata?: unknown } + > | null; + status?: Schemas.StatusA35Enum | "started" | null; + team?: number | null; +}; + +type TaskResponseDTO = Partial< + Omit +> & { + id: string; + channel?: string | null; + created_by?: Schemas.UserBasic | null; + github_user_integration?: string | null; + json_schema?: unknown | null; + latest_run?: Record | null; + runtime?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isTaskRunResponseDTO(value: unknown): value is TaskRunResponseDTO { + return isRecord(value) && typeof value.id === "string"; +} + +function normalizeTaskRunStatus(status: unknown): TaskRunStatus { + switch (status) { + case "started": + return "in_progress"; + case "not_started": + case "queued": + case "in_progress": + case "completed": + case "failed": + case "cancelled": + return status; + default: + return "not_started"; + } +} + +function normalizeArtifactType(type: string): ArtifactType { + switch (type) { + case "plan": + case "context": + case "reference": + case "output": + case "artifact": + case "user_attachment": + case "skill_bundle": + return type; + default: + return "artifact"; + } +} + +function normalizeArtifactMetadata( + value: unknown, +): TaskRunArtifactMetadata | undefined { + if ( + !isRecord(value) || + typeof value.skill_name !== "string" || + (value.skill_source !== "user" && + value.skill_source !== "repo" && + value.skill_source !== "marketplace" && + value.skill_source !== "codex") + ) { + return undefined; + } + if ( + typeof value.content_sha256 !== "string" || + value.bundle_format !== "zip" || + typeof value.schema_version !== "number" + ) { + return undefined; + } + + return { + skill_name: value.skill_name, + skill_source: value.skill_source, + content_sha256: value.content_sha256, + bundle_format: value.bundle_format, + schema_version: value.schema_version, + }; +} + +function normalizeTaskRunArtifact( + artifact: NonNullable[number], +): TaskRunArtifact { + const metadata = normalizeArtifactMetadata(artifact.metadata); + + return { + ...(artifact.id === undefined ? {} : { id: artifact.id }), + name: artifact.name, + type: normalizeArtifactType(artifact.type), + ...(artifact.source === undefined ? {} : { source: artifact.source }), + ...(artifact.size === undefined ? {} : { size: artifact.size }), + ...(artifact.content_type === undefined + ? {} + : { content_type: artifact.content_type }), + ...(metadata === undefined ? {} : { metadata }), + ...(artifact.storage_path === undefined + ? {} + : { storage_path: artifact.storage_path }), + ...(artifact.uploaded_at === undefined + ? {} + : { uploaded_at: artifact.uploaded_at }), + }; +} + +export function normalizeTaskRunResponse( + dto: TaskRunResponseDTO, + context: { teamId: number; taskId?: string }, +): TaskRun { + return { + id: dto.id, + task: dto.task ?? context.taskId ?? "", + team: dto.team ?? context.teamId, + branch: dto.branch ?? null, + ...(dto.runtime_adapter === undefined + ? {} + : { runtime_adapter: dto.runtime_adapter }), + ...(dto.model === undefined ? {} : { model: dto.model }), + ...(dto.reasoning_effort === undefined + ? {} + : { reasoning_effort: dto.reasoning_effort }), + ...(dto.stage === undefined ? {} : { stage: dto.stage }), + ...(dto.environment === undefined ? {} : { environment: dto.environment }), + status: normalizeTaskRunStatus(dto.status), + log_url: dto.log_url ?? "", + error_message: dto.error_message ?? null, + output: isRecord(dto.output) ? dto.output : null, + state: isRecord(dto.state) ? dto.state : {}, + ...(dto.artifacts == null + ? {} + : { artifacts: dto.artifacts.map(normalizeTaskRunArtifact) }), + created_at: dto.created_at ?? "", + updated_at: dto.updated_at ?? "", + completed_at: dto.completed_at ?? null, + }; +} + +export function normalizeTaskResponse( + dto: TaskResponseDTO, + context: { teamId: number }, +): Task { + const jsonSchema = isRecord(dto.json_schema) ? dto.json_schema : null; + const runtime = + dto.runtime === "acp" || dto.runtime === "pi" ? dto.runtime : undefined; + + const latestRun = isTaskRunResponseDTO(dto.latest_run) + ? normalizeTaskRunResponse(dto.latest_run, { + teamId: context.teamId, + taskId: dto.id, + }) + : undefined; + + return { + id: dto.id, + task_number: dto.task_number ?? null, + slug: dto.slug ?? "", + title: dto.title ?? "", + ...(dto.title_manually_set === undefined + ? {} + : { title_manually_set: dto.title_manually_set }), + description: dto.description ?? "", + created_at: dto.created_at ?? "", + updated_at: dto.updated_at ?? "", + ...(dto.created_by === undefined ? {} : { created_by: dto.created_by }), + origin_product: dto.origin_product ?? "", + ...(dto.repository === undefined ? {} : { repository: dto.repository }), + ...(dto.github_integration === undefined + ? {} + : { github_integration: dto.github_integration }), + ...(dto.github_user_integration === undefined + ? {} + : { github_user_integration: dto.github_user_integration }), + ...(dto.json_schema === undefined ? {} : { json_schema: jsonSchema }), + ...(dto.signal_report === undefined + ? {} + : { signal_report: dto.signal_report }), + ...(dto.internal === undefined ? {} : { internal: dto.internal }), + ...(runtime === undefined ? {} : { runtime }), + ...(dto.channel === undefined ? {} : { channel: dto.channel }), + ...(latestRun === undefined ? {} : { latest_run: latestRun }), + }; +} diff --git a/packages/core/src/automations/automationSchedule.test.ts b/packages/core/src/automations/automationSchedule.test.ts new file mode 100644 index 0000000000..5deb2487a0 --- /dev/null +++ b/packages/core/src/automations/automationSchedule.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; +import { + type AutomationScheduleDraft, + buildCronExpression, + createDefaultScheduleDraft, + deriveAutomationName, + formatAutomationScheduleSummary, + formatScheduleSummary, + parseCronExpression, + sanitizeHour, + sanitizeMinute, + WEEKDAY_OPTIONS, +} from "./automationSchedule"; + +describe("automationSchedule", () => { + it("creates the default daily schedule draft", () => { + expect(createDefaultScheduleDraft()).toEqual({ + mode: "daily", + hour: "09", + minute: "00", + weekday: "1", + rawCron: "0 9 * * *", + }); + }); + + it("provides cron weekday values in display order", () => { + expect(WEEKDAY_OPTIONS).toEqual([ + { value: "1", label: "Mon" }, + { value: "2", label: "Tue" }, + { value: "3", label: "Wed" }, + { value: "4", label: "Thu" }, + { value: "5", label: "Fri" }, + { value: "6", label: "Sat" }, + { value: "0", label: "Sun" }, + ]); + }); + + it.each([ + ["", ""], + ["a", ""], + ["7", "07"], + ["09", "09"], + ["2x3", "23"], + ["24", "23"], + ["999", "23"], + ])("sanitizes hour input %j to %j", (input, expected) => { + expect(sanitizeHour(input)).toBe(expected); + }); + + it.each([ + ["", ""], + ["a", ""], + ["7", "07"], + ["09", "09"], + ["5x9", "59"], + ["60", "59"], + ["999", "59"], + ])("sanitizes minute input %j to %j", (input, expected) => { + expect(sanitizeMinute(input)).toBe(expected); + }); + + it.each<{ + name: string; + changes: Partial; + expected: string; + }>([ + { + name: "hourly", + changes: { mode: "hourly", minute: "15" }, + expected: "15 * * * *", + }, + { + name: "daily", + changes: { mode: "daily", hour: "09", minute: "15" }, + expected: "15 9 * * *", + }, + { + name: "weekdays", + changes: { mode: "weekdays", hour: "10", minute: "00" }, + expected: "0 10 * * 1-5", + }, + { + name: "weekly", + changes: { + mode: "weekly", + hour: "11", + minute: "30", + weekday: "4", + }, + expected: "30 11 * * 4", + }, + { + name: "weekly with a missing weekday", + changes: { mode: "weekly", weekday: "" }, + expected: "0 9 * * 1", + }, + { + name: "preset with missing time values", + changes: { mode: "daily", hour: "", minute: "" }, + expected: "0 9 * * *", + }, + { + name: "custom", + changes: { mode: "custom", rawCron: " */15 * * * * " }, + expected: "*/15 * * * *", + }, + ])("builds the $name cron expression", ({ changes, expected }) => { + expect( + buildCronExpression({ ...createDefaultScheduleDraft(), ...changes }), + ).toBe(expected); + }); + + it.each([ + [ + "15 * * * *", + { + mode: "hourly", + hour: "09", + minute: "15", + weekday: "*", + rawCron: "15 * * * *", + }, + ], + [ + "0 9 * * *", + { + mode: "daily", + hour: "09", + minute: "00", + weekday: "*", + rawCron: "0 9 * * *", + }, + ], + [ + "0 9 * * 1-5", + { + mode: "weekdays", + hour: "09", + minute: "00", + weekday: "1", + rawCron: "0 9 * * 1-5", + }, + ], + [ + "30 14 * * 2", + { + mode: "weekly", + hour: "14", + minute: "30", + weekday: "2", + rawCron: "30 14 * * 2", + }, + ], + ] as const)("parses %s into a schedule draft", (cron, expected) => { + expect(parseCronExpression(cron)).toEqual(expected); + }); + + it.each(["*/15 * * * *", "0 9 1 * *", "0 9 * 1 *", "0 9 * * 1,3"])( + "keeps unsupported cron expression %s in custom mode", + (cron) => { + expect(parseCronExpression(cron)).toMatchObject({ + mode: "custom", + rawCron: cron, + }); + }, + ); + + it("normalizes surrounding and repeated cron whitespace", () => { + expect(parseCronExpression(" 5 8 * * * ")).toEqual({ + mode: "daily", + hour: "08", + minute: "05", + weekday: "*", + rawCron: "5 8 * * *", + }); + }); + + it("uses default draft fields for a cron expression with the wrong arity", () => { + expect(parseCronExpression("0 9 * *")).toEqual({ + mode: "custom", + hour: "09", + minute: "00", + weekday: "1", + rawCron: "0 9 * *", + }); + }); + + it("derives a compact name from the first non-empty prompt line", () => { + expect( + deriveAutomationName( + "\n Review every open PostHog PR for stale comments \nIgnore this line", + ), + ).toBe("Review every open PostHog PR for stale comments"); + }); + + it("returns an empty name for a blank prompt", () => { + expect(deriveAutomationName(" \n\t\n ")).toBe(""); + }); + + it("limits derived names to 80 characters", () => { + expect(deriveAutomationName("a".repeat(100))).toBe("a".repeat(80)); + }); + + it.each([ + ["15 * * * *", "Europe/London", "Every hour at :15 · Europe/London"], + ["0 9 * * *", null, "Daily at 09:00"], + ["0 9 * * 1-5", "UTC", "Weekdays at 09:00 · UTC"], + ["30 14 * * 2", undefined, "Tue at 14:30"], + ["30 14 * * 7", "UTC", "Weekly at 14:30 · UTC"], + ["*/15 * * * *", "UTC", "Custom schedule · UTC"], + ])("formats %s with timezone %j", (cronExpression, timezone, expected) => { + expect(formatScheduleSummary(cronExpression, timezone)).toBe(expected); + }); + + it("formats a schedule from an automation-shaped input", () => { + expect( + formatAutomationScheduleSummary({ + cron_expression: "0 18 * * 0", + timezone: "America/New_York", + }), + ).toBe("Sun at 18:00 · America/New_York"); + }); +}); diff --git a/packages/core/src/automations/automationSchedule.ts b/packages/core/src/automations/automationSchedule.ts new file mode 100644 index 0000000000..527e8da497 --- /dev/null +++ b/packages/core/src/automations/automationSchedule.ts @@ -0,0 +1,216 @@ +export type AutomationScheduleMode = + | "hourly" + | "daily" + | "weekdays" + | "weekly" + | "custom"; + +export interface AutomationScheduleDraft { + mode: AutomationScheduleMode; + hour: string; + minute: string; + weekday: string; + rawCron: string; +} + +export interface AutomationScheduleSummaryInput { + cron_expression: string; + timezone?: string | null; +} + +export const WEEKDAY_OPTIONS = [ + { value: "1", label: "Mon" }, + { value: "2", label: "Tue" }, + { value: "3", label: "Wed" }, + { value: "4", label: "Thu" }, + { value: "5", label: "Fri" }, + { value: "6", label: "Sat" }, + { value: "0", label: "Sun" }, +] as const; + +export function createDefaultScheduleDraft(): AutomationScheduleDraft { + return { + mode: "daily", + hour: "09", + minute: "00", + weekday: "1", + rawCron: "0 9 * * *", + }; +} + +function padTimePart(value: string): string { + return value.padStart(2, "0"); +} + +export function sanitizeHour(value: string): string { + const digitsOnly = value.replace(/\D/g, "").slice(0, 2); + if (!digitsOnly) { + return ""; + } + + return String(Math.min(23, Number(digitsOnly))).padStart(2, "0"); +} + +export function sanitizeMinute(value: string): string { + const digitsOnly = value.replace(/\D/g, "").slice(0, 2); + if (!digitsOnly) { + return ""; + } + + return String(Math.min(59, Number(digitsOnly))).padStart(2, "0"); +} + +export function buildCronExpression(draft: AutomationScheduleDraft): string { + if (draft.mode === "custom") { + return draft.rawCron.trim(); + } + + const minute = draft.minute ? String(Number(draft.minute)) : "0"; + const hour = draft.hour ? String(Number(draft.hour)) : "9"; + + switch (draft.mode) { + case "hourly": + return `${minute} * * * *`; + case "weekdays": + return `${minute} ${hour} * * 1-5`; + case "weekly": + return `${minute} ${hour} * * ${draft.weekday || "1"}`; + default: + return `${minute} ${hour} * * *`; + } +} + +export function parseCronExpression( + cronExpression: string, +): AutomationScheduleDraft { + const normalized = cronExpression.trim(); + const parts = normalized.split(/\s+/); + + if (parts.length !== 5) { + return { + ...createDefaultScheduleDraft(), + mode: "custom", + rawCron: normalized, + }; + } + + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; + const isNumericMinute = /^\d{1,2}$/.test(minute); + const isNumericHour = /^\d{1,2}$/.test(hour); + const draftBase = { + hour: padTimePart(hour), + minute: padTimePart(minute), + weekday: dayOfWeek, + rawCron: normalized, + }; + + if ( + isNumericMinute && + hour === "*" && + dayOfMonth === "*" && + month === "*" && + dayOfWeek === "*" + ) { + return { + ...draftBase, + mode: "hourly", + hour: "09", + }; + } + + if ( + isNumericMinute && + isNumericHour && + dayOfMonth === "*" && + month === "*" && + dayOfWeek === "*" + ) { + return { + ...draftBase, + mode: "daily", + }; + } + + if ( + isNumericMinute && + isNumericHour && + dayOfMonth === "*" && + month === "*" && + dayOfWeek === "1-5" + ) { + return { + ...draftBase, + mode: "weekdays", + weekday: "1", + }; + } + + if ( + isNumericMinute && + isNumericHour && + dayOfMonth === "*" && + month === "*" && + /^\d$/.test(dayOfWeek) + ) { + return { + ...draftBase, + mode: "weekly", + }; + } + + return { + ...draftBase, + mode: "custom", + }; +} + +export function deriveAutomationName(prompt: string): string { + const normalized = prompt + .split("\n") + .map((line) => line.trim()) + .find(Boolean); + + if (!normalized) { + return ""; + } + + return normalized.replace(/\s+/g, " ").slice(0, 80); +} + +function formatTime(hour: string, minute: string): string { + return `${padTimePart(hour)}:${padTimePart(minute)}`; +} + +export function formatScheduleSummary( + cronExpression: string, + timezone: string | null | undefined, +): string { + const draft = parseCronExpression(cronExpression); + const suffix = timezone ? ` · ${timezone}` : ""; + + switch (draft.mode) { + case "hourly": + return `Every hour at :${padTimePart(draft.minute)}${suffix}`; + case "weekdays": + return `Weekdays at ${formatTime(draft.hour, draft.minute)}${suffix}`; + case "weekly": { + const label = + WEEKDAY_OPTIONS.find((option) => option.value === draft.weekday) + ?.label ?? "Weekly"; + return `${label} at ${formatTime(draft.hour, draft.minute)}${suffix}`; + } + case "custom": + return `Custom schedule${suffix}`; + default: + return `Daily at ${formatTime(draft.hour, draft.minute)}${suffix}`; + } +} + +export function formatAutomationScheduleSummary( + automation: AutomationScheduleSummaryInput, +): string { + return formatScheduleSummary( + automation.cron_expression, + automation.timezone ?? null, + ); +} diff --git a/packages/core/src/automations/automationStatus.test.ts b/packages/core/src/automations/automationStatus.test.ts new file mode 100644 index 0000000000..862fc3c0b5 --- /dev/null +++ b/packages/core/src/automations/automationStatus.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + type AutomationStatusPresentation, + type AutomationTaskRunStatus, + getAutomationStatusPresentation, +} from "./automationStatus"; + +describe("automationStatus", () => { + it.each<{ + status: AutomationTaskRunStatus; + expected: AutomationStatusPresentation | null; + }>([ + { + status: "not_started", + expected: { label: "Queued", tone: "warning", iconKind: "queued" }, + }, + { + status: "queued", + expected: { label: "Queued", tone: "warning", iconKind: "queued" }, + }, + { status: "started", expected: null }, + { status: "in_progress", expected: null }, + { + status: "completed", + expected: { label: "Success", tone: "success", iconKind: "success" }, + }, + { + status: "failed", + expected: { label: "Failed", tone: "error", iconKind: "failed" }, + }, + { + status: "cancelled", + expected: { label: "Failed", tone: "error", iconKind: "failed" }, + }, + ])( + "maps task-run status $status to renderer-neutral presentation data", + ({ status, expected }) => { + expect( + getAutomationStatusPresentation({ + lastRunStatus: "success", + lastTaskRunStatus: status, + }), + ).toEqual(expected); + }, + ); + + it.each([ + ["running", null], + ["success", { label: "Success", tone: "success", iconKind: "success" }], + ["failed", { label: "Failed", tone: "error", iconKind: "failed" }], + [null, { label: "Never run", tone: "neutral", iconKind: "never-run" }], + ["unknown", { label: "Never run", tone: "neutral", iconKind: "never-run" }], + ] as const)( + "falls back from automation status %j to semantic presentation data", + (lastRunStatus, expected) => { + expect(getAutomationStatusPresentation({ lastRunStatus })).toEqual( + expected, + ); + }, + ); + + it("prioritizes linked task-run detail over the automation-level status", () => { + expect( + getAutomationStatusPresentation({ + lastRunStatus: "failed", + lastTaskRunStatus: "completed", + }), + ).toEqual({ + label: "Success", + tone: "success", + iconKind: "success", + }); + }); + + it("does not expose renderer-specific class names", () => { + expect( + getAutomationStatusPresentation({ lastRunStatus: "success" }), + ).not.toHaveProperty("className"); + }); +}); diff --git a/packages/core/src/automations/automationStatus.ts b/packages/core/src/automations/automationStatus.ts new file mode 100644 index 0000000000..4bcc13735e --- /dev/null +++ b/packages/core/src/automations/automationStatus.ts @@ -0,0 +1,83 @@ +export type AutomationTaskRunStatus = + | "not_started" + | "queued" + | "started" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + +export interface AutomationStatusInput { + lastRunStatus: string | null; + lastTaskRunStatus?: AutomationTaskRunStatus | null; +} + +export type AutomationStatusTone = "neutral" | "warning" | "success" | "error"; + +export type AutomationStatusIconKind = + | "queued" + | "success" + | "failed" + | "never-run"; + +export interface AutomationStatusPresentation { + label: string; + tone: AutomationStatusTone; + iconKind: AutomationStatusIconKind; +} + +export function getAutomationStatusPresentation({ + lastRunStatus, + lastTaskRunStatus, +}: AutomationStatusInput): AutomationStatusPresentation | null { + switch (lastTaskRunStatus) { + case "not_started": + case "queued": + return { + label: "Queued", + tone: "warning", + iconKind: "queued", + }; + case "started": + case "in_progress": + return null; + case "completed": + return { + label: "Success", + tone: "success", + iconKind: "success", + }; + case "failed": + case "cancelled": + return { + label: "Failed", + tone: "error", + iconKind: "failed", + }; + default: + break; + } + + switch (lastRunStatus) { + case "running": + return null; + case "success": + return { + label: "Success", + tone: "success", + iconKind: "success", + }; + case "failed": + return { + label: "Failed", + tone: "error", + iconKind: "failed", + }; + default: + return { + label: "Never run", + tone: "neutral", + iconKind: "never-run", + }; + } +} diff --git a/packages/core/src/cloud-task/cloud-task-engine.ts b/packages/core/src/cloud-task/cloud-task-engine.ts new file mode 100644 index 0000000000..006b0ab6d2 --- /dev/null +++ b/packages/core/src/cloud-task/cloud-task-engine.ts @@ -0,0 +1,2250 @@ +import type { RootLogger, ScopedLogger } from "@posthog/di/logger"; +import type { IAnalytics } from "@posthog/platform/analytics"; +import { + type CloudTaskPermissionRequestUpdate, + isTerminalStatus, + mcpToolKey, + posthogToolMeta, + type StoredLogEntry, + serializeError, + type TaskRunStatus, + TypedEventEmitter, +} from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import type { ICloudTaskAuth, McpRelayExecutor } from "./identifiers"; +import { + CloudTaskEvent, + type CloudTaskEvents, + type SendCommandInput, + type SendCommandOutput, + type StopInput, + type StopOutput, + type WatchInput, +} from "./schemas"; +import { type SseEvent, SseEventParser } from "./sse-parser"; + +// Reconnect backoff: flat base delay for the first SSE_RECONNECT_FLAT_ATTEMPTS attempts, then +// exponential up to the cap (0.5, 0.5, 0.5, 1, 2, 4, 8, 16, 30s), spanning ~60s before giving up. +const MAX_SSE_RECONNECT_ATTEMPTS = 9; +const MAX_CUMULATIVE_RECONNECT_ATTEMPTS = 30; +const SSE_RECONNECT_BASE_DELAY_MS = 500; +const SSE_RECONNECT_FLAT_ATTEMPTS = 3; +const SSE_RECONNECT_MAX_DELAY_MS = 30_000; +const SSE_HEALTHY_CONNECTION_MS = 60_000; +const EVENT_BATCH_FLUSH_MS = 16; +const EVENT_BATCH_MAX_SIZE = 50; +const SESSION_LOG_PAGE_LIMIT = 5_000; +const MAX_HANDLED_RELAY_REQUEST_IDS = 1_000; +const MCP_RELAY_METHODS_WITHOUT_APPROVAL = new Set([ + "initialize", + "notifications/initialized", + "ping", + "tools/list", + "prompts/list", + "resources/list", + "resources/templates/list", +]); + +// Authoritative end-of-stream sentinel, matched on the SSE event name (event.event, not data.type). +// The client stops on it without consulting run status. +const STREAM_END_EVENT_NAME = "stream-end"; + +interface SessionLogsPage { + entries: StoredLogEntry[]; + hasMore: boolean; +} + +interface CloudTaskConnectionError { + title: string; + message: string; + retryable: boolean; + autoRetry?: boolean; +} + +class CloudTaskStreamError extends Error { + constructor( + message: string, + public readonly details: CloudTaskConnectionError, + public readonly status?: number, + ) { + super(message); + this.name = "CloudTaskStreamError"; + } +} + +class BackendStreamError extends Error { + constructor(message: string) { + super(message); + this.name = "BackendStreamError"; + } +} + +interface TaskRunResponse { + id: string; + status: TaskRunStatus; + stage?: string | null; + output?: Record | null; + state?: Record | null; + error_message?: string | null; + branch?: string | null; + updated_at?: string; + completed_at?: string | null; +} + +interface TaskRunStateEvent { + type: "task_run_state"; + status?: TaskRunStatus; + stage?: string | null; + output?: Record | null; + state?: Record | null; + error_message?: string | null; + branch?: string | null; + updated_at?: string | null; + completed_at?: string | null; +} + +// Which endpoint a connection reads from. Event ids are only meaningful within their issuing leg. +type StreamLeg = "proxy" | "django"; + +interface WatcherState { + taskId: string; + runId: string; + apiHost: string; + teamId: number; + subscriberCount: number; + sseAbortController: AbortController | null; + reconnectTimeoutId: ReturnType | null; + batchFlushTimeoutId: ReturnType | null; + pendingLogEntries: StoredLogEntry[]; + totalEntryCount: number; + /** On resume the renderer already holds the prior conversation; start live- + * only (no bootstrap fetch/snapshot) seeded at this count so the in-flight + * turn can't collide with a re-fetched snapshot. Null on non-resume watches. */ + resumeFromEntryCount: number | null; + reconnectAttempts: number; + streamErrorAttempts: number; + cumulativeReconnectAttempts: number; + lastEventId: string | null; + // Leg that issued lastEventId, and the leg of the connection currently being read. + lastEventIdLeg: StreamLeg | null; + streamLeg: StreamLeg | null; + // Ids of log entries already ingested on the current leg. The durable stream + // re-sends the tail by id on reconnect/replay, so dropping a seen id here is + // what stops a re-delivered entry (e.g. a `turn_complete`) from being counted + // and emitted twice. Cleared on a leg switch, where the id space changes. + seenEventIds: Set; + lastStatus: TaskRunStatus | null; + lastStage: string | null; + lastOutput: Record | null; + lastErrorMessage: string | null; + lastBranch: string | null; + lastSandboxAlive: boolean | null; + lastStatusUpdatedAt: string | null; + connStartedAt: number; + connSentLastEventId: string | null; + connDataEventsReceived: number; + isBootstrapping: boolean; + hasEmittedSnapshot: boolean; + bufferedLogBatches: StoredLogEntry[][]; + // Live entries emitted since the last snapshot, retained so a re-subscribe snapshot can reconcile + // entries the server has not persisted yet. emitCurrentSnapshot trims this to the still-missing + // set; with no re-subscribe it holds the run's emitted entries until the watch ends. + emittedLogEntries: StoredLogEntry[]; + failed: boolean; + needsPostBootstrapReconnect: boolean; + needsStopAfterBootstrap: boolean; + streamEnded: boolean; + // Consumes one automatic re-bootstrap recovery; re-armed by a data event or healthy connection. + selfHealAttempted: boolean; + // Both streamBaseUrl and streamReadToken non-null => read via the agent-proxy; either null => Django. + streamTargetResolved: boolean; + streamBaseUrl: string | null; + streamReadToken: string | null; + // True once stream_token resolved. False for old servers (404), which fall back to status polling. + durableStreamEnabled: boolean; +} + +function watcherKey(taskId: string, runId: string): string { + return `${taskId}:${runId}`; +} + +function isTaskRunStateEvent(data: unknown): data is TaskRunStateEvent { + return ( + typeof data === "object" && + data !== null && + (data as { type?: string }).type === "task_run_state" + ); +} + +interface SseErrorEventData { + error: string; +} + +function isSseErrorEvent(data: unknown): data is SseErrorEventData { + return ( + typeof data === "object" && + data !== null && + "error" in data && + typeof (data as SseErrorEventData).error === "string" + ); +} + +interface PermissionRequestEventData { + type: "permission_request"; + requestId: string; + toolCall: CloudTaskPermissionRequestUpdate["toolCall"]; + options: CloudTaskPermissionRequestUpdate["options"]; +} + +function isPermissionRequestEvent( + data: unknown, +): data is PermissionRequestEventData { + return ( + typeof data === "object" && + data !== null && + (data as { type?: string }).type === "permission_request" && + typeof (data as { requestId?: string }).requestId === "string" + ); +} + +interface McpRequestEventData { + type: "mcp_request"; + requestId: string; + server: string; + payload: Record; + expiresAt: string; +} + +function isMcpRequestEvent(data: unknown): data is McpRequestEventData { + if (typeof data !== "object" || data === null) return false; + const candidate = data as Partial; + return ( + candidate.type === "mcp_request" && + typeof candidate.requestId === "string" && + typeof candidate.server === "string" && + typeof candidate.payload === "object" && + candidate.payload !== null + ); +} + +/** Prefix marking a desktop-issued relay approval prompt, so `sendCommand` can + * resolve its response locally instead of POSTing it to the sandbox. */ +const RELAY_APPROVAL_REQUEST_PREFIX = "relay-approval:"; + +const RELAY_KEY_SEPARATOR = ""; + +function relayApprovalKey( + runId: string, + server: string, + kind: "method" | "tool", + name: string, +): string { + return [runId, server, kind, name].join(RELAY_KEY_SEPARATOR); +} + +interface RelayApprovalRequest { + approvalKey: string; + title: string; + toolName: string; + rawInput: Record; + mcp: { server: string; tool: string }; +} + +function relayApprovalRequest( + runId: string, + server: string, + payload: Record, +): RelayApprovalRequest | null { + const method = + typeof payload.method === "string" ? payload.method : "unknown"; + if (MCP_RELAY_METHODS_WITHOUT_APPROVAL.has(method)) return null; + + const params = + payload.params && typeof payload.params === "object" + ? (payload.params as Record) + : {}; + + if (method === "tools/call") { + const tool = typeof params.name === "string" ? params.name : "unknown"; + const args = + params.arguments && typeof params.arguments === "object" + ? (params.arguments as Record) + : {}; + const toolName = mcpToolKey({ server, tool }); + return { + approvalKey: relayApprovalKey(runId, server, "tool", tool), + title: `The agent wants to call ${tool} (${server}) on your machine`, + toolName, + rawInput: { ...args, toolName }, + mcp: { server, tool }, + }; + } + + const toolName = `mcp:${server}:${method}`; + return { + approvalKey: relayApprovalKey(runId, server, "method", method), + title: `The agent wants to send ${method} to ${server} on your machine`, + toolName, + rawInput: { method, params }, + mcp: { server, tool: method }, + }; +} + +function isKeepaliveEvent(event: SseEvent): boolean { + return ( + event.event === "keepalive" || + (typeof event.data === "object" && + event.data !== null && + "type" in event.data && + event.data.type === "keepalive") + ); +} + +function createStreamStatusError(status: number): CloudTaskStreamError { + switch (status) { + case 401: + return new CloudTaskStreamError( + "Cloud authentication expired", + { + title: "Cloud authentication expired", + message: "Please reauthenticate and retry the cloud run stream.", + retryable: true, + autoRetry: false, + }, + status, + ); + case 403: + return new CloudTaskStreamError( + "Cloud access denied", + { + title: "Cloud access denied", + message: + "You no longer have access to this cloud run. Reauthenticate and retry.", + retryable: true, + autoRetry: false, + }, + status, + ); + case 404: + return new CloudTaskStreamError( + "Cloud run not found", + { + title: "Cloud run not found", + message: + "This cloud run could not be found. It may have been deleted or moved.", + retryable: false, + autoRetry: false, + }, + status, + ); + case 406: + return new CloudTaskStreamError( + "Cloud stream unavailable", + { + title: "Cloud stream unavailable", + message: + "The backend rejected the live stream request. Restart the backend and retry.", + retryable: true, + autoRetry: false, + }, + status, + ); + default: + return new CloudTaskStreamError( + `Stream request failed with status ${status}`, + { + title: "Cloud stream failed", + message: `The cloud stream request failed with status ${status}. Retry to reconnect.`, + retryable: true, + autoRetry: true, + }, + status, + ); + } +} + +function shouldFailWatcherForFetchStatus(status: number): boolean { + return status === 401 || status === 403 || status === 404; +} + +// 5xx and 429 are momentary: the stream-token endpoint exists but is briefly unavailable, so the +// target stays unresolved and the next reconnect retries instead of caching a Django fallback. +function isTransientStreamTargetStatus(status: number): boolean { + return status >= 500 || status === 429; +} + +// Content-based frequency map keyed by the serialized entry. SSE ids are absent from persisted +// (historical) entries, so the payload itself is the identity used to dedup live against historical. +function buildEntryFrequencyMap( + entries: StoredLogEntry[], +): Map { + const counts = new Map(); + for (const entry of entries) { + const serialized = JSON.stringify(entry); + counts.set(serialized, (counts.get(serialized) ?? 0) + 1); + } + return counts; +} + +// Keeps only entries absent from counts, consuming one occurrence per match so a payload present N +// times in the reference set is suppressed at most N times. Mutates counts. +function filterEntriesNotInFrequencyMap( + entries: StoredLogEntry[], + counts: Map, +): StoredLogEntry[] { + return entries.filter((entry) => { + const serialized = JSON.stringify(entry); + const remaining = counts.get(serialized) ?? 0; + if (remaining <= 0) { + return true; + } + counts.set(serialized, remaining - 1); + return false; + }); +} + +function extractSandboxAlive( + state: Record | null | undefined, +): boolean | null | undefined { + if (!state || !Object.hasOwn(state, "sandbox_alive")) { + return undefined; + } + + const sandboxAlive = state.sandbox_alive; + return typeof sandboxAlive === "boolean" ? sandboxAlive : null; +} + +function sandboxAlivePayload(watcher: { lastSandboxAlive: boolean | null }): { + sandboxAlive?: boolean | null; +} { + return watcher.lastSandboxAlive === null + ? {} + : { sandboxAlive: watcher.lastSandboxAlive }; +} + +export interface CloudTaskEngineDependencies { + auth: ICloudTaskAuth; + analytics: IAnalytics; + logger: RootLogger; + mcpRelayExecutor?: McpRelayExecutor | null; + streamFetch?: CloudTaskFetch; +} + +export type CloudTaskFetch = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +export function createCloudTaskEngine( + dependencies: CloudTaskEngineDependencies, +): CloudTaskEngine { + return new CloudTaskEngine(dependencies); +} + +export class CloudTaskEngine extends TypedEventEmitter { + private watchers = new Map(); + private readonly log: ScopedLogger; + private readonly auth: ICloudTaskAuth; + private readonly analytics: IAnalytics; + private readonly mcpRelayExecutor: McpRelayExecutor | null; + private readonly streamFetch: CloudTaskFetch; + + constructor({ + auth, + analytics, + logger, + mcpRelayExecutor = null, + streamFetch = globalThis.fetch.bind(globalThis), + }: CloudTaskEngineDependencies) { + super(); + this.auth = auth; + this.analytics = analytics; + this.mcpRelayExecutor = mcpRelayExecutor; + this.streamFetch = streamFetch; + this.log = logger.scope("cloud-task"); + } + + /** + * Relay-designated server names per run (docs/cloud-mcp-relay.md). + * In-memory by design: only the client that created a run in this app + * session may execute relay requests for it; requests for undesignated + * runs or names are dropped. + */ + private readonly relayDesignations = new Map>(); + /** requestId dedupe — the event stream is at-least-once and replays on reconnect. */ + private readonly handledRelayRequestIds = new Set(); + private readonly handledRelayRequestOrder: string[] = []; + + /** Sensitive relay requests require desktop-owned approval. */ + private readonly relayAlwaysApprovals = new Set(); + /** Desktop-issued relay approval prompts awaiting a task-view answer. */ + private readonly pendingLocalRelayPrompts = new Map< + string, + { + runId: string; + resolve: (outcome: { + optionId: string | null; + customInput?: string; + }) => void; + } + >(); + + designateRelayedMcpServers(runId: string, servers: string[]): void { + if (servers.length === 0) return; + this.relayDesignations.set(runId, new Set(servers)); + this.log.info("Designated relayed MCP servers for run", { + runId, + servers, + }); + } + + private markRelayRequestHandled(requestId: string): void { + this.handledRelayRequestIds.add(requestId); + this.handledRelayRequestOrder.push(requestId); + if (this.handledRelayRequestOrder.length > MAX_HANDLED_RELAY_REQUEST_IDS) { + const evicted = this.handledRelayRequestOrder.shift(); + if (evicted) this.handledRelayRequestIds.delete(evicted); + } + } + + private async handleMcpRelayRequest( + watcher: WatcherState, + data: McpRequestEventData, + ): Promise { + if (!this.mcpRelayExecutor) return; + const designated = this.relayDesignations.get(watcher.runId); + if (!designated?.has(data.server)) { + // Not created by this client, or a name the run never declared. + return; + } + if (this.handledRelayRequestIds.has(data.requestId)) return; + this.markRelayRequestHandled(data.requestId); + + const expiresAt = Date.parse(data.expiresAt); + if (this.relayRequestExpired(expiresAt)) { + this.log.info("Dropping expired MCP relay request", { + runId: watcher.runId, + server: data.server, + requestId: data.requestId, + }); + return; + } + + const approvalRequest = relayApprovalRequest( + watcher.runId, + data.server, + data.payload, + ); + if (approvalRequest) { + const approval = await this.ensureRelayRequestApproval( + watcher, + approvalRequest, + expiresAt, + ); + if (!approval.approved) { + // Expired prompts get no response: the sandbox has already timed the + // request out, and a late mcp_response would be rejected as unknown. + if (!approval.expired) { + await this.sendRelayResponse(watcher, data, { + error: { code: -32000, message: approval.message }, + }); + } + return; + } + if (this.relayRequestExpired(expiresAt)) return; + } + + let execution: { + payload?: Record; + error?: { code: number; message: string }; + }; + try { + execution = await this.mcpRelayExecutor.execute( + watcher.runId, + data.server, + data.payload, + ); + } catch (error) { + execution = { + error: { + code: -32000, + message: + error instanceof Error + ? error.message + : "MCP relay execution failed", + }, + }; + } + + // Fire-and-forget notifications produce no response payload or error. + if (!execution.payload && !execution.error) return; + + await this.sendRelayResponse(watcher, data, execution); + } + + private relayRequestExpired(expiresAt: number): boolean { + return Number.isFinite(expiresAt) && expiresAt < Date.now(); + } + + private async sendRelayResponse( + watcher: WatcherState, + data: McpRequestEventData, + execution: { + payload?: Record; + error?: { code: number; message: string }; + }, + ): Promise { + try { + await this.sendCommand({ + taskId: watcher.taskId, + runId: watcher.runId, + apiHost: watcher.apiHost, + teamId: watcher.teamId, + method: "mcp_response", + params: { + requestId: data.requestId, + server: data.server, + ...(execution.payload + ? { payload: execution.payload } + : { error: execution.error }), + }, + }); + } catch (error) { + // The sandbox times the request out on its own; nothing to unwind here. + this.log.warn("Failed to deliver mcp_response command", { + runId: watcher.runId, + requestId: data.requestId, + error: serializeError(error), + }); + } + } + + private async ensureRelayRequestApproval( + watcher: WatcherState, + request: RelayApprovalRequest, + expiresAt: number, + ): Promise< + { approved: true } | { approved: false; expired: boolean; message: string } + > { + const { runId } = watcher; + if (this.relayAlwaysApprovals.has(request.approvalKey)) { + return { approved: true }; + } + + const requestId = `${RELAY_APPROVAL_REQUEST_PREFIX}${globalThis.crypto.randomUUID()}`; + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId, + kind: "permission_request" as const, + requestId, + toolCall: { + toolCallId: requestId, + title: request.title, + kind: "other", + rawInput: request.rawInput, + _meta: posthogToolMeta({ + toolName: request.toolName, + mcp: request.mcp, + }), + }, + options: [ + { kind: "allow_once", name: "Yes", optionId: "allow" }, + { + kind: "allow_always", + name: "Yes, always allow", + optionId: "allow_always", + }, + { + kind: "reject_once", + name: "Type here to tell the agent what to do differently", + optionId: "reject", + _meta: { customInput: true }, + }, + ], + }); + + const outcome = await new Promise<{ + optionId: string | null; + customInput?: string; + }>((resolve) => { + this.pendingLocalRelayPrompts.set(requestId, { runId, resolve }); + // The sandbox abandons the request at expiresAt; keep waiting any longer + // and an approval would execute a call whose result nothing consumes. + const waitMs = Number.isFinite(expiresAt) + ? Math.max(0, expiresAt - Date.now()) + : 60_000; + const timer = setTimeout(() => { + if (this.pendingLocalRelayPrompts.delete(requestId)) { + resolve({ optionId: null }); + } + }, waitMs); + timer.unref?.(); + }); + + if (outcome.optionId === "allow_always") { + this.relayAlwaysApprovals.add(request.approvalKey); + return { approved: true }; + } + if (outcome.optionId === "allow") return { approved: true }; + if (outcome.optionId === null) { + return { + approved: false, + expired: true, + message: "The user did not respond in time.", + }; + } + return { + approved: false, + expired: false, + message: outcome.customInput + ? `The user denied this MCP request: ${outcome.customInput}` + : "The user denied this MCP request.", + }; + } + + /** Drop a terminal run's relay approval state and abandon its open prompts. */ + private evictRelayApprovalState(runId: string): void { + const prefix = `${runId}${RELAY_KEY_SEPARATOR}`; + for (const key of [...this.relayAlwaysApprovals]) { + if (key.startsWith(prefix)) this.relayAlwaysApprovals.delete(key); + } + for (const [requestId, prompt] of [...this.pendingLocalRelayPrompts]) { + if (prompt.runId !== runId) continue; + this.pendingLocalRelayPrompts.delete(requestId); + prompt.resolve({ optionId: null }); + } + } + + watch(input: WatchInput): void { + const key = watcherKey(input.taskId, input.runId); + + const existing = this.watchers.get(key); + if (existing) { + existing.subscriberCount++; + this.log.info("Cloud task watcher subscriber added", { + key, + subscribers: existing.subscriberCount, + }); + void this.emitCurrentSnapshot(key); + return; + } + + this.startWatcher(input, 1); + } + + unwatch(taskId: string, runId: string): void { + const key = watcherKey(taskId, runId); + const watcher = this.watchers.get(key); + if (!watcher) { + return; + } + + watcher.subscriberCount--; + if (watcher.subscriberCount <= 0) { + this.stopWatcher(key); + } else { + this.log.info("Cloud task watcher subscriber removed", { + key, + subscribers: watcher.subscriberCount, + }); + } + } + + async retry(taskId: string, runId: string): Promise { + const key = watcherKey(taskId, runId); + const watcher = this.watchers.get(key); + if (!watcher) return; + + if (watcher.reconnectTimeoutId) { + clearTimeout(watcher.reconnectTimeoutId); + watcher.reconnectTimeoutId = null; + } + + watcher.sseAbortController?.abort(); + watcher.sseAbortController = null; + + if (watcher.batchFlushTimeoutId) { + clearTimeout(watcher.batchFlushTimeoutId); + watcher.batchFlushTimeoutId = null; + } + + this.log.info("Retrying cloud task watcher", { + key, + hasSnapshot: watcher.hasEmittedSnapshot, + }); + + // Start over from scratch: a poisoned resume position loops straight back into the same + // failure, so re-bootstrap to re-resolve the read leg and emit a fresh snapshot. + this.resetWatcherForRebootstrap(watcher); + void this.bootstrapWatcher(key); + } + + reconnectIfDisconnected(taskId: string, runId: string): void { + const key = watcherKey(taskId, runId); + const watcher = this.watchers.get(key); + if ( + !watcher || + watcher.sseAbortController || + watcher.reconnectTimeoutId || + watcher.isBootstrapping || + isTerminalStatus(watcher.lastStatus) + ) { + return; + } + + void this.connectSse(key); + } + + // Resets a watcher to its pre-bootstrap state so bootstrapWatcher can rebuild it from server truth. + private resetWatcherForRebootstrap(watcher: WatcherState): void { + watcher.reconnectAttempts = 0; + watcher.streamErrorAttempts = 0; + watcher.cumulativeReconnectAttempts = 0; + watcher.failed = false; + watcher.pendingLogEntries = []; + watcher.bufferedLogBatches = []; + watcher.needsPostBootstrapReconnect = false; + watcher.needsStopAfterBootstrap = false; + watcher.streamEnded = false; + watcher.selfHealAttempted = false; + watcher.lastEventId = null; + watcher.lastEventIdLeg = null; + watcher.streamLeg = null; + // The rebuild re-resolves the read leg, so a retained id could false-match a + // different entry on the next connection — and the leg-switch clear in + // connectSse can't catch it, since lastEventId was just nulled. The re-fetched + // snapshot re-delivers history, so no dedup state is lost. + watcher.seenEventIds.clear(); + watcher.totalEntryCount = 0; + watcher.isBootstrapping = false; + watcher.streamTargetResolved = false; + watcher.streamBaseUrl = null; + watcher.streamReadToken = null; + watcher.durableStreamEnabled = false; + } + + async sendCommand(input: SendCommandInput): Promise { + if (input.method === "permission_response") { + const params = input.params ?? {}; + const requestId = + typeof params.requestId === "string" ? params.requestId : null; + if (requestId?.startsWith(RELAY_APPROVAL_REQUEST_PREFIX)) { + // A desktop-issued relay approval: resolve it locally — the sandbox + // never saw this prompt, so there is nothing to POST. + const pending = this.pendingLocalRelayPrompts.get(requestId); + this.pendingLocalRelayPrompts.delete(requestId); + pending?.resolve({ + optionId: + typeof params.optionId === "string" ? params.optionId : null, + customInput: + typeof params.customInput === "string" + ? params.customInput + : undefined, + }); + return { success: true }; + } + } + + const url = `${input.apiHost}/api/projects/${input.teamId}/tasks/${input.taskId}/runs/${input.runId}/command/`; + const body = { + jsonrpc: "2.0", + method: input.method, + params: input.params ?? {}, + id: `posthog-code-${Date.now()}`, + }; + + try { + const response = await this.auth.authenticatedFetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + let errorMessage = `Command failed with status ${response.status}`; + try { + const errorJson = JSON.parse(errorText); + if (errorJson.error?.message) { + errorMessage = errorJson.error.message; + } else if (errorJson.error) { + errorMessage = + typeof errorJson.error === "string" + ? errorJson.error + : JSON.stringify(errorJson.error); + } + } catch { + if (errorText) errorMessage = errorText; + } + + this.log.warn("Cloud task command failed", { + taskId: input.taskId, + runId: input.runId, + method: input.method, + status: response.status, + error: errorMessage, + }); + return { success: false, error: errorMessage }; + } + + const data = (await response.json()) as { + error?: { message?: string }; + result?: unknown; + }; + + if (data.error) { + this.log.warn("Cloud task command returned error", { + taskId: input.taskId, + method: input.method, + error: data.error, + }); + return { + success: false, + error: data.error.message ?? JSON.stringify(data.error), + }; + } + + this.log.info("Cloud task command sent", { + taskId: input.taskId, + runId: input.runId, + method: input.method, + }); + + return { success: true, result: data.result }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + this.log.error("Cloud task command error", { + taskId: input.taskId, + method: input.method, + error: errorMessage, + }); + return { success: false, error: errorMessage }; + } + } + + async stop(input: StopInput): Promise { + try { + const context = await this.auth.getCloudContext(); + if (!context) { + return { success: false, error: "No active cloud project" }; + } + const url = `${context.apiHost}/api/projects/${context.teamId}/tasks/${input.taskId}/runs/${input.runId}/cancel/`; + const response = await this.auth.authenticatedFetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(input.reason ? { reason: input.reason } : {}), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + let errorMessage = `Stop failed with status ${response.status}`; + try { + const errorJson = JSON.parse(errorText) as { error?: unknown }; + if (typeof errorJson.error === "string" && errorJson.error) { + errorMessage = errorJson.error; + } + } catch { + if (errorText) errorMessage = errorText; + } + + this.log.warn("Cloud run stop failed", { + taskId: input.taskId, + runId: input.runId, + status: response.status, + error: errorMessage, + }); + return { + success: false, + error: errorMessage, + retryable: response.status === 503 || response.status >= 500, + }; + } + + const data = (await response.json()) as { status?: string }; + this.log.info("Cloud run stop accepted", { + taskId: input.taskId, + runId: input.runId, + runStatus: data.status, + }); + return { success: true, runStatus: data.status }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + this.log.error("Cloud run stop error", { + taskId: input.taskId, + runId: input.runId, + error: errorMessage, + }); + return { success: false, error: errorMessage, retryable: true }; + } + } + + unwatchAll(): void { + for (const key of [...this.watchers.keys()]) { + this.stopWatcher(key); + } + } + + private startWatcher(input: WatchInput, subscriberCount: number): void { + const key = watcherKey(input.taskId, input.runId); + + const watcher: WatcherState = { + taskId: input.taskId, + runId: input.runId, + apiHost: input.apiHost, + teamId: input.teamId, + subscriberCount, + sseAbortController: null, + reconnectTimeoutId: null, + batchFlushTimeoutId: null, + pendingLogEntries: [], + totalEntryCount: 0, + resumeFromEntryCount: input.resumeFromEntryCount ?? null, + reconnectAttempts: 0, + streamErrorAttempts: 0, + cumulativeReconnectAttempts: 0, + lastEventId: null, + lastEventIdLeg: null, + streamLeg: null, + seenEventIds: new Set(), + lastStatus: null, + lastStage: null, + lastOutput: null, + lastErrorMessage: null, + lastBranch: null, + lastSandboxAlive: null, + lastStatusUpdatedAt: null, + connStartedAt: 0, + connSentLastEventId: null, + connDataEventsReceived: 0, + isBootstrapping: false, + hasEmittedSnapshot: false, + bufferedLogBatches: [], + emittedLogEntries: [], + failed: false, + needsPostBootstrapReconnect: false, + needsStopAfterBootstrap: false, + streamEnded: false, + selfHealAttempted: false, + streamTargetResolved: false, + streamBaseUrl: null, + streamReadToken: null, + durableStreamEnabled: false, + }; + + this.watchers.set(key, watcher); + this.log.info("Cloud task watcher started", { key }); + void this.bootstrapWatcher(key); + } + + private stopWatcher(key: string): void { + const watcher = this.watchers.get(key); + if (!watcher) return; + + if (this.relayDesignations.has(watcher.runId)) { + // No watcher → no relay events → nothing executes; release the run's + // live server connections (stdio children included). They reopen + // lazily if the run is watched again. + void this.mcpRelayExecutor?.closeRun?.(watcher.runId).catch(() => {}); + } + + watcher.sseAbortController?.abort(); + + if (watcher.reconnectTimeoutId) { + clearTimeout(watcher.reconnectTimeoutId); + watcher.reconnectTimeoutId = null; + } + + if (watcher.batchFlushTimeoutId) { + clearTimeout(watcher.batchFlushTimeoutId); + watcher.batchFlushTimeoutId = null; + } + + this.flushLogBatch(key); + this.watchers.delete(key); + this.log.info("Cloud task watcher stopped", { key }); + } + + private async bootstrapWatcher(key: string): Promise { + const watcher = this.watchers.get(key); + if (!watcher) return; + + watcher.failed = false; + watcher.needsPostBootstrapReconnect = false; + watcher.needsStopAfterBootstrap = false; + + const run = await this.fetchTaskRun(watcher); + const currentWatcher = this.watchers.get(key); + if (!currentWatcher || currentWatcher !== watcher) return; + if (watcher.failed) return; + + if (!run) { + this.failWatcher(key, { + title: "Failed to load cloud run", + message: "Could not fetch the cloud run state. Retry to reconnect.", + retryable: true, + }); + return; + } + + this.applyTaskRunState(watcher, run); + + if ( + !isTerminalStatus(run.status) && + watcher.resumeFromEntryCount !== null + ) { + watcher.totalEntryCount = watcher.resumeFromEntryCount; + watcher.hasEmittedSnapshot = true; + watcher.isBootstrapping = false; + void this.connectSse(key, { startLatest: true }); + return; + } + + if (isTerminalStatus(run.status)) { + const historicalEntries = await this.fetchAllSessionLogs(watcher); + const terminalWatcher = this.watchers.get(key); + if (!terminalWatcher || terminalWatcher !== watcher) return; + if (watcher.failed) return; + if (!historicalEntries) { + this.failWatcher(key, { + title: "Failed to load task history", + message: + "Could not load the persisted cloud task logs. Retry to reconnect.", + retryable: true, + }); + return; + } + + watcher.totalEntryCount = historicalEntries.length; + watcher.hasEmittedSnapshot = true; + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "snapshot", + newEntries: historicalEntries, + totalEntryCount: watcher.totalEntryCount, + status: watcher.lastStatus ?? undefined, + stage: watcher.lastStage, + output: watcher.lastOutput, + errorMessage: watcher.lastErrorMessage, + branch: watcher.lastBranch, + ...sandboxAlivePayload(watcher), + }); + this.stopWatcher(key); + return; + } + + watcher.isBootstrapping = true; + watcher.bufferedLogBatches = []; + void this.connectSse(key, { startLatest: true }); + + const historicalEntries = await this.fetchAllSessionLogs(watcher); + const bootstrappingWatcher = this.watchers.get(key); + if (!bootstrappingWatcher || bootstrappingWatcher !== watcher) return; + if (watcher.failed) return; + if (!historicalEntries) { + this.failWatcher(key, { + title: "Failed to load cloud run history", + message: + "Could not load the existing cloud run logs. Retry to reconnect.", + retryable: true, + }); + return; + } + + // Flush any pending live entries into the bootstrap buffer before snapshot. + this.flushLogBatch(key); + + watcher.totalEntryCount = historicalEntries.length; + watcher.hasEmittedSnapshot = true; + + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "snapshot", + newEntries: historicalEntries, + totalEntryCount: watcher.totalEntryCount, + status: watcher.lastStatus ?? undefined, + stage: watcher.lastStage, + output: watcher.lastOutput, + errorMessage: watcher.lastErrorMessage, + branch: watcher.lastBranch, + ...sandboxAlivePayload(watcher), + }); + + watcher.isBootstrapping = false; + this.drainBufferedLogBatches(key, historicalEntries); + + if (watcher.failed) { + return; + } + + if (watcher.needsStopAfterBootstrap) { + watcher.needsStopAfterBootstrap = false; + await this.finalizeWatcherStop(key); + return; + } + + if (watcher.needsPostBootstrapReconnect) { + watcher.needsPostBootstrapReconnect = false; + this.scheduleReconnect(key, undefined, { countAttempt: false }); + } + + void this.verifyPostBootstrapStatus(key); + } + + private async verifyPostBootstrapStatus(key: string): Promise { + const watcher = this.watchers.get(key); + if (!watcher) return; + if (isTerminalStatus(watcher.lastStatus)) return; + + const run = await this.fetchTaskRun(watcher); + const currentWatcher = this.watchers.get(key); + if (!currentWatcher || currentWatcher !== watcher) return; + if (!run) return; + + if (!this.applyTaskRunState(watcher, run)) return; + if (isTerminalStatus(watcher.lastStatus)) return; + + this.emitStatusUpdate(watcher); + } + + private emitStatusUpdate(watcher: WatcherState): void { + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "status", + status: watcher.lastStatus ?? undefined, + stage: watcher.lastStage, + output: watcher.lastOutput, + errorMessage: watcher.lastErrorMessage, + branch: watcher.lastBranch, + ...sandboxAlivePayload(watcher), + }); + } + + private async connectSse( + key: string, + options?: { startLatest?: boolean }, + ): Promise { + const watcher = this.watchers.get(key); + if (!watcher) return; + + const controller = new AbortController(); + watcher.sseAbortController = controller; + + watcher.connStartedAt = 0; + watcher.connDataEventsReceived = 0; + + // Resolve the read target once (proxy URL + token, or Django), reused across reconnects. + if (!watcher.streamTargetResolved) { + await this.resolveStreamTarget(watcher); + const resolvedWatcher = this.watchers.get(key); + if ( + !resolvedWatcher || + resolvedWatcher !== watcher || + controller.signal.aborted + ) { + return; + } + } + + const usingProxy = Boolean( + watcher.streamBaseUrl && watcher.streamReadToken, + ); + const base = usingProxy + ? watcher.streamBaseUrl?.replace(/\/+$/, "") + : watcher.apiHost; + const leg: StreamLeg = usingProxy ? "proxy" : "django"; + // Proxy and Django id spaces are unrelated, so drop the resume position on a leg switch and + // let start=latest plus the next snapshot cover the gap. + if (watcher.lastEventId && watcher.lastEventIdLeg !== leg) { + this.log.info("Cloud task stream leg changed, dropping resume position", { + key, + from: watcher.lastEventIdLeg, + to: leg, + }); + watcher.lastEventId = null; + watcher.lastEventIdLeg = null; + // Proxy and Django ids are unrelated, so a retained id could false-match a + // different entry on the new leg. Drop them; the snapshot covers the gap. + watcher.seenEventIds.clear(); + } + watcher.streamLeg = leg; + + // Captured after the leg-switch drop so they reflect what this connection actually sends. + watcher.connSentLastEventId = watcher.lastEventId; + const startLatest = Boolean(options?.startLatest && !watcher.lastEventId); + const url = new URL( + usingProxy + ? `${base}/v1/runs/${encodeURIComponent(watcher.runId)}/stream` + : `${base}/api/projects/${watcher.teamId}/tasks/${encodeURIComponent( + watcher.taskId, + )}/runs/${encodeURIComponent(watcher.runId)}/stream/`, + ); + if (startLatest) { + url.searchParams.set("start", "latest"); + } + const headers: Record = { + Accept: "text/event-stream", + }; + if (watcher.lastEventId) { + headers["Last-Event-ID"] = watcher.lastEventId; + } + if (usingProxy) { + headers.Authorization = `Bearer ${watcher.streamReadToken}`; + } + + // Info so every stream attempt is visible in the logs; Bearer token redacted. + this.log.info(`Opening cloud task stream via ${leg}: ${url.toString()}`, { + key, + leg, + usingProxy, + durableStream: watcher.durableStreamEnabled, + method: "GET", + streamUrl: url.toString(), + lastEventId: watcher.lastEventId, + startLatest, + headers: usingProxy + ? { ...headers, Authorization: "Bearer " } + : headers, + }); + + const parser = new SseEventParser((message, data) => + this.log.warn(message, data), + ); + const decoder = new TextDecoder(); + + // Track how long the body stayed open so healthy long-lived connections cut by churn + // aren't penalized as failed reconnects (see SSE_HEALTHY_CONNECTION_MS). + let connectedAt = 0; + let streamWasEstablished = false; + let bytesReceived = 0; + let eventsReceived = 0; + + try { + // The proxy authenticates with the run-scoped Bearer token; the Django leg uses the session. + const response = usingProxy + ? await this.streamFetch(url.toString(), { + method: "GET", + headers, + signal: controller.signal, + }) + : await this.auth.authenticatedFetch(url.toString(), { + method: "GET", + headers, + signal: controller.signal, + }); + + this.log.info( + `Cloud task stream response ${response.status} ${ + response.ok ? "ok" : "FAILED" + } via ${leg}`, + { + key, + leg, + status: response.status, + ok: response.ok, + streamUrl: url.toString(), + }, + ); + + if (!response.ok) { + throw createStreamStatusError(response.status); + } + + if (!response.body) { + throw new Error("Stream response did not include a body"); + } + + connectedAt = Date.now(); + streamWasEstablished = true; + watcher.connStartedAt = connectedAt; + + this.log.info(`Cloud task SSE connected via ${leg}: ${url.toString()}`, { + key, + leg, + streamUrl: url.toString(), + sentLastEventId: watcher.connSentLastEventId, + startLatest, + status: response.status, + server: response.headers.get("server"), + via: response.headers.get("via"), + cfRay: response.headers.get("cf-ray"), + cfCacheStatus: response.headers.get("cf-cache-status"), + xAccelBuffering: response.headers.get("x-accel-buffering"), + contentType: response.headers.get("content-type"), + requestId: response.headers.get("x-request-id"), + }); + + const reader = response.body.getReader(); + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + if (!value) { + continue; + } + + bytesReceived += value.byteLength; + const chunk = decoder.decode(value, { stream: true }); + const events = parser.parse(chunk); + for (const event of events) { + eventsReceived += 1; + const backendError = this.handleSseEvent(key, event); + if (backendError) { + throw backendError; + } + } + } + + const trailingEvents = parser.parse(decoder.decode()); + for (const event of trailingEvents) { + const backendError = this.handleSseEvent(key, event); + if (backendError) { + throw backendError; + } + } + + this.flushLogBatch(key); + + if (controller.signal.aborted) { + return; + } + + this.log.info("Cloud task stream closed cleanly", { + key, + connectionDurationMs: Date.now() - connectedAt, + bytesReceived, + eventsReceived, + dataEventsReceived: watcher.connDataEventsReceived, + lastEventId: watcher.lastEventId, + }); + + // A long-lived clean close is healthy churn, not a loop: clear the cumulative budget so an + // idle run can ride out proxy timeout cycles, while instant-EOF loops still exhaust it. + const completedWatcher = this.watchers.get(key); + if ( + completedWatcher && + streamWasEstablished && + Date.now() - connectedAt >= SSE_HEALTHY_CONNECTION_MS + ) { + completedWatcher.cumulativeReconnectAttempts = 0; + completedWatcher.selfHealAttempted = false; + } + + await this.handleStreamCompletion(key, { reconnectOnDisconnect: true }); + } catch (error) { + this.flushLogBatch(key); + + if (controller.signal.aborted) { + return; + } + + // Proxy-leg 401: the read token expired or its signing key rotated. Re-resolve to mint a + // fresh token (or route back to Django) instead of failing. Django-leg 401 stays fatal below. + const unauthorizedWatcher = this.watchers.get(key); + if ( + error instanceof CloudTaskStreamError && + error.status === 401 && + unauthorizedWatcher?.streamBaseUrl + ) { + // Keep durableStreamEnabled set: clearing it would route this disconnect through legacy + // status polling, which can stop the watch on a terminal status before stream-end arrives. + // The next connectSse re-resolves the target and resolveStreamTarget re-derives durability. + unauthorizedWatcher.streamTargetResolved = false; + unauthorizedWatcher.streamBaseUrl = null; + unauthorizedWatcher.streamReadToken = null; + this.log.info("Cloud task stream proxy token rejected, re-resolving", { + key, + }); + await this.handleStreamCompletion(key, { + reconnectOnDisconnect: true, + reconnectError: error, + countReconnectAttempt: true, + }); + return; + } + + if ( + error instanceof CloudTaskStreamError && + error.details.autoRetry === false + ) { + this.failWatcher(key, error.details); + return; + } + + const errorMessage = + error instanceof Error ? error.message : "Unknown stream error"; + + const isBackendError = error instanceof BackendStreamError; + const wasHealthyStream = + !isBackendError && + streamWasEstablished && + Date.now() - connectedAt >= SSE_HEALTHY_CONNECTION_MS; + + const errorWatcher = this.watchers.get(key); + if (errorWatcher) { + if (isBackendError) { + errorWatcher.streamErrorAttempts += 1; + } else if (wasHealthyStream) { + errorWatcher.streamErrorAttempts = 0; + // A healthy-length connection proves timeout cycling, not a loop. + errorWatcher.cumulativeReconnectAttempts = 0; + errorWatcher.selfHealAttempted = false; + } + } + + this.log.warn("Cloud task stream error", { + key, + leg, + streamUrl: url.toString(), + error: errorMessage, + errorDetail: serializeError(error), + wasHealthyStream, + isBackendError, + streamWasEstablished, + connectionDurationMs: streamWasEstablished + ? Date.now() - connectedAt + : 0, + bytesReceived, + eventsReceived, + dataEventsReceived: errorWatcher?.connDataEventsReceived ?? 0, + lastEventId: errorWatcher?.lastEventId ?? null, + reconnectAttempts: errorWatcher?.reconnectAttempts ?? 0, + streamErrorAttempts: errorWatcher?.streamErrorAttempts ?? 0, + cumulativeReconnectAttempts: + errorWatcher?.cumulativeReconnectAttempts ?? 0, + }); + await this.handleStreamCompletion(key, { + reconnectOnDisconnect: true, + reconnectError: error, + countReconnectAttempt: !isBackendError && !wasHealthyStream, + }); + } finally { + const currentWatcher = this.watchers.get(key); + if (currentWatcher?.sseAbortController === controller) { + currentWatcher.sseAbortController = null; + } + } + } + + // Returns a BackendStreamError when the stream carries an error event so the caller can throw at + // the read site; returns null otherwise. It does not throw, so a single event cannot unwind the + // reader loop unexpectedly. + private handleSseEvent( + key: string, + event: SseEvent, + ): BackendStreamError | null { + const watcher = this.watchers.get(key); + if (!watcher || watcher.failed) return null; + + if (event.id) { + watcher.lastEventId = event.id; + watcher.lastEventIdLeg = watcher.streamLeg; + } + + if (event.event === "error") { + const message = isSseErrorEvent(event.data) + ? event.data.error + : "Unknown stream error"; + return new BackendStreamError(message); + } + + if (event.event === STREAM_END_EVENT_NAME) { + // The run's stream is durably complete. Mark it so completion stops instead + // of reconnecting, independent of run status. The connection will close + // naturally (clean EOF) right after this sentinel. + watcher.streamEnded = true; + return null; + } + + // A keepalive or real event proves the transport recovered. A keepalive does not clear the + // backend-error budget, which only a real data event below resets. + watcher.reconnectAttempts = 0; + + if (isKeepaliveEvent(event)) { + return null; + } + + // A real data event proves the stream materialized; clear the remaining budgets and re-arm self-heal. + watcher.streamErrorAttempts = 0; + watcher.cumulativeReconnectAttempts = 0; + watcher.selfHealAttempted = false; + + watcher.connDataEventsReceived += 1; + if (watcher.connDataEventsReceived === 1 && watcher.connSentLastEventId) { + this.log.info("Cloud task SSE resumed", { + key, + resumedFrom: watcher.connSentLastEventId, + firstEventIdAfterResume: event.id ?? null, + }); + } + + if (isTaskRunStateEvent(event.data)) { + if (this.applyTaskRunState(watcher, event.data)) { + if (!watcher.isBootstrapping && !isTerminalStatus(watcher.lastStatus)) { + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "status", + status: watcher.lastStatus ?? undefined, + stage: watcher.lastStage, + output: watcher.lastOutput, + errorMessage: watcher.lastErrorMessage, + branch: watcher.lastBranch, + ...sandboxAlivePayload(watcher), + }); + } + } + return null; + } + + // Drop a re-delivered event by its stream id. The durable stream re-sends + // the tail on reconnect/replay: each re-sent log entry would otherwise be + // counted as a new entry (advancing totalEntryCount past the renderer's + // processedLineCount guard) and emitted again — the root cause of duplicate + // transcript entries and back-to-back completion notifications — and a + // re-sent permission_request frame would re-surface an already-answered + // question as a fresh pending card. Events without an id (legacy servers) + // fall through and are handled downstream. + const eventId = event.id; + if (eventId !== undefined) { + if (watcher.seenEventIds.has(eventId)) { + return null; + } + watcher.seenEventIds.add(eventId); + } + + if (isMcpRequestEvent(event.data)) { + void this.handleMcpRelayRequest(watcher, event.data); + return null; + } + + if (isPermissionRequestEvent(event.data)) { + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "permission_request" as const, + requestId: event.data.requestId, + toolCall: event.data.toolCall, + options: event.data.options, + }); + return null; + } + + watcher.pendingLogEntries.push(event.data as StoredLogEntry); + if (watcher.pendingLogEntries.length >= EVENT_BATCH_MAX_SIZE) { + this.flushLogBatch(key); + return null; + } + + if (!watcher.batchFlushTimeoutId) { + watcher.batchFlushTimeoutId = setTimeout(() => { + watcher.batchFlushTimeoutId = null; + this.flushLogBatch(key); + }, EVENT_BATCH_FLUSH_MS); + } + + return null; + } + + private flushLogBatch(key: string): void { + const watcher = this.watchers.get(key); + if (!watcher || watcher.pendingLogEntries.length === 0) return; + + if (watcher.batchFlushTimeoutId) { + clearTimeout(watcher.batchFlushTimeoutId); + watcher.batchFlushTimeoutId = null; + } + + const entries = watcher.pendingLogEntries; + watcher.pendingLogEntries = []; + + if (watcher.isBootstrapping) { + watcher.bufferedLogBatches.push(entries); + return; + } + + watcher.totalEntryCount += entries.length; + this.rememberEmittedLogEntries(watcher, entries); + + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "logs", + newEntries: entries, + totalEntryCount: watcher.totalEntryCount, + }); + } + + private drainBufferedLogBatches( + key: string, + historicalEntries: StoredLogEntry[], + ): void { + const watcher = this.watchers.get(key); + if (!watcher || watcher.bufferedLogBatches.length === 0) return; + + const historicalCounts = buildEntryFrequencyMap(historicalEntries); + + for (const entries of watcher.bufferedLogBatches) { + const dedupedEntries = filterEntriesNotInFrequencyMap( + entries, + historicalCounts, + ); + + if (dedupedEntries.length === 0) { + continue; + } + + watcher.totalEntryCount += dedupedEntries.length; + this.rememberEmittedLogEntries(watcher, dedupedEntries); + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "logs", + newEntries: dedupedEntries, + totalEntryCount: watcher.totalEntryCount, + }); + } + + watcher.bufferedLogBatches = []; + } + + private rememberEmittedLogEntries( + watcher: WatcherState, + entries: StoredLogEntry[], + ): void { + watcher.emittedLogEntries.push(...entries); + } + + private mergeHistoricalAndEmittedEntries( + historicalEntries: StoredLogEntry[], + emittedEntries: StoredLogEntry[], + ): { + snapshotEntries: StoredLogEntry[]; + missingEmittedEntries: StoredLogEntry[]; + } { + if (emittedEntries.length === 0) { + return { snapshotEntries: historicalEntries, missingEmittedEntries: [] }; + } + + const historicalCounts = buildEntryFrequencyMap(historicalEntries); + const missingEmittedEntries = filterEntriesNotInFrequencyMap( + emittedEntries, + historicalCounts, + ); + + return { + snapshotEntries: [...historicalEntries, ...missingEmittedEntries], + missingEmittedEntries, + }; + } + + private async emitCurrentSnapshot(key: string): Promise { + const watcher = this.watchers.get(key); + if (!watcher || watcher.failed) return; + + const historicalEntries = await this.fetchAllSessionLogs(watcher); + const currentWatcher = this.watchers.get(key); + if (!currentWatcher || currentWatcher !== watcher || watcher.failed) { + return; + } + + if (!historicalEntries) { + this.log.warn("Cloud task snapshot replay failed", { + taskId: watcher.taskId, + runId: watcher.runId, + }); + return; + } + + const { snapshotEntries, missingEmittedEntries } = + this.mergeHistoricalAndEmittedEntries( + historicalEntries, + watcher.emittedLogEntries, + ); + watcher.emittedLogEntries = missingEmittedEntries; + if (snapshotEntries.length > watcher.totalEntryCount) { + watcher.totalEntryCount = snapshotEntries.length; + } + + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "snapshot", + newEntries: snapshotEntries, + totalEntryCount: snapshotEntries.length, + status: watcher.lastStatus ?? undefined, + stage: watcher.lastStage, + output: watcher.lastOutput, + errorMessage: watcher.lastErrorMessage, + branch: watcher.lastBranch, + ...sandboxAlivePayload(watcher), + }); + } + + private failWatcher(key: string, error: CloudTaskConnectionError): void { + const watcher = this.watchers.get(key); + if (!watcher) return; + + this.log.warn("Cloud task watcher failed", { + key, + errorTitle: error.title, + retryable: error.retryable, + status: watcher.lastStatus, + wasBootstrapping: watcher.isBootstrapping, + reconnectAttempts: watcher.reconnectAttempts, + cumulativeReconnectAttempts: watcher.cumulativeReconnectAttempts, + totalEntryCount: watcher.totalEntryCount, + lastEventId: watcher.lastEventId, + }); + + this.analytics.track(ANALYTICS_EVENTS.CLOUD_STREAM_DISCONNECTED, { + task_id: watcher.taskId, + run_id: watcher.runId, + team_id: watcher.teamId, + error_title: error.title, + retryable: error.retryable, + reconnect_attempts: watcher.reconnectAttempts, + stream_error_attempts: watcher.streamErrorAttempts, + cumulative_reconnect_attempts: watcher.cumulativeReconnectAttempts, + was_bootstrapping: watcher.isBootstrapping, + }); + + watcher.failed = true; + watcher.isBootstrapping = false; + watcher.pendingLogEntries = []; + watcher.bufferedLogBatches = []; + + if (watcher.reconnectTimeoutId) { + clearTimeout(watcher.reconnectTimeoutId); + watcher.reconnectTimeoutId = null; + } + + if (watcher.batchFlushTimeoutId) { + clearTimeout(watcher.batchFlushTimeoutId); + watcher.batchFlushTimeoutId = null; + } + + watcher.sseAbortController?.abort(); + watcher.sseAbortController = null; + + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "error", + errorTitle: error.title, + errorMessage: error.message, + retryable: error.retryable, + }); + } + + private scheduleReconnect( + key: string, + error?: unknown, + options: { countAttempt?: boolean } = {}, + ): void { + const watcher = this.watchers.get(key); + // Status-unaware: the loop only stops on the stream-end sentinel or budget exhaustion below. + if (!watcher || watcher.failed) { + return; + } + + if (watcher.reconnectTimeoutId) { + clearTimeout(watcher.reconnectTimeoutId); + } + + // Bounds runaway loops that clean-EOF (countAttempt=false) and dodge reconnectAttempts. + watcher.cumulativeReconnectAttempts += 1; + const countAttempt = options.countAttempt ?? true; + if (countAttempt) { + watcher.reconnectAttempts += 1; + } + + if ( + watcher.cumulativeReconnectAttempts > MAX_CUMULATIVE_RECONNECT_ATTEMPTS + ) { + // A poisoned resume position burns the budget without an error frame. Rebuild once from + // scratch (the app-restart recovery) before failing; if it loops straight back, fail for real. + if (!watcher.selfHealAttempted) { + watcher.reconnectTimeoutId = null; + this.log.warn( + "Cloud task stream looping without events, re-bootstrapping", + { key }, + ); + this.resetWatcherForRebootstrap(watcher); + // Set after the reset (which clears it): consumes the single allowed self-heal so a + // straight-back loop fails next time instead of re-bootstrapping forever. + watcher.selfHealAttempted = true; + void this.bootstrapWatcher(key); + return; + } + this.failWatcher(key, { + title: "Cloud run unreachable", + message: + "Could not maintain a connection to the cloud run after many attempts. Click retry once the issue is resolved.", + retryable: true, + }); + return; + } + + // Fail once either budget (transport reconnect or backend stream-error) is exhausted. + const attemptCount = Math.max( + watcher.reconnectAttempts, + watcher.streamErrorAttempts, + ); + if (attemptCount > MAX_SSE_RECONNECT_ATTEMPTS) { + const details = + error instanceof CloudTaskStreamError + ? error.details + : { + title: "Cloud stream disconnected", + message: + "Lost connection to the cloud run stream. Retry to reconnect.", + retryable: true, + }; + this.failWatcher(key, details); + return; + } + + const backoffAttempts = + error instanceof BackendStreamError + ? watcher.streamErrorAttempts + : watcher.reconnectAttempts; + const delay = Math.min( + SSE_RECONNECT_BASE_DELAY_MS * + 2 ** Math.max(backoffAttempts - SSE_RECONNECT_FLAT_ATTEMPTS, 0), + SSE_RECONNECT_MAX_DELAY_MS, + ); + + watcher.reconnectTimeoutId = setTimeout(() => { + const currentWatcher = this.watchers.get(key); + if (!currentWatcher) return; + currentWatcher.reconnectTimeoutId = null; + void this.connectSse(key, { + startLatest: + currentWatcher.isBootstrapping || currentWatcher.hasEmittedSnapshot, + }); + }, delay); + } + + private async handleStreamCompletion( + key: string, + options: { + reconnectOnDisconnect: boolean; + reconnectError?: unknown; + countReconnectAttempt?: boolean; + }, + ): Promise { + const watcher = this.watchers.get(key); + if (!watcher) return; + if (watcher.failed) return; + + const { reconnectOnDisconnect } = options; + + // Bootstrap owns the snapshot lifecycle: stopping mid-bootstrap would discard the backlog and + // buffered live entries. Record intent and let bootstrap finish. + if (watcher.isBootstrapping) { + if (watcher.streamEnded || !reconnectOnDisconnect) { + watcher.needsStopAfterBootstrap = true; + } else { + watcher.needsPostBootstrapReconnect = true; + } + return; + } + + // The stream-end sentinel is the only signal that ends a durable watch. Any disconnect without + // it is transport churn to reconnect through; status is tracked for display only, never to stop. + if (watcher.streamEnded) { + await this.finalizeWatcherStop(key); + return; + } + + // Legacy mode (old server): no sentinel, so poll run status on disconnect to decide stop vs + // reconnect. The reconnect budgets keep the new semantics, so self-heal stays active here too. + if (!watcher.durableStreamEnabled && reconnectOnDisconnect) { + const run = await this.fetchTaskRun(watcher); + const legacyWatcher = this.watchers.get(key); + if (!legacyWatcher || legacyWatcher !== watcher) return; + if (watcher.failed) return; + + if (run) { + this.applyTaskRunState(watcher, run); + } + if (isTerminalStatus(watcher.lastStatus)) { + this.emitStatusUpdate(watcher); + this.stopWatcher(key); + return; + } + if (run) { + this.emitStatusUpdate(watcher); + } + this.scheduleReconnect(key, options.reconnectError, { + countAttempt: options.countReconnectAttempt ?? false, + }); + return; + } + + // All callers pass reconnectOnDisconnect, and durable watches only stop via the stream-end + // sentinel or a terminal legacy poll (both handled above); any other disconnect reconnects. + if (reconnectOnDisconnect) { + this.scheduleReconnect(key, options.reconnectError, { + countAttempt: options.countReconnectAttempt ?? false, + }); + } + } + + // Stops a watcher whose stream is durably complete. Repairs the displayed status if the stream + // ended non-terminal (dropped final frame); the poll never decides whether to stop. + private async finalizeWatcherStop(key: string): Promise { + const watcher = this.watchers.get(key); + if (!watcher) return; + + if (!isTerminalStatus(watcher.lastStatus)) { + const run = await this.fetchTaskRun(watcher); + const currentWatcher = this.watchers.get(key); + if (!currentWatcher || currentWatcher !== watcher) return; + if (run) { + this.applyTaskRunState(watcher, run); + } + } + + this.emitStatusUpdate(watcher); + this.stopWatcher(key); + } + + private applyTaskRunState( + watcher: WatcherState, + run: + | Pick< + TaskRunResponse, + | "status" + | "stage" + | "output" + | "state" + | "error_message" + | "branch" + | "updated_at" + > + | TaskRunStateEvent, + ): boolean { + const updatedAt = run.updated_at ?? null; + if ( + updatedAt && + watcher.lastStatusUpdatedAt && + Date.parse(updatedAt) <= Date.parse(watcher.lastStatusUpdatedAt) + ) { + return false; + } + + const nextStatus = run.status ?? watcher.lastStatus; + const nextStage = run.stage ?? null; + const nextOutput = run.output ?? null; + const nextErrorMessage = run.error_message ?? null; + const nextBranch = run.branch ?? null; + const sandboxAlive = extractSandboxAlive(run.state); + const nextSandboxAlive = + sandboxAlive === undefined ? watcher.lastSandboxAlive : sandboxAlive; + + const changed = + nextStatus !== watcher.lastStatus || + nextStage !== watcher.lastStage || + JSON.stringify(nextOutput) !== JSON.stringify(watcher.lastOutput) || + nextErrorMessage !== watcher.lastErrorMessage || + nextBranch !== watcher.lastBranch || + nextSandboxAlive !== watcher.lastSandboxAlive; + + watcher.lastStatus = nextStatus ?? null; + watcher.lastStage = nextStage; + watcher.lastOutput = nextOutput; + watcher.lastErrorMessage = nextErrorMessage; + watcher.lastBranch = nextBranch; + watcher.lastSandboxAlive = nextSandboxAlive; + if (updatedAt) { + watcher.lastStatusUpdatedAt = updatedAt; + } + + // A terminal run gets no further relay requests; drop its designation and + // approval state so the maps don't grow for the lifetime of the app session. + if (isTerminalStatus(watcher.lastStatus)) { + this.relayDesignations.delete(watcher.runId); + this.evictRelayApprovalState(watcher.runId); + } + + return changed; + } + + private async fetchSessionLogsPage( + watcher: WatcherState, + offset: number, + ): Promise { + const url = new URL( + `${watcher.apiHost}/api/projects/${watcher.teamId}/tasks/${watcher.taskId}/runs/${watcher.runId}/session_logs/`, + ); + url.searchParams.set("limit", SESSION_LOG_PAGE_LIMIT.toString()); + url.searchParams.set("offset", offset.toString()); + + try { + const authedResponse = await this.auth.authenticatedFetch( + url.toString(), + { + method: "GET", + }, + ); + + if (!authedResponse.ok) { + this.log.warn("Cloud task session logs fetch failed", { + status: authedResponse.status, + taskId: watcher.taskId, + runId: watcher.runId, + offset, + }); + if (shouldFailWatcherForFetchStatus(authedResponse.status)) { + this.failWatcher( + watcherKey(watcher.taskId, watcher.runId), + createStreamStatusError(authedResponse.status).details, + ); + } + return null; + } + + const raw = await authedResponse.text(); + return { + entries: JSON.parse(raw) as StoredLogEntry[], + hasMore: authedResponse.headers.get("X-Has-More") === "true", + }; + } catch (error) { + this.log.warn("Cloud task session logs fetch error", { + taskId: watcher.taskId, + runId: watcher.runId, + offset, + error, + }); + return null; + } + } + + private async fetchAllSessionLogs( + watcher: WatcherState, + ): Promise { + const entries: StoredLogEntry[] = []; + let offset = 0; + + while (true) { + const page = await this.fetchSessionLogsPage(watcher, offset); + if (!page) { + return null; + } + + entries.push(...page.entries); + if (!page.hasMore || page.entries.length === 0) { + return entries; + } + + offset += page.entries.length; + } + } + + private async resolveStreamTarget(watcher: WatcherState): Promise { + const url = `${watcher.apiHost}/api/projects/${watcher.teamId}/tasks/${watcher.taskId}/runs/${watcher.runId}/stream_token/`; + try { + const response = await this.auth.authenticatedFetch(url, { + method: "GET", + }); + if (!response.ok) { + watcher.streamBaseUrl = null; + watcher.streamReadToken = null; + if (isTransientStreamTargetStatus(response.status)) { + // Transient: read from Django this round but leave the target unresolved so the next + // reconnect retries durable resolution instead of pinning the run to status polling. + this.log.warn("Cloud task stream target temporarily unavailable", { + taskId: watcher.taskId, + runId: watcher.runId, + status: response.status, + }); + return; + } + // Refused, or an old server without the endpoint: read from Django with status polling. + watcher.durableStreamEnabled = false; + watcher.streamTargetResolved = true; + this.log.info("Cloud task stream reading from API host", { + taskId: watcher.taskId, + runId: watcher.runId, + status: response.status, + }); + return; + } + const data = (await response.json()) as { + token?: string; + stream_base_url?: string | null; + }; + watcher.streamReadToken = data.token ?? null; + watcher.streamBaseUrl = data.stream_base_url ?? null; + // The endpoint resolving at all opts this watcher into the status-unaware contract; + // old servers 404 above and stay on legacy status polling. + watcher.durableStreamEnabled = true; + watcher.streamTargetResolved = true; + this.log.info("Cloud task stream target resolved", { + taskId: watcher.taskId, + runId: watcher.runId, + streamBaseUrl: watcher.streamBaseUrl, + hasToken: Boolean(watcher.streamReadToken), + durableStream: watcher.durableStreamEnabled, + }); + } catch (error) { + // Transient failure: leave unresolved so the next reconnect retries and falls back to Django. + watcher.streamBaseUrl = null; + watcher.streamReadToken = null; + this.log.warn("Cloud task stream target resolution failed", { + taskId: watcher.taskId, + runId: watcher.runId, + error, + }); + } + } + + private async fetchTaskRun( + watcher: WatcherState, + ): Promise { + const url = `${watcher.apiHost}/api/projects/${watcher.teamId}/tasks/${watcher.taskId}/runs/${watcher.runId}/`; + + try { + const authedResponse = await this.auth.authenticatedFetch(url, { + method: "GET", + }); + + if (!authedResponse.ok) { + this.log.warn("Cloud task status fetch failed", { + status: authedResponse.status, + taskId: watcher.taskId, + runId: watcher.runId, + }); + if (shouldFailWatcherForFetchStatus(authedResponse.status)) { + this.failWatcher( + watcherKey(watcher.taskId, watcher.runId), + createStreamStatusError(authedResponse.status).details, + ); + } + return null; + } + + return (await authedResponse.json()) as TaskRunResponse; + } catch (error) { + this.log.warn("Cloud task status fetch error", { + taskId: watcher.taskId, + runId: watcher.runId, + error, + }); + return null; + } + } +} diff --git a/packages/core/src/cloud-task/cloud-task-service.test.ts b/packages/core/src/cloud-task/cloud-task-service.test.ts new file mode 100644 index 0000000000..644f9dd7cb --- /dev/null +++ b/packages/core/src/cloud-task/cloud-task-service.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { CloudTaskService } from "./cloud-task"; +import { CloudTaskEngine } from "./cloud-task-engine"; + +describe("CloudTaskService", () => { + it("preserves the injectable service API as a thin engine wrapper", () => { + const scopedLog = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const service = new CloudTaskService( + { + authenticatedFetch: vi.fn(), + getCloudContext: vi.fn(), + }, + { track: vi.fn() } as never, + { ...scopedLog, scope: vi.fn(() => scopedLog) }, + ); + + expect(service).toBeInstanceOf(CloudTaskEngine); + expect(service.watch).toBeTypeOf("function"); + expect(service.retry).toBeTypeOf("function"); + expect(service.unwatchAll).toBeTypeOf("function"); + }); +}); diff --git a/packages/core/src/cloud-task/cloud-task.test.ts b/packages/core/src/cloud-task/cloud-task.test.ts index a7fbf37c66..4178bd3d78 100644 --- a/packages/core/src/cloud-task/cloud-task.test.ts +++ b/packages/core/src/cloud-task/cloud-task.test.ts @@ -5,14 +5,17 @@ const mockNetFetch = vi.hoisted(() => vi.fn()); const mockStreamFetch = vi.hoisted(() => vi.fn()); const mockStreamTokenFetch = vi.hoisted(() => vi.fn()); -// The service now uses global fetch for BOTH authenticated API calls (JSON) -// and SSE streaming. The two used to be distinct (net.fetch vs global fetch). // Route by URL: /stream_token/ → token mock (read-leg resolution), the stream leg // (Django /stream/ or proxy /v1/runs/:run/stream) → stream mock, everything else → API mock. // The token mock has a Django-path default so existing fixtures (which never set it) are untouched. const fetchRouter = vi.hoisted(() => - vi.fn((input: string | Request, init?: RequestInit) => { - const url = typeof input === "string" ? input : input.url; + vi.fn((input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; const impl = url.includes("/stream_token/") ? mockStreamTokenFetch : /\/stream(\/|\?|$)/.test(url) @@ -22,7 +25,10 @@ const fetchRouter = vi.hoisted(() => }), ); -import { CloudTaskService } from "./cloud-task"; +import { + type CloudTaskEngine, + createCloudTaskEngine, +} from "./cloud-task-engine"; const mockAuthService = { authenticatedFetch: vi.fn(), @@ -86,8 +92,8 @@ async function waitFor( } } -describe("CloudTaskService", () => { - let service: CloudTaskService; +describe("CloudTaskEngine", () => { + let service: CloudTaskEngine; beforeEach(() => { const scopedLog = { @@ -98,11 +104,12 @@ describe("CloudTaskService", () => { }; const loggerMock = { ...scopedLog, scope: vi.fn(() => scopedLog) }; const analyticsMock = { track: vi.fn() }; - service = new CloudTaskService( - mockAuthService as never, - analyticsMock as never, - loggerMock, - ); + service = createCloudTaskEngine({ + auth: mockAuthService as never, + analytics: analyticsMock as never, + logger: loggerMock, + streamFetch: fetchRouter, + }); mockNetFetch.mockReset(); mockStreamFetch.mockReset(); mockStreamTokenFetch.mockReset(); @@ -3077,8 +3084,8 @@ describe("CloudTaskService", () => { }); }); -describe("CloudTaskService MCP relay", () => { - let relayService: CloudTaskService; +describe("CloudTaskEngine MCP relay", () => { + let relayService: CloudTaskEngine; let mcpRelayExecutor: { execute: ReturnType; closeRun: ReturnType; @@ -3099,12 +3106,12 @@ describe("CloudTaskService MCP relay", () => { })), closeRun: vi.fn(async () => {}), }; - relayService = new CloudTaskService( - mockAuthService as never, - analyticsMock as never, - loggerMock, - mcpRelayExecutor as never, - ); + relayService = createCloudTaskEngine({ + auth: mockAuthService as never, + analytics: analyticsMock as never, + logger: loggerMock, + mcpRelayExecutor: mcpRelayExecutor as never, + }); mockNetFetch.mockReset(); mockStreamFetch.mockReset(); diff --git a/packages/core/src/cloud-task/cloud-task.ts b/packages/core/src/cloud-task/cloud-task.ts index 22b5a1ddab..1e2003d85a 100644 --- a/packages/core/src/cloud-task/cloud-task.ts +++ b/packages/core/src/cloud-task/cloud-task.ts @@ -1,2226 +1,35 @@ -import { - ROOT_LOGGER, - type RootLogger, - type ScopedLogger, -} from "@posthog/di/logger"; +import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; import { ANALYTICS_SERVICE, type IAnalytics, } from "@posthog/platform/analytics"; -import type { StoredLogEntry } from "@posthog/shared"; -import { - mcpToolKey, - posthogToolMeta, - serializeError, - TypedEventEmitter, -} from "@posthog/shared"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { inject, injectable, optional, preDestroy } from "inversify"; -import type { CloudTaskPermissionRequestUpdate } from "./cloud-task-types"; +import { CloudTaskEngine } from "./cloud-task-engine"; import { CLOUD_TASK_AUTH, type ICloudTaskAuth, MCP_RELAY_EXECUTOR, type McpRelayExecutor, } from "./identifiers"; -import { - CloudTaskEvent, - type CloudTaskEvents, - isTerminalStatus, - type SendCommandInput, - type SendCommandOutput, - type StopInput, - type StopOutput, - type TaskRunStatus, - type WatchInput, -} from "./schemas"; -import { type SseEvent, SseEventParser } from "./sse-parser"; - -// Reconnect backoff: flat base delay for the first SSE_RECONNECT_FLAT_ATTEMPTS attempts, then -// exponential up to the cap (0.5, 0.5, 0.5, 1, 2, 4, 8, 16, 30s), spanning ~60s before giving up. -const MAX_SSE_RECONNECT_ATTEMPTS = 9; -const MAX_CUMULATIVE_RECONNECT_ATTEMPTS = 30; -const SSE_RECONNECT_BASE_DELAY_MS = 500; -const SSE_RECONNECT_FLAT_ATTEMPTS = 3; -const SSE_RECONNECT_MAX_DELAY_MS = 30_000; -const SSE_HEALTHY_CONNECTION_MS = 60_000; -const EVENT_BATCH_FLUSH_MS = 16; -const EVENT_BATCH_MAX_SIZE = 50; -const SESSION_LOG_PAGE_LIMIT = 5_000; -const MAX_HANDLED_RELAY_REQUEST_IDS = 1_000; -const MCP_RELAY_METHODS_WITHOUT_APPROVAL = new Set([ - "initialize", - "notifications/initialized", - "ping", - "tools/list", - "prompts/list", - "resources/list", - "resources/templates/list", -]); - -// Authoritative end-of-stream sentinel, matched on the SSE event name (event.event, not data.type). -// The client stops on it without consulting run status. -const STREAM_END_EVENT_NAME = "stream-end"; - -interface SessionLogsPage { - entries: StoredLogEntry[]; - hasMore: boolean; -} - -interface CloudTaskConnectionError { - title: string; - message: string; - retryable: boolean; - autoRetry?: boolean; -} - -class CloudTaskStreamError extends Error { - constructor( - message: string, - public readonly details: CloudTaskConnectionError, - public readonly status?: number, - ) { - super(message); - this.name = "CloudTaskStreamError"; - } -} - -class BackendStreamError extends Error { - constructor(message: string) { - super(message); - this.name = "BackendStreamError"; - } -} - -interface TaskRunResponse { - id: string; - status: TaskRunStatus; - stage?: string | null; - output?: Record | null; - state?: Record | null; - error_message?: string | null; - branch?: string | null; - updated_at?: string; - completed_at?: string | null; -} - -interface TaskRunStateEvent { - type: "task_run_state"; - status?: TaskRunStatus; - stage?: string | null; - output?: Record | null; - state?: Record | null; - error_message?: string | null; - branch?: string | null; - updated_at?: string | null; - completed_at?: string | null; -} - -// Which endpoint a connection reads from. Event ids are only meaningful within their issuing leg. -type StreamLeg = "proxy" | "django"; - -interface WatcherState { - taskId: string; - runId: string; - apiHost: string; - teamId: number; - subscriberCount: number; - sseAbortController: AbortController | null; - reconnectTimeoutId: ReturnType | null; - batchFlushTimeoutId: ReturnType | null; - pendingLogEntries: StoredLogEntry[]; - totalEntryCount: number; - /** On resume the renderer already holds the prior conversation; start live- - * only (no bootstrap fetch/snapshot) seeded at this count so the in-flight - * turn can't collide with a re-fetched snapshot. Null on non-resume watches. */ - resumeFromEntryCount: number | null; - reconnectAttempts: number; - streamErrorAttempts: number; - cumulativeReconnectAttempts: number; - lastEventId: string | null; - // Leg that issued lastEventId, and the leg of the connection currently being read. - lastEventIdLeg: StreamLeg | null; - streamLeg: StreamLeg | null; - // Ids of log entries already ingested on the current leg. The durable stream - // re-sends the tail by id on reconnect/replay, so dropping a seen id here is - // what stops a re-delivered entry (e.g. a `turn_complete`) from being counted - // and emitted twice. Cleared on a leg switch, where the id space changes. - seenEventIds: Set; - lastStatus: TaskRunStatus | null; - lastStage: string | null; - lastOutput: Record | null; - lastErrorMessage: string | null; - lastBranch: string | null; - lastSandboxAlive: boolean | null; - lastStatusUpdatedAt: string | null; - connStartedAt: number; - connSentLastEventId: string | null; - connDataEventsReceived: number; - isBootstrapping: boolean; - hasEmittedSnapshot: boolean; - bufferedLogBatches: StoredLogEntry[][]; - // Live entries emitted since the last snapshot, retained so a re-subscribe snapshot can reconcile - // entries the server has not persisted yet. emitCurrentSnapshot trims this to the still-missing - // set; with no re-subscribe it holds the run's emitted entries until the watch ends. - emittedLogEntries: StoredLogEntry[]; - failed: boolean; - needsPostBootstrapReconnect: boolean; - needsStopAfterBootstrap: boolean; - streamEnded: boolean; - // Consumes one automatic re-bootstrap recovery; re-armed by a data event or healthy connection. - selfHealAttempted: boolean; - // Both streamBaseUrl and streamReadToken non-null => read via the agent-proxy; either null => Django. - streamTargetResolved: boolean; - streamBaseUrl: string | null; - streamReadToken: string | null; - // True once stream_token resolved. False for old servers (404), which fall back to status polling. - durableStreamEnabled: boolean; -} - -function watcherKey(taskId: string, runId: string): string { - return `${taskId}:${runId}`; -} - -function isTaskRunStateEvent(data: unknown): data is TaskRunStateEvent { - return ( - typeof data === "object" && - data !== null && - (data as { type?: string }).type === "task_run_state" - ); -} - -interface SseErrorEventData { - error: string; -} - -function isSseErrorEvent(data: unknown): data is SseErrorEventData { - return ( - typeof data === "object" && - data !== null && - "error" in data && - typeof (data as SseErrorEventData).error === "string" - ); -} - -interface PermissionRequestEventData { - type: "permission_request"; - requestId: string; - toolCall: CloudTaskPermissionRequestUpdate["toolCall"]; - options: CloudTaskPermissionRequestUpdate["options"]; -} - -function isPermissionRequestEvent( - data: unknown, -): data is PermissionRequestEventData { - return ( - typeof data === "object" && - data !== null && - (data as { type?: string }).type === "permission_request" && - typeof (data as { requestId?: string }).requestId === "string" - ); -} - -interface McpRequestEventData { - type: "mcp_request"; - requestId: string; - server: string; - payload: Record; - expiresAt: string; -} - -function isMcpRequestEvent(data: unknown): data is McpRequestEventData { - if (typeof data !== "object" || data === null) return false; - const candidate = data as Partial; - return ( - candidate.type === "mcp_request" && - typeof candidate.requestId === "string" && - typeof candidate.server === "string" && - typeof candidate.payload === "object" && - candidate.payload !== null - ); -} - -/** Prefix marking a desktop-issued relay approval prompt, so `sendCommand` can - * resolve its response locally instead of POSTing it to the sandbox. */ -const RELAY_APPROVAL_REQUEST_PREFIX = "relay-approval:"; - -const RELAY_KEY_SEPARATOR = ""; - -function relayApprovalKey( - runId: string, - server: string, - kind: "method" | "tool", - name: string, -): string { - return [runId, server, kind, name].join(RELAY_KEY_SEPARATOR); -} - -interface RelayApprovalRequest { - approvalKey: string; - title: string; - toolName: string; - rawInput: Record; - mcp: { server: string; tool: string }; -} - -function relayApprovalRequest( - runId: string, - server: string, - payload: Record, -): RelayApprovalRequest | null { - const method = - typeof payload.method === "string" ? payload.method : "unknown"; - if (MCP_RELAY_METHODS_WITHOUT_APPROVAL.has(method)) return null; - - const params = - payload.params && typeof payload.params === "object" - ? (payload.params as Record) - : {}; - - if (method === "tools/call") { - const tool = typeof params.name === "string" ? params.name : "unknown"; - const args = - params.arguments && typeof params.arguments === "object" - ? (params.arguments as Record) - : {}; - const toolName = mcpToolKey({ server, tool }); - return { - approvalKey: relayApprovalKey(runId, server, "tool", tool), - title: `The agent wants to call ${tool} (${server}) on your machine`, - toolName, - rawInput: { ...args, toolName }, - mcp: { server, tool }, - }; - } - - const toolName = `mcp:${server}:${method}`; - return { - approvalKey: relayApprovalKey(runId, server, "method", method), - title: `The agent wants to send ${method} to ${server} on your machine`, - toolName, - rawInput: { method, params }, - mcp: { server, tool: method }, - }; -} - -function isKeepaliveEvent(event: SseEvent): boolean { - return ( - event.event === "keepalive" || - (typeof event.data === "object" && - event.data !== null && - "type" in event.data && - event.data.type === "keepalive") - ); -} - -function createStreamStatusError(status: number): CloudTaskStreamError { - switch (status) { - case 401: - return new CloudTaskStreamError( - "Cloud authentication expired", - { - title: "Cloud authentication expired", - message: "Please reauthenticate and retry the cloud run stream.", - retryable: true, - autoRetry: false, - }, - status, - ); - case 403: - return new CloudTaskStreamError( - "Cloud access denied", - { - title: "Cloud access denied", - message: - "You no longer have access to this cloud run. Reauthenticate and retry.", - retryable: true, - autoRetry: false, - }, - status, - ); - case 404: - return new CloudTaskStreamError( - "Cloud run not found", - { - title: "Cloud run not found", - message: - "This cloud run could not be found. It may have been deleted or moved.", - retryable: false, - autoRetry: false, - }, - status, - ); - case 406: - return new CloudTaskStreamError( - "Cloud stream unavailable", - { - title: "Cloud stream unavailable", - message: - "The backend rejected the live stream request. Restart the backend and retry.", - retryable: true, - autoRetry: false, - }, - status, - ); - default: - return new CloudTaskStreamError( - `Stream request failed with status ${status}`, - { - title: "Cloud stream failed", - message: `The cloud stream request failed with status ${status}. Retry to reconnect.`, - retryable: true, - autoRetry: true, - }, - status, - ); - } -} - -function shouldFailWatcherForFetchStatus(status: number): boolean { - return status === 401 || status === 403 || status === 404; -} - -// 5xx and 429 are momentary: the stream-token endpoint exists but is briefly unavailable, so the -// target stays unresolved and the next reconnect retries instead of caching a Django fallback. -function isTransientStreamTargetStatus(status: number): boolean { - return status >= 500 || status === 429; -} - -// Content-based frequency map keyed by the serialized entry. SSE ids are absent from persisted -// (historical) entries, so the payload itself is the identity used to dedup live against historical. -function buildEntryFrequencyMap( - entries: StoredLogEntry[], -): Map { - const counts = new Map(); - for (const entry of entries) { - const serialized = JSON.stringify(entry); - counts.set(serialized, (counts.get(serialized) ?? 0) + 1); - } - return counts; -} - -// Keeps only entries absent from counts, consuming one occurrence per match so a payload present N -// times in the reference set is suppressed at most N times. Mutates counts. -function filterEntriesNotInFrequencyMap( - entries: StoredLogEntry[], - counts: Map, -): StoredLogEntry[] { - return entries.filter((entry) => { - const serialized = JSON.stringify(entry); - const remaining = counts.get(serialized) ?? 0; - if (remaining <= 0) { - return true; - } - counts.set(serialized, remaining - 1); - return false; - }); -} - -function extractSandboxAlive( - state: Record | null | undefined, -): boolean | null | undefined { - if (!state || !Object.hasOwn(state, "sandbox_alive")) { - return undefined; - } - - const sandboxAlive = state.sandbox_alive; - return typeof sandboxAlive === "boolean" ? sandboxAlive : null; -} - -function sandboxAlivePayload(watcher: { lastSandboxAlive: boolean | null }): { - sandboxAlive?: boolean | null; -} { - return watcher.lastSandboxAlive === null - ? {} - : { sandboxAlive: watcher.lastSandboxAlive }; -} @injectable() -export class CloudTaskService extends TypedEventEmitter { - private watchers = new Map(); - private readonly log: ScopedLogger; - +export class CloudTaskService extends CloudTaskEngine { constructor( @inject(CLOUD_TASK_AUTH) - private readonly auth: ICloudTaskAuth, + auth: ICloudTaskAuth, @inject(ANALYTICS_SERVICE) - private readonly analytics: IAnalytics, + analytics: IAnalytics, @inject(ROOT_LOGGER) logger: RootLogger, @inject(MCP_RELAY_EXECUTOR) @optional() - private readonly mcpRelayExecutor: McpRelayExecutor | null = null, + mcpRelayExecutor: McpRelayExecutor | null = null, ) { - super(); - this.log = logger.scope("cloud-task"); - } - - /** - * Relay-designated server names per run (docs/cloud-mcp-relay.md). - * In-memory by design: only the client that created a run in this app - * session may execute relay requests for it; requests for undesignated - * runs or names are dropped. - */ - private readonly relayDesignations = new Map>(); - /** requestId dedupe — the event stream is at-least-once and replays on reconnect. */ - private readonly handledRelayRequestIds = new Set(); - private readonly handledRelayRequestOrder: string[] = []; - - /** Sensitive relay requests require desktop-owned approval. */ - private readonly relayAlwaysApprovals = new Set(); - /** Desktop-issued relay approval prompts awaiting a task-view answer. */ - private readonly pendingLocalRelayPrompts = new Map< - string, - { - runId: string; - resolve: (outcome: { - optionId: string | null; - customInput?: string; - }) => void; - } - >(); - - designateRelayedMcpServers(runId: string, servers: string[]): void { - if (servers.length === 0) return; - this.relayDesignations.set(runId, new Set(servers)); - this.log.info("Designated relayed MCP servers for run", { - runId, - servers, - }); - } - - private markRelayRequestHandled(requestId: string): void { - this.handledRelayRequestIds.add(requestId); - this.handledRelayRequestOrder.push(requestId); - if (this.handledRelayRequestOrder.length > MAX_HANDLED_RELAY_REQUEST_IDS) { - const evicted = this.handledRelayRequestOrder.shift(); - if (evicted) this.handledRelayRequestIds.delete(evicted); - } - } - - private async handleMcpRelayRequest( - watcher: WatcherState, - data: McpRequestEventData, - ): Promise { - if (!this.mcpRelayExecutor) return; - const designated = this.relayDesignations.get(watcher.runId); - if (!designated?.has(data.server)) { - // Not created by this client, or a name the run never declared. - return; - } - if (this.handledRelayRequestIds.has(data.requestId)) return; - this.markRelayRequestHandled(data.requestId); - - const expiresAt = Date.parse(data.expiresAt); - if (this.relayRequestExpired(expiresAt)) { - this.log.info("Dropping expired MCP relay request", { - runId: watcher.runId, - server: data.server, - requestId: data.requestId, - }); - return; - } - - const approvalRequest = relayApprovalRequest( - watcher.runId, - data.server, - data.payload, - ); - if (approvalRequest) { - const approval = await this.ensureRelayRequestApproval( - watcher, - approvalRequest, - expiresAt, - ); - if (!approval.approved) { - // Expired prompts get no response: the sandbox has already timed the - // request out, and a late mcp_response would be rejected as unknown. - if (!approval.expired) { - await this.sendRelayResponse(watcher, data, { - error: { code: -32000, message: approval.message }, - }); - } - return; - } - if (this.relayRequestExpired(expiresAt)) return; - } - - let execution: { - payload?: Record; - error?: { code: number; message: string }; - }; - try { - execution = await this.mcpRelayExecutor.execute( - watcher.runId, - data.server, - data.payload, - ); - } catch (error) { - execution = { - error: { - code: -32000, - message: - error instanceof Error - ? error.message - : "MCP relay execution failed", - }, - }; - } - - // Fire-and-forget notifications produce no response payload or error. - if (!execution.payload && !execution.error) return; - - await this.sendRelayResponse(watcher, data, execution); - } - - private relayRequestExpired(expiresAt: number): boolean { - return Number.isFinite(expiresAt) && expiresAt < Date.now(); - } - - private async sendRelayResponse( - watcher: WatcherState, - data: McpRequestEventData, - execution: { - payload?: Record; - error?: { code: number; message: string }; - }, - ): Promise { - try { - await this.sendCommand({ - taskId: watcher.taskId, - runId: watcher.runId, - apiHost: watcher.apiHost, - teamId: watcher.teamId, - method: "mcp_response", - params: { - requestId: data.requestId, - server: data.server, - ...(execution.payload - ? { payload: execution.payload } - : { error: execution.error }), - }, - }); - } catch (error) { - // The sandbox times the request out on its own; nothing to unwind here. - this.log.warn("Failed to deliver mcp_response command", { - runId: watcher.runId, - requestId: data.requestId, - error: serializeError(error), - }); - } - } - - private async ensureRelayRequestApproval( - watcher: WatcherState, - request: RelayApprovalRequest, - expiresAt: number, - ): Promise< - { approved: true } | { approved: false; expired: boolean; message: string } - > { - const { runId } = watcher; - if (this.relayAlwaysApprovals.has(request.approvalKey)) { - return { approved: true }; - } - - const requestId = `${RELAY_APPROVAL_REQUEST_PREFIX}${globalThis.crypto.randomUUID()}`; - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId, - kind: "permission_request" as const, - requestId, - toolCall: { - toolCallId: requestId, - title: request.title, - kind: "other", - rawInput: request.rawInput, - _meta: posthogToolMeta({ - toolName: request.toolName, - mcp: request.mcp, - }), - }, - options: [ - { kind: "allow_once", name: "Yes", optionId: "allow" }, - { - kind: "allow_always", - name: "Yes, always allow", - optionId: "allow_always", - }, - { - kind: "reject_once", - name: "Type here to tell the agent what to do differently", - optionId: "reject", - _meta: { customInput: true }, - }, - ], - }); - - const outcome = await new Promise<{ - optionId: string | null; - customInput?: string; - }>((resolve) => { - this.pendingLocalRelayPrompts.set(requestId, { runId, resolve }); - // The sandbox abandons the request at expiresAt; keep waiting any longer - // and an approval would execute a call whose result nothing consumes. - const waitMs = Number.isFinite(expiresAt) - ? Math.max(0, expiresAt - Date.now()) - : 60_000; - const timer = setTimeout(() => { - if (this.pendingLocalRelayPrompts.delete(requestId)) { - resolve({ optionId: null }); - } - }, waitMs); - timer.unref?.(); - }); - - if (outcome.optionId === "allow_always") { - this.relayAlwaysApprovals.add(request.approvalKey); - return { approved: true }; - } - if (outcome.optionId === "allow") return { approved: true }; - if (outcome.optionId === null) { - return { - approved: false, - expired: true, - message: "The user did not respond in time.", - }; - } - return { - approved: false, - expired: false, - message: outcome.customInput - ? `The user denied this MCP request: ${outcome.customInput}` - : "The user denied this MCP request.", - }; - } - - /** Drop a terminal run's relay approval state and abandon its open prompts. */ - private evictRelayApprovalState(runId: string): void { - const prefix = `${runId}${RELAY_KEY_SEPARATOR}`; - for (const key of [...this.relayAlwaysApprovals]) { - if (key.startsWith(prefix)) this.relayAlwaysApprovals.delete(key); - } - for (const [requestId, prompt] of [...this.pendingLocalRelayPrompts]) { - if (prompt.runId !== runId) continue; - this.pendingLocalRelayPrompts.delete(requestId); - prompt.resolve({ optionId: null }); - } - } - - watch(input: WatchInput): void { - const key = watcherKey(input.taskId, input.runId); - - const existing = this.watchers.get(key); - if (existing) { - existing.subscriberCount++; - this.log.info("Cloud task watcher subscriber added", { - key, - subscribers: existing.subscriberCount, - }); - void this.emitCurrentSnapshot(key); - return; - } - - this.startWatcher(input, 1); - } - - unwatch(taskId: string, runId: string): void { - const key = watcherKey(taskId, runId); - const watcher = this.watchers.get(key); - if (!watcher) { - return; - } - - watcher.subscriberCount--; - if (watcher.subscriberCount <= 0) { - this.stopWatcher(key); - } else { - this.log.info("Cloud task watcher subscriber removed", { - key, - subscribers: watcher.subscriberCount, - }); - } - } - - async retry(taskId: string, runId: string): Promise { - const key = watcherKey(taskId, runId); - const watcher = this.watchers.get(key); - if (!watcher) return; - - if (watcher.reconnectTimeoutId) { - clearTimeout(watcher.reconnectTimeoutId); - watcher.reconnectTimeoutId = null; - } - - watcher.sseAbortController?.abort(); - watcher.sseAbortController = null; - - if (watcher.batchFlushTimeoutId) { - clearTimeout(watcher.batchFlushTimeoutId); - watcher.batchFlushTimeoutId = null; - } - - this.log.info("Retrying cloud task watcher", { - key, - hasSnapshot: watcher.hasEmittedSnapshot, - }); - - // Start over from scratch: a poisoned resume position loops straight back into the same - // failure, so re-bootstrap to re-resolve the read leg and emit a fresh snapshot. - this.resetWatcherForRebootstrap(watcher); - void this.bootstrapWatcher(key); - } - - // Resets a watcher to its pre-bootstrap state so bootstrapWatcher can rebuild it from server truth. - private resetWatcherForRebootstrap(watcher: WatcherState): void { - watcher.reconnectAttempts = 0; - watcher.streamErrorAttempts = 0; - watcher.cumulativeReconnectAttempts = 0; - watcher.failed = false; - watcher.pendingLogEntries = []; - watcher.bufferedLogBatches = []; - watcher.needsPostBootstrapReconnect = false; - watcher.needsStopAfterBootstrap = false; - watcher.streamEnded = false; - watcher.selfHealAttempted = false; - watcher.lastEventId = null; - watcher.lastEventIdLeg = null; - watcher.streamLeg = null; - // The rebuild re-resolves the read leg, so a retained id could false-match a - // different entry on the next connection — and the leg-switch clear in - // connectSse can't catch it, since lastEventId was just nulled. The re-fetched - // snapshot re-delivers history, so no dedup state is lost. - watcher.seenEventIds.clear(); - watcher.totalEntryCount = 0; - watcher.isBootstrapping = false; - watcher.streamTargetResolved = false; - watcher.streamBaseUrl = null; - watcher.streamReadToken = null; - watcher.durableStreamEnabled = false; - } - - async sendCommand(input: SendCommandInput): Promise { - if (input.method === "permission_response") { - const params = input.params ?? {}; - const requestId = - typeof params.requestId === "string" ? params.requestId : null; - if (requestId?.startsWith(RELAY_APPROVAL_REQUEST_PREFIX)) { - // A desktop-issued relay approval: resolve it locally — the sandbox - // never saw this prompt, so there is nothing to POST. - const pending = this.pendingLocalRelayPrompts.get(requestId); - this.pendingLocalRelayPrompts.delete(requestId); - pending?.resolve({ - optionId: - typeof params.optionId === "string" ? params.optionId : null, - customInput: - typeof params.customInput === "string" - ? params.customInput - : undefined, - }); - return { success: true }; - } - } - - const url = `${input.apiHost}/api/projects/${input.teamId}/tasks/${input.taskId}/runs/${input.runId}/command/`; - const body = { - jsonrpc: "2.0", - method: input.method, - params: input.params ?? {}, - id: `posthog-code-${Date.now()}`, - }; - - try { - const response = await this.auth.authenticatedFetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - let errorMessage = `Command failed with status ${response.status}`; - try { - const errorJson = JSON.parse(errorText); - if (errorJson.error?.message) { - errorMessage = errorJson.error.message; - } else if (errorJson.error) { - errorMessage = - typeof errorJson.error === "string" - ? errorJson.error - : JSON.stringify(errorJson.error); - } - } catch { - if (errorText) errorMessage = errorText; - } - - this.log.warn("Cloud task command failed", { - taskId: input.taskId, - runId: input.runId, - method: input.method, - status: response.status, - error: errorMessage, - }); - return { success: false, error: errorMessage }; - } - - const data = (await response.json()) as { - error?: { message?: string }; - result?: unknown; - }; - - if (data.error) { - this.log.warn("Cloud task command returned error", { - taskId: input.taskId, - method: input.method, - error: data.error, - }); - return { - success: false, - error: data.error.message ?? JSON.stringify(data.error), - }; - } - - this.log.info("Cloud task command sent", { - taskId: input.taskId, - runId: input.runId, - method: input.method, - }); - - return { success: true, result: data.result }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; - this.log.error("Cloud task command error", { - taskId: input.taskId, - method: input.method, - error: errorMessage, - }); - return { success: false, error: errorMessage }; - } - } - - async stop(input: StopInput): Promise { - try { - const context = await this.auth.getCloudContext(); - if (!context) { - return { success: false, error: "No active cloud project" }; - } - const url = `${context.apiHost}/api/projects/${context.teamId}/tasks/${input.taskId}/runs/${input.runId}/cancel/`; - const response = await this.auth.authenticatedFetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(input.reason ? { reason: input.reason } : {}), - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - let errorMessage = `Stop failed with status ${response.status}`; - try { - const errorJson = JSON.parse(errorText) as { error?: unknown }; - if (typeof errorJson.error === "string" && errorJson.error) { - errorMessage = errorJson.error; - } - } catch { - if (errorText) errorMessage = errorText; - } - - this.log.warn("Cloud run stop failed", { - taskId: input.taskId, - runId: input.runId, - status: response.status, - error: errorMessage, - }); - return { - success: false, - error: errorMessage, - retryable: response.status === 503 || response.status >= 500, - }; - } - - const data = (await response.json()) as { status?: string }; - this.log.info("Cloud run stop accepted", { - taskId: input.taskId, - runId: input.runId, - runStatus: data.status, - }); - return { success: true, runStatus: data.status }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; - this.log.error("Cloud run stop error", { - taskId: input.taskId, - runId: input.runId, - error: errorMessage, - }); - return { success: false, error: errorMessage, retryable: true }; - } + super({ auth, analytics, logger, mcpRelayExecutor }); } @preDestroy() - unwatchAll(): void { - for (const key of [...this.watchers.keys()]) { - this.stopWatcher(key); - } - } - - private startWatcher(input: WatchInput, subscriberCount: number): void { - const key = watcherKey(input.taskId, input.runId); - - const watcher: WatcherState = { - taskId: input.taskId, - runId: input.runId, - apiHost: input.apiHost, - teamId: input.teamId, - subscriberCount, - sseAbortController: null, - reconnectTimeoutId: null, - batchFlushTimeoutId: null, - pendingLogEntries: [], - totalEntryCount: 0, - resumeFromEntryCount: input.resumeFromEntryCount ?? null, - reconnectAttempts: 0, - streamErrorAttempts: 0, - cumulativeReconnectAttempts: 0, - lastEventId: null, - lastEventIdLeg: null, - streamLeg: null, - seenEventIds: new Set(), - lastStatus: null, - lastStage: null, - lastOutput: null, - lastErrorMessage: null, - lastBranch: null, - lastSandboxAlive: null, - lastStatusUpdatedAt: null, - connStartedAt: 0, - connSentLastEventId: null, - connDataEventsReceived: 0, - isBootstrapping: false, - hasEmittedSnapshot: false, - bufferedLogBatches: [], - emittedLogEntries: [], - failed: false, - needsPostBootstrapReconnect: false, - needsStopAfterBootstrap: false, - streamEnded: false, - selfHealAttempted: false, - streamTargetResolved: false, - streamBaseUrl: null, - streamReadToken: null, - durableStreamEnabled: false, - }; - - this.watchers.set(key, watcher); - this.log.info("Cloud task watcher started", { key }); - void this.bootstrapWatcher(key); - } - - private stopWatcher(key: string): void { - const watcher = this.watchers.get(key); - if (!watcher) return; - - if (this.relayDesignations.has(watcher.runId)) { - // No watcher → no relay events → nothing executes; release the run's - // live server connections (stdio children included). They reopen - // lazily if the run is watched again. - void this.mcpRelayExecutor?.closeRun?.(watcher.runId).catch(() => {}); - } - - watcher.sseAbortController?.abort(); - - if (watcher.reconnectTimeoutId) { - clearTimeout(watcher.reconnectTimeoutId); - watcher.reconnectTimeoutId = null; - } - - if (watcher.batchFlushTimeoutId) { - clearTimeout(watcher.batchFlushTimeoutId); - watcher.batchFlushTimeoutId = null; - } - - this.flushLogBatch(key); - this.watchers.delete(key); - this.log.info("Cloud task watcher stopped", { key }); - } - - private async bootstrapWatcher(key: string): Promise { - const watcher = this.watchers.get(key); - if (!watcher) return; - - watcher.failed = false; - watcher.needsPostBootstrapReconnect = false; - watcher.needsStopAfterBootstrap = false; - - const run = await this.fetchTaskRun(watcher); - const currentWatcher = this.watchers.get(key); - if (!currentWatcher || currentWatcher !== watcher) return; - if (watcher.failed) return; - - if (!run) { - this.failWatcher(key, { - title: "Failed to load cloud run", - message: "Could not fetch the cloud run state. Retry to reconnect.", - retryable: true, - }); - return; - } - - this.applyTaskRunState(watcher, run); - - if ( - !isTerminalStatus(run.status) && - watcher.resumeFromEntryCount !== null - ) { - watcher.totalEntryCount = watcher.resumeFromEntryCount; - watcher.hasEmittedSnapshot = true; - watcher.isBootstrapping = false; - void this.connectSse(key, { startLatest: true }); - return; - } - - if (isTerminalStatus(run.status)) { - const historicalEntries = await this.fetchAllSessionLogs(watcher); - const terminalWatcher = this.watchers.get(key); - if (!terminalWatcher || terminalWatcher !== watcher) return; - if (watcher.failed) return; - if (!historicalEntries) { - this.failWatcher(key, { - title: "Failed to load task history", - message: - "Could not load the persisted cloud task logs. Retry to reconnect.", - retryable: true, - }); - return; - } - - watcher.totalEntryCount = historicalEntries.length; - watcher.hasEmittedSnapshot = true; - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "snapshot", - newEntries: historicalEntries, - totalEntryCount: watcher.totalEntryCount, - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - ...sandboxAlivePayload(watcher), - }); - this.stopWatcher(key); - return; - } - - watcher.isBootstrapping = true; - watcher.bufferedLogBatches = []; - void this.connectSse(key, { startLatest: true }); - - const historicalEntries = await this.fetchAllSessionLogs(watcher); - const bootstrappingWatcher = this.watchers.get(key); - if (!bootstrappingWatcher || bootstrappingWatcher !== watcher) return; - if (watcher.failed) return; - if (!historicalEntries) { - this.failWatcher(key, { - title: "Failed to load cloud run history", - message: - "Could not load the existing cloud run logs. Retry to reconnect.", - retryable: true, - }); - return; - } - - // Flush any pending live entries into the bootstrap buffer before snapshot. - this.flushLogBatch(key); - - watcher.totalEntryCount = historicalEntries.length; - watcher.hasEmittedSnapshot = true; - - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "snapshot", - newEntries: historicalEntries, - totalEntryCount: watcher.totalEntryCount, - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - ...sandboxAlivePayload(watcher), - }); - - watcher.isBootstrapping = false; - this.drainBufferedLogBatches(key, historicalEntries); - - if (watcher.failed) { - return; - } - - if (watcher.needsStopAfterBootstrap) { - watcher.needsStopAfterBootstrap = false; - await this.finalizeWatcherStop(key); - return; - } - - if (watcher.needsPostBootstrapReconnect) { - watcher.needsPostBootstrapReconnect = false; - this.scheduleReconnect(key, undefined, { countAttempt: false }); - } - - void this.verifyPostBootstrapStatus(key); - } - - private async verifyPostBootstrapStatus(key: string): Promise { - const watcher = this.watchers.get(key); - if (!watcher) return; - if (isTerminalStatus(watcher.lastStatus)) return; - - const run = await this.fetchTaskRun(watcher); - const currentWatcher = this.watchers.get(key); - if (!currentWatcher || currentWatcher !== watcher) return; - if (!run) return; - - if (!this.applyTaskRunState(watcher, run)) return; - if (isTerminalStatus(watcher.lastStatus)) return; - - this.emitStatusUpdate(watcher); - } - - private emitStatusUpdate(watcher: WatcherState): void { - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "status", - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - ...sandboxAlivePayload(watcher), - }); - } - - private async connectSse( - key: string, - options?: { startLatest?: boolean }, - ): Promise { - const watcher = this.watchers.get(key); - if (!watcher) return; - - const controller = new AbortController(); - watcher.sseAbortController = controller; - - watcher.connStartedAt = 0; - watcher.connDataEventsReceived = 0; - - // Resolve the read target once (proxy URL + token, or Django), reused across reconnects. - if (!watcher.streamTargetResolved) { - await this.resolveStreamTarget(watcher); - const resolvedWatcher = this.watchers.get(key); - if ( - !resolvedWatcher || - resolvedWatcher !== watcher || - controller.signal.aborted - ) { - return; - } - } - - const usingProxy = Boolean( - watcher.streamBaseUrl && watcher.streamReadToken, - ); - const base = usingProxy - ? watcher.streamBaseUrl?.replace(/\/+$/, "") - : watcher.apiHost; - const leg: StreamLeg = usingProxy ? "proxy" : "django"; - // Proxy and Django id spaces are unrelated, so drop the resume position on a leg switch and - // let start=latest plus the next snapshot cover the gap. - if (watcher.lastEventId && watcher.lastEventIdLeg !== leg) { - this.log.info("Cloud task stream leg changed, dropping resume position", { - key, - from: watcher.lastEventIdLeg, - to: leg, - }); - watcher.lastEventId = null; - watcher.lastEventIdLeg = null; - // Proxy and Django ids are unrelated, so a retained id could false-match a - // different entry on the new leg. Drop them; the snapshot covers the gap. - watcher.seenEventIds.clear(); - } - watcher.streamLeg = leg; - - // Captured after the leg-switch drop so they reflect what this connection actually sends. - watcher.connSentLastEventId = watcher.lastEventId; - const startLatest = Boolean(options?.startLatest && !watcher.lastEventId); - const url = new URL( - usingProxy - ? `${base}/v1/runs/${encodeURIComponent(watcher.runId)}/stream` - : `${base}/api/projects/${watcher.teamId}/tasks/${encodeURIComponent( - watcher.taskId, - )}/runs/${encodeURIComponent(watcher.runId)}/stream/`, - ); - if (startLatest) { - url.searchParams.set("start", "latest"); - } - const headers: Record = { - Accept: "text/event-stream", - }; - if (watcher.lastEventId) { - headers["Last-Event-ID"] = watcher.lastEventId; - } - if (usingProxy) { - headers.Authorization = `Bearer ${watcher.streamReadToken}`; - } - - // Info so every stream attempt is visible in the logs; Bearer token redacted. - this.log.info(`Opening cloud task stream via ${leg}: ${url.toString()}`, { - key, - leg, - usingProxy, - durableStream: watcher.durableStreamEnabled, - method: "GET", - streamUrl: url.toString(), - lastEventId: watcher.lastEventId, - startLatest, - headers: usingProxy - ? { ...headers, Authorization: "Bearer " } - : headers, - }); - - const parser = new SseEventParser((message, data) => - this.log.warn(message, data), - ); - const decoder = new TextDecoder(); - - // Track how long the body stayed open so healthy long-lived connections cut by churn - // aren't penalized as failed reconnects (see SSE_HEALTHY_CONNECTION_MS). - let connectedAt = 0; - let streamWasEstablished = false; - let bytesReceived = 0; - let eventsReceived = 0; - - try { - // The proxy authenticates with the run-scoped Bearer token; the Django leg uses the session. - const response = usingProxy - ? await fetch(url.toString(), { - method: "GET", - headers, - signal: controller.signal, - }) - : await this.auth.authenticatedFetch(url.toString(), { - method: "GET", - headers, - signal: controller.signal, - }); - - this.log.info( - `Cloud task stream response ${response.status} ${ - response.ok ? "ok" : "FAILED" - } via ${leg}`, - { - key, - leg, - status: response.status, - ok: response.ok, - streamUrl: url.toString(), - }, - ); - - if (!response.ok) { - throw createStreamStatusError(response.status); - } - - if (!response.body) { - throw new Error("Stream response did not include a body"); - } - - connectedAt = Date.now(); - streamWasEstablished = true; - watcher.connStartedAt = connectedAt; - - this.log.info(`Cloud task SSE connected via ${leg}: ${url.toString()}`, { - key, - leg, - streamUrl: url.toString(), - sentLastEventId: watcher.connSentLastEventId, - startLatest, - status: response.status, - server: response.headers.get("server"), - via: response.headers.get("via"), - cfRay: response.headers.get("cf-ray"), - cfCacheStatus: response.headers.get("cf-cache-status"), - xAccelBuffering: response.headers.get("x-accel-buffering"), - contentType: response.headers.get("content-type"), - requestId: response.headers.get("x-request-id"), - }); - - const reader = response.body.getReader(); - - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - if (!value) { - continue; - } - - bytesReceived += value.byteLength; - const chunk = decoder.decode(value, { stream: true }); - const events = parser.parse(chunk); - for (const event of events) { - eventsReceived += 1; - const backendError = this.handleSseEvent(key, event); - if (backendError) { - throw backendError; - } - } - } - - const trailingEvents = parser.parse(decoder.decode()); - for (const event of trailingEvents) { - const backendError = this.handleSseEvent(key, event); - if (backendError) { - throw backendError; - } - } - - this.flushLogBatch(key); - - if (controller.signal.aborted) { - return; - } - - this.log.info("Cloud task stream closed cleanly", { - key, - connectionDurationMs: Date.now() - connectedAt, - bytesReceived, - eventsReceived, - dataEventsReceived: watcher.connDataEventsReceived, - lastEventId: watcher.lastEventId, - }); - - // A long-lived clean close is healthy churn, not a loop: clear the cumulative budget so an - // idle run can ride out proxy timeout cycles, while instant-EOF loops still exhaust it. - const completedWatcher = this.watchers.get(key); - if ( - completedWatcher && - streamWasEstablished && - Date.now() - connectedAt >= SSE_HEALTHY_CONNECTION_MS - ) { - completedWatcher.cumulativeReconnectAttempts = 0; - completedWatcher.selfHealAttempted = false; - } - - await this.handleStreamCompletion(key, { reconnectOnDisconnect: true }); - } catch (error) { - this.flushLogBatch(key); - - if (controller.signal.aborted) { - return; - } - - // Proxy-leg 401: the read token expired or its signing key rotated. Re-resolve to mint a - // fresh token (or route back to Django) instead of failing. Django-leg 401 stays fatal below. - const unauthorizedWatcher = this.watchers.get(key); - if ( - error instanceof CloudTaskStreamError && - error.status === 401 && - unauthorizedWatcher?.streamBaseUrl - ) { - // Keep durableStreamEnabled set: clearing it would route this disconnect through legacy - // status polling, which can stop the watch on a terminal status before stream-end arrives. - // The next connectSse re-resolves the target and resolveStreamTarget re-derives durability. - unauthorizedWatcher.streamTargetResolved = false; - unauthorizedWatcher.streamBaseUrl = null; - unauthorizedWatcher.streamReadToken = null; - this.log.info("Cloud task stream proxy token rejected, re-resolving", { - key, - }); - await this.handleStreamCompletion(key, { - reconnectOnDisconnect: true, - reconnectError: error, - countReconnectAttempt: true, - }); - return; - } - - if ( - error instanceof CloudTaskStreamError && - error.details.autoRetry === false - ) { - this.failWatcher(key, error.details); - return; - } - - const errorMessage = - error instanceof Error ? error.message : "Unknown stream error"; - - const isBackendError = error instanceof BackendStreamError; - const wasHealthyStream = - !isBackendError && - streamWasEstablished && - Date.now() - connectedAt >= SSE_HEALTHY_CONNECTION_MS; - - const errorWatcher = this.watchers.get(key); - if (errorWatcher) { - if (isBackendError) { - errorWatcher.streamErrorAttempts += 1; - } else if (wasHealthyStream) { - errorWatcher.streamErrorAttempts = 0; - // A healthy-length connection proves timeout cycling, not a loop. - errorWatcher.cumulativeReconnectAttempts = 0; - errorWatcher.selfHealAttempted = false; - } - } - - this.log.warn("Cloud task stream error", { - key, - leg, - streamUrl: url.toString(), - error: errorMessage, - errorDetail: serializeError(error), - wasHealthyStream, - isBackendError, - streamWasEstablished, - connectionDurationMs: streamWasEstablished - ? Date.now() - connectedAt - : 0, - bytesReceived, - eventsReceived, - dataEventsReceived: errorWatcher?.connDataEventsReceived ?? 0, - lastEventId: errorWatcher?.lastEventId ?? null, - reconnectAttempts: errorWatcher?.reconnectAttempts ?? 0, - streamErrorAttempts: errorWatcher?.streamErrorAttempts ?? 0, - cumulativeReconnectAttempts: - errorWatcher?.cumulativeReconnectAttempts ?? 0, - }); - await this.handleStreamCompletion(key, { - reconnectOnDisconnect: true, - reconnectError: error, - countReconnectAttempt: !isBackendError && !wasHealthyStream, - }); - } finally { - const currentWatcher = this.watchers.get(key); - if (currentWatcher?.sseAbortController === controller) { - currentWatcher.sseAbortController = null; - } - } - } - - // Returns a BackendStreamError when the stream carries an error event so the caller can throw at - // the read site; returns null otherwise. It does not throw, so a single event cannot unwind the - // reader loop unexpectedly. - private handleSseEvent( - key: string, - event: SseEvent, - ): BackendStreamError | null { - const watcher = this.watchers.get(key); - if (!watcher || watcher.failed) return null; - - if (event.id) { - watcher.lastEventId = event.id; - watcher.lastEventIdLeg = watcher.streamLeg; - } - - if (event.event === "error") { - const message = isSseErrorEvent(event.data) - ? event.data.error - : "Unknown stream error"; - return new BackendStreamError(message); - } - - if (event.event === STREAM_END_EVENT_NAME) { - // The run's stream is durably complete. Mark it so completion stops instead - // of reconnecting, independent of run status. The connection will close - // naturally (clean EOF) right after this sentinel. - watcher.streamEnded = true; - return null; - } - - // A keepalive or real event proves the transport recovered. A keepalive does not clear the - // backend-error budget, which only a real data event below resets. - watcher.reconnectAttempts = 0; - - if (isKeepaliveEvent(event)) { - return null; - } - - // A real data event proves the stream materialized; clear the remaining budgets and re-arm self-heal. - watcher.streamErrorAttempts = 0; - watcher.cumulativeReconnectAttempts = 0; - watcher.selfHealAttempted = false; - - watcher.connDataEventsReceived += 1; - if (watcher.connDataEventsReceived === 1 && watcher.connSentLastEventId) { - this.log.info("Cloud task SSE resumed", { - key, - resumedFrom: watcher.connSentLastEventId, - firstEventIdAfterResume: event.id ?? null, - }); - } - - if (isTaskRunStateEvent(event.data)) { - if (this.applyTaskRunState(watcher, event.data)) { - if (!watcher.isBootstrapping && !isTerminalStatus(watcher.lastStatus)) { - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "status", - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - ...sandboxAlivePayload(watcher), - }); - } - } - return null; - } - - // Drop a re-delivered event by its stream id. The durable stream re-sends - // the tail on reconnect/replay: each re-sent log entry would otherwise be - // counted as a new entry (advancing totalEntryCount past the renderer's - // processedLineCount guard) and emitted again — the root cause of duplicate - // transcript entries and back-to-back completion notifications — and a - // re-sent permission_request frame would re-surface an already-answered - // question as a fresh pending card. Events without an id (legacy servers) - // fall through and are handled downstream. - const eventId = event.id; - if (eventId !== undefined) { - if (watcher.seenEventIds.has(eventId)) { - return null; - } - watcher.seenEventIds.add(eventId); - } - - if (isMcpRequestEvent(event.data)) { - void this.handleMcpRelayRequest(watcher, event.data); - return null; - } - - if (isPermissionRequestEvent(event.data)) { - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "permission_request" as const, - requestId: event.data.requestId, - toolCall: event.data.toolCall, - options: event.data.options, - }); - return null; - } - - watcher.pendingLogEntries.push(event.data as StoredLogEntry); - if (watcher.pendingLogEntries.length >= EVENT_BATCH_MAX_SIZE) { - this.flushLogBatch(key); - return null; - } - - if (!watcher.batchFlushTimeoutId) { - watcher.batchFlushTimeoutId = setTimeout(() => { - watcher.batchFlushTimeoutId = null; - this.flushLogBatch(key); - }, EVENT_BATCH_FLUSH_MS); - } - - return null; - } - - private flushLogBatch(key: string): void { - const watcher = this.watchers.get(key); - if (!watcher || watcher.pendingLogEntries.length === 0) return; - - if (watcher.batchFlushTimeoutId) { - clearTimeout(watcher.batchFlushTimeoutId); - watcher.batchFlushTimeoutId = null; - } - - const entries = watcher.pendingLogEntries; - watcher.pendingLogEntries = []; - - if (watcher.isBootstrapping) { - watcher.bufferedLogBatches.push(entries); - return; - } - - watcher.totalEntryCount += entries.length; - this.rememberEmittedLogEntries(watcher, entries); - - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "logs", - newEntries: entries, - totalEntryCount: watcher.totalEntryCount, - }); - } - - private drainBufferedLogBatches( - key: string, - historicalEntries: StoredLogEntry[], - ): void { - const watcher = this.watchers.get(key); - if (!watcher || watcher.bufferedLogBatches.length === 0) return; - - const historicalCounts = buildEntryFrequencyMap(historicalEntries); - - for (const entries of watcher.bufferedLogBatches) { - const dedupedEntries = filterEntriesNotInFrequencyMap( - entries, - historicalCounts, - ); - - if (dedupedEntries.length === 0) { - continue; - } - - watcher.totalEntryCount += dedupedEntries.length; - this.rememberEmittedLogEntries(watcher, dedupedEntries); - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "logs", - newEntries: dedupedEntries, - totalEntryCount: watcher.totalEntryCount, - }); - } - - watcher.bufferedLogBatches = []; - } - - private rememberEmittedLogEntries( - watcher: WatcherState, - entries: StoredLogEntry[], - ): void { - watcher.emittedLogEntries.push(...entries); - } - - private mergeHistoricalAndEmittedEntries( - historicalEntries: StoredLogEntry[], - emittedEntries: StoredLogEntry[], - ): { - snapshotEntries: StoredLogEntry[]; - missingEmittedEntries: StoredLogEntry[]; - } { - if (emittedEntries.length === 0) { - return { snapshotEntries: historicalEntries, missingEmittedEntries: [] }; - } - - const historicalCounts = buildEntryFrequencyMap(historicalEntries); - const missingEmittedEntries = filterEntriesNotInFrequencyMap( - emittedEntries, - historicalCounts, - ); - - return { - snapshotEntries: [...historicalEntries, ...missingEmittedEntries], - missingEmittedEntries, - }; - } - - private async emitCurrentSnapshot(key: string): Promise { - const watcher = this.watchers.get(key); - if (!watcher || watcher.failed) return; - - const historicalEntries = await this.fetchAllSessionLogs(watcher); - const currentWatcher = this.watchers.get(key); - if (!currentWatcher || currentWatcher !== watcher || watcher.failed) { - return; - } - - if (!historicalEntries) { - this.log.warn("Cloud task snapshot replay failed", { - taskId: watcher.taskId, - runId: watcher.runId, - }); - return; - } - - const { snapshotEntries, missingEmittedEntries } = - this.mergeHistoricalAndEmittedEntries( - historicalEntries, - watcher.emittedLogEntries, - ); - watcher.emittedLogEntries = missingEmittedEntries; - if (snapshotEntries.length > watcher.totalEntryCount) { - watcher.totalEntryCount = snapshotEntries.length; - } - - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "snapshot", - newEntries: snapshotEntries, - totalEntryCount: snapshotEntries.length, - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - ...sandboxAlivePayload(watcher), - }); - } - - private failWatcher(key: string, error: CloudTaskConnectionError): void { - const watcher = this.watchers.get(key); - if (!watcher) return; - - this.log.warn("Cloud task watcher failed", { - key, - errorTitle: error.title, - retryable: error.retryable, - status: watcher.lastStatus, - wasBootstrapping: watcher.isBootstrapping, - reconnectAttempts: watcher.reconnectAttempts, - cumulativeReconnectAttempts: watcher.cumulativeReconnectAttempts, - totalEntryCount: watcher.totalEntryCount, - lastEventId: watcher.lastEventId, - }); - - this.analytics.track(ANALYTICS_EVENTS.CLOUD_STREAM_DISCONNECTED, { - task_id: watcher.taskId, - run_id: watcher.runId, - team_id: watcher.teamId, - error_title: error.title, - retryable: error.retryable, - reconnect_attempts: watcher.reconnectAttempts, - stream_error_attempts: watcher.streamErrorAttempts, - cumulative_reconnect_attempts: watcher.cumulativeReconnectAttempts, - was_bootstrapping: watcher.isBootstrapping, - }); - - watcher.failed = true; - watcher.isBootstrapping = false; - watcher.pendingLogEntries = []; - watcher.bufferedLogBatches = []; - - if (watcher.reconnectTimeoutId) { - clearTimeout(watcher.reconnectTimeoutId); - watcher.reconnectTimeoutId = null; - } - - if (watcher.batchFlushTimeoutId) { - clearTimeout(watcher.batchFlushTimeoutId); - watcher.batchFlushTimeoutId = null; - } - - watcher.sseAbortController?.abort(); - watcher.sseAbortController = null; - - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "error", - errorTitle: error.title, - errorMessage: error.message, - retryable: error.retryable, - }); - } - - private scheduleReconnect( - key: string, - error?: unknown, - options: { countAttempt?: boolean } = {}, - ): void { - const watcher = this.watchers.get(key); - // Status-unaware: the loop only stops on the stream-end sentinel or budget exhaustion below. - if (!watcher || watcher.failed) { - return; - } - - if (watcher.reconnectTimeoutId) { - clearTimeout(watcher.reconnectTimeoutId); - } - - // Bounds runaway loops that clean-EOF (countAttempt=false) and dodge reconnectAttempts. - watcher.cumulativeReconnectAttempts += 1; - const countAttempt = options.countAttempt ?? true; - if (countAttempt) { - watcher.reconnectAttempts += 1; - } - - if ( - watcher.cumulativeReconnectAttempts > MAX_CUMULATIVE_RECONNECT_ATTEMPTS - ) { - // A poisoned resume position burns the budget without an error frame. Rebuild once from - // scratch (the app-restart recovery) before failing; if it loops straight back, fail for real. - if (!watcher.selfHealAttempted) { - watcher.reconnectTimeoutId = null; - this.log.warn( - "Cloud task stream looping without events, re-bootstrapping", - { key }, - ); - this.resetWatcherForRebootstrap(watcher); - // Set after the reset (which clears it): consumes the single allowed self-heal so a - // straight-back loop fails next time instead of re-bootstrapping forever. - watcher.selfHealAttempted = true; - void this.bootstrapWatcher(key); - return; - } - this.failWatcher(key, { - title: "Cloud run unreachable", - message: - "Could not maintain a connection to the cloud run after many attempts. Click retry once the issue is resolved.", - retryable: true, - }); - return; - } - - // Fail once either budget (transport reconnect or backend stream-error) is exhausted. - const attemptCount = Math.max( - watcher.reconnectAttempts, - watcher.streamErrorAttempts, - ); - if (attemptCount > MAX_SSE_RECONNECT_ATTEMPTS) { - const details = - error instanceof CloudTaskStreamError - ? error.details - : { - title: "Cloud stream disconnected", - message: - "Lost connection to the cloud run stream. Retry to reconnect.", - retryable: true, - }; - this.failWatcher(key, details); - return; - } - - const backoffAttempts = - error instanceof BackendStreamError - ? watcher.streamErrorAttempts - : watcher.reconnectAttempts; - const delay = Math.min( - SSE_RECONNECT_BASE_DELAY_MS * - 2 ** Math.max(backoffAttempts - SSE_RECONNECT_FLAT_ATTEMPTS, 0), - SSE_RECONNECT_MAX_DELAY_MS, - ); - - watcher.reconnectTimeoutId = setTimeout(() => { - const currentWatcher = this.watchers.get(key); - if (!currentWatcher) return; - currentWatcher.reconnectTimeoutId = null; - void this.connectSse(key, { - startLatest: - currentWatcher.isBootstrapping || currentWatcher.hasEmittedSnapshot, - }); - }, delay); - } - - private async handleStreamCompletion( - key: string, - options: { - reconnectOnDisconnect: boolean; - reconnectError?: unknown; - countReconnectAttempt?: boolean; - }, - ): Promise { - const watcher = this.watchers.get(key); - if (!watcher) return; - if (watcher.failed) return; - - const { reconnectOnDisconnect } = options; - - // Bootstrap owns the snapshot lifecycle: stopping mid-bootstrap would discard the backlog and - // buffered live entries. Record intent and let bootstrap finish. - if (watcher.isBootstrapping) { - if (watcher.streamEnded || !reconnectOnDisconnect) { - watcher.needsStopAfterBootstrap = true; - } else { - watcher.needsPostBootstrapReconnect = true; - } - return; - } - - // The stream-end sentinel is the only signal that ends a durable watch. Any disconnect without - // it is transport churn to reconnect through; status is tracked for display only, never to stop. - if (watcher.streamEnded) { - await this.finalizeWatcherStop(key); - return; - } - - // Legacy mode (old server): no sentinel, so poll run status on disconnect to decide stop vs - // reconnect. The reconnect budgets keep the new semantics, so self-heal stays active here too. - if (!watcher.durableStreamEnabled && reconnectOnDisconnect) { - const run = await this.fetchTaskRun(watcher); - const legacyWatcher = this.watchers.get(key); - if (!legacyWatcher || legacyWatcher !== watcher) return; - if (watcher.failed) return; - - if (run) { - this.applyTaskRunState(watcher, run); - } - if (isTerminalStatus(watcher.lastStatus)) { - this.emitStatusUpdate(watcher); - this.stopWatcher(key); - return; - } - if (run) { - this.emitStatusUpdate(watcher); - } - this.scheduleReconnect(key, options.reconnectError, { - countAttempt: options.countReconnectAttempt ?? false, - }); - return; - } - - // All callers pass reconnectOnDisconnect, and durable watches only stop via the stream-end - // sentinel or a terminal legacy poll (both handled above); any other disconnect reconnects. - if (reconnectOnDisconnect) { - this.scheduleReconnect(key, options.reconnectError, { - countAttempt: options.countReconnectAttempt ?? false, - }); - } - } - - // Stops a watcher whose stream is durably complete. Repairs the displayed status if the stream - // ended non-terminal (dropped final frame); the poll never decides whether to stop. - private async finalizeWatcherStop(key: string): Promise { - const watcher = this.watchers.get(key); - if (!watcher) return; - - if (!isTerminalStatus(watcher.lastStatus)) { - const run = await this.fetchTaskRun(watcher); - const currentWatcher = this.watchers.get(key); - if (!currentWatcher || currentWatcher !== watcher) return; - if (run) { - this.applyTaskRunState(watcher, run); - } - } - - this.emitStatusUpdate(watcher); - this.stopWatcher(key); - } - - private applyTaskRunState( - watcher: WatcherState, - run: - | Pick< - TaskRunResponse, - | "status" - | "stage" - | "output" - | "state" - | "error_message" - | "branch" - | "updated_at" - > - | TaskRunStateEvent, - ): boolean { - const updatedAt = run.updated_at ?? null; - if ( - updatedAt && - watcher.lastStatusUpdatedAt && - Date.parse(updatedAt) <= Date.parse(watcher.lastStatusUpdatedAt) - ) { - return false; - } - - const nextStatus = run.status ?? watcher.lastStatus; - const nextStage = run.stage ?? null; - const nextOutput = run.output ?? null; - const nextErrorMessage = run.error_message ?? null; - const nextBranch = run.branch ?? null; - const sandboxAlive = extractSandboxAlive(run.state); - const nextSandboxAlive = - sandboxAlive === undefined ? watcher.lastSandboxAlive : sandboxAlive; - - const changed = - nextStatus !== watcher.lastStatus || - nextStage !== watcher.lastStage || - JSON.stringify(nextOutput) !== JSON.stringify(watcher.lastOutput) || - nextErrorMessage !== watcher.lastErrorMessage || - nextBranch !== watcher.lastBranch || - nextSandboxAlive !== watcher.lastSandboxAlive; - - watcher.lastStatus = nextStatus ?? null; - watcher.lastStage = nextStage; - watcher.lastOutput = nextOutput; - watcher.lastErrorMessage = nextErrorMessage; - watcher.lastBranch = nextBranch; - watcher.lastSandboxAlive = nextSandboxAlive; - if (updatedAt) { - watcher.lastStatusUpdatedAt = updatedAt; - } - - // A terminal run gets no further relay requests; drop its designation and - // approval state so the maps don't grow for the lifetime of the app session. - if (isTerminalStatus(watcher.lastStatus)) { - this.relayDesignations.delete(watcher.runId); - this.evictRelayApprovalState(watcher.runId); - } - - return changed; - } - - private async fetchSessionLogsPage( - watcher: WatcherState, - offset: number, - ): Promise { - const url = new URL( - `${watcher.apiHost}/api/projects/${watcher.teamId}/tasks/${watcher.taskId}/runs/${watcher.runId}/session_logs/`, - ); - url.searchParams.set("limit", SESSION_LOG_PAGE_LIMIT.toString()); - url.searchParams.set("offset", offset.toString()); - - try { - const authedResponse = await this.auth.authenticatedFetch( - url.toString(), - { - method: "GET", - }, - ); - - if (!authedResponse.ok) { - this.log.warn("Cloud task session logs fetch failed", { - status: authedResponse.status, - taskId: watcher.taskId, - runId: watcher.runId, - offset, - }); - if (shouldFailWatcherForFetchStatus(authedResponse.status)) { - this.failWatcher( - watcherKey(watcher.taskId, watcher.runId), - createStreamStatusError(authedResponse.status).details, - ); - } - return null; - } - - const raw = await authedResponse.text(); - return { - entries: JSON.parse(raw) as StoredLogEntry[], - hasMore: authedResponse.headers.get("X-Has-More") === "true", - }; - } catch (error) { - this.log.warn("Cloud task session logs fetch error", { - taskId: watcher.taskId, - runId: watcher.runId, - offset, - error, - }); - return null; - } - } - - private async fetchAllSessionLogs( - watcher: WatcherState, - ): Promise { - const entries: StoredLogEntry[] = []; - let offset = 0; - - while (true) { - const page = await this.fetchSessionLogsPage(watcher, offset); - if (!page) { - return null; - } - - entries.push(...page.entries); - if (!page.hasMore || page.entries.length === 0) { - return entries; - } - - offset += page.entries.length; - } - } - - private async resolveStreamTarget(watcher: WatcherState): Promise { - const url = `${watcher.apiHost}/api/projects/${watcher.teamId}/tasks/${watcher.taskId}/runs/${watcher.runId}/stream_token/`; - try { - const response = await this.auth.authenticatedFetch(url, { - method: "GET", - }); - if (!response.ok) { - watcher.streamBaseUrl = null; - watcher.streamReadToken = null; - if (isTransientStreamTargetStatus(response.status)) { - // Transient: read from Django this round but leave the target unresolved so the next - // reconnect retries durable resolution instead of pinning the run to status polling. - this.log.warn("Cloud task stream target temporarily unavailable", { - taskId: watcher.taskId, - runId: watcher.runId, - status: response.status, - }); - return; - } - // Refused, or an old server without the endpoint: read from Django with status polling. - watcher.durableStreamEnabled = false; - watcher.streamTargetResolved = true; - this.log.info("Cloud task stream reading from API host", { - taskId: watcher.taskId, - runId: watcher.runId, - status: response.status, - }); - return; - } - const data = (await response.json()) as { - token?: string; - stream_base_url?: string | null; - }; - watcher.streamReadToken = data.token ?? null; - watcher.streamBaseUrl = data.stream_base_url ?? null; - // The endpoint resolving at all opts this watcher into the status-unaware contract; - // old servers 404 above and stay on legacy status polling. - watcher.durableStreamEnabled = true; - watcher.streamTargetResolved = true; - this.log.info("Cloud task stream target resolved", { - taskId: watcher.taskId, - runId: watcher.runId, - streamBaseUrl: watcher.streamBaseUrl, - hasToken: Boolean(watcher.streamReadToken), - durableStream: watcher.durableStreamEnabled, - }); - } catch (error) { - // Transient failure: leave unresolved so the next reconnect retries and falls back to Django. - watcher.streamBaseUrl = null; - watcher.streamReadToken = null; - this.log.warn("Cloud task stream target resolution failed", { - taskId: watcher.taskId, - runId: watcher.runId, - error, - }); - } - } - - private async fetchTaskRun( - watcher: WatcherState, - ): Promise { - const url = `${watcher.apiHost}/api/projects/${watcher.teamId}/tasks/${watcher.taskId}/runs/${watcher.runId}/`; - - try { - const authedResponse = await this.auth.authenticatedFetch(url, { - method: "GET", - }); - - if (!authedResponse.ok) { - this.log.warn("Cloud task status fetch failed", { - status: authedResponse.status, - taskId: watcher.taskId, - runId: watcher.runId, - }); - if (shouldFailWatcherForFetchStatus(authedResponse.status)) { - this.failWatcher( - watcherKey(watcher.taskId, watcher.runId), - createStreamStatusError(authedResponse.status).details, - ); - } - return null; - } - - return (await authedResponse.json()) as TaskRunResponse; - } catch (error) { - this.log.warn("Cloud task status fetch error", { - taskId: watcher.taskId, - runId: watcher.runId, - error, - }); - return null; - } + override unwatchAll(): void { + super.unwatchAll(); } } diff --git a/packages/core/src/cloud-task/schemas.ts b/packages/core/src/cloud-task/schemas.ts index d694e52141..b8c03eb202 100644 --- a/packages/core/src/cloud-task/schemas.ts +++ b/packages/core/src/cloud-task/schemas.ts @@ -1,20 +1,12 @@ -import type { TaskRunStatus } from "@posthog/shared"; +import type { CloudTaskUpdatePayload } from "@posthog/shared"; import { z } from "zod"; -import type { CloudTaskUpdatePayload } from "./cloud-task-types"; -export type { CloudTaskUpdatePayload, TaskRunStatus }; - -export const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const; - -export function isTerminalStatus( - status: TaskRunStatus | string | null | undefined, -): boolean { - return ( - status !== null && - status !== undefined && - TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]) - ); -} +export { + type CloudTaskUpdatePayload, + isTerminalStatus, + type TaskRunStatus, + TERMINAL_STATUSES, +} from "@posthog/shared"; // --- Events --- diff --git a/packages/core/src/inbox/artefacts.test.ts b/packages/core/src/inbox/artefacts.test.ts index a4e972613e..3d733ffbd3 100644 --- a/packages/core/src/inbox/artefacts.test.ts +++ b/packages/core/src/inbox/artefacts.test.ts @@ -1,11 +1,43 @@ -import type { SuggestedReviewer } from "@posthog/shared/types"; +import type { + AvailableSuggestedReviewer, + SuggestedReviewer, +} from "@posthog/shared/types"; import { describe, expect, it } from "vitest"; import { + buildReviewerOptions, extractSuggestedReviewers, + orderSuggestedReviewers, reviewerInitials, + reviewerMatchesAvailable, + reviewerOptionLabel, suggestedReviewerDisplayName, + toSuggestedReviewerWriteContent, } from "./artefacts"; +function makeReviewer( + partial: Partial = {}, +): SuggestedReviewer { + return { + github_login: "octocat", + github_name: "The Octocat", + relevant_commits: [], + user: null, + ...partial, + }; +} + +function makeAvailableReviewer( + partial: Partial = {}, +): AvailableSuggestedReviewer { + return { + uuid: "uuid-1", + name: "Ada Lovelace", + email: "ada@example.com", + github_login: "ada", + ...partial, + }; +} + describe("artefacts", () => { it("extracts suggested reviewers from artefacts", () => { const reviewers: SuggestedReviewer[] = [ @@ -46,4 +78,129 @@ describe("artefacts", () => { expect(reviewerInitials("Ben W.", null)).toBe("BW"); expect(reviewerInitials("", "ben@posthog.com")).toBe("BE"); }); + + it("moves the current user to the front", () => { + const reviewers = [ + makeReviewer({ + github_login: "a", + user: { + id: 1, + uuid: "uuid-a", + email: "a@posthog.com", + first_name: "a", + last_name: "", + }, + }), + makeReviewer({ + github_login: "me", + user: { + id: 2, + uuid: "uuid-me", + email: "me@posthog.com", + first_name: "me", + last_name: "", + }, + }), + ]; + + expect( + orderSuggestedReviewers(reviewers, "uuid-me").map( + (reviewer) => reviewer.github_login, + ), + ).toEqual(["me", "a"]); + }); + + it("deduplicates reviewer options and pins the current user first", () => { + const options = buildReviewerOptions( + [ + makeAvailableReviewer({ uuid: "b", name: "Bob" }), + makeAvailableReviewer({ uuid: "a", name: "Ada" }), + makeAvailableReviewer({ uuid: "a", name: "Ada duplicate" }), + ], + "b", + ); + + expect(options.map((option) => option.uuid)).toEqual(["b", "a"]); + }); + + it("labels the current reviewer", () => { + expect( + reviewerOptionLabel({ + uuid: "uuid-me", + name: "Ada", + email: "ada@example.com", + github_login: "ada", + isMe: true, + }), + ).toBe("Ada (Me)"); + }); + + it.each([ + { + name: "user uuid", + reviewer: makeReviewer({ + github_login: "", + user: { + id: 1, + uuid: "uuid-1", + email: "", + first_name: "", + last_name: "", + }, + }), + expected: true, + }, + { + name: "case-insensitive GitHub login", + reviewer: makeReviewer({ github_login: "ADA" }), + expected: true, + }, + { + name: "different reviewer", + reviewer: makeReviewer(), + expected: false, + }, + ])("matches an available reviewer by $name", ({ reviewer, expected }) => { + expect(reviewerMatchesAvailable(reviewer, makeAvailableReviewer())).toBe( + expected, + ); + }); + + it.each([ + { + name: "GitHub login", + reviewer: makeReviewer({ + github_login: "ada", + user: { + id: 1, + uuid: "uuid-1", + email: "", + first_name: "", + last_name: "", + }, + }), + expected: [{ github_login: "ada" }], + }, + { + name: "user uuid fallback", + reviewer: makeReviewer({ + github_login: "", + user: { + id: 1, + uuid: "uuid-1", + email: "", + first_name: "", + last_name: "", + }, + }), + expected: [{ user_uuid: "uuid-1" }], + }, + { + name: "unresolved reviewer", + reviewer: makeReviewer({ github_login: "" }), + expected: [], + }, + ])("builds write content from the $name", ({ reviewer, expected }) => { + expect(toSuggestedReviewerWriteContent([reviewer])).toEqual(expected); + }); }); diff --git a/packages/core/src/inbox/artefacts.ts b/packages/core/src/inbox/artefacts.ts index 1978f710bc..dc5b2eddb2 100644 --- a/packages/core/src/inbox/artefacts.ts +++ b/packages/core/src/inbox/artefacts.ts @@ -1,8 +1,18 @@ import type { + AvailableSuggestedReviewer, RepoSelectionArtefact, SuggestedReviewer, + SuggestedReviewerWriteEntry, } from "@posthog/shared/types"; +export interface ReviewerOption { + uuid: string; + name: string; + email: string; + github_login: string; + isMe: boolean; +} + function hasRepositoryContent( content: unknown, ): content is RepoSelectionArtefact["content"] { @@ -48,6 +58,82 @@ export function extractSuggestedReviewers( return artefact?.content ?? []; } +export function orderSuggestedReviewers( + reviewers: SuggestedReviewer[], + currentUserUuid: string | null | undefined, +): SuggestedReviewer[] { + if (!currentUserUuid) return reviewers; + const currentUserIndex = reviewers.findIndex( + (reviewer) => reviewer.user?.uuid === currentUserUuid, + ); + if (currentUserIndex <= 0) return reviewers; + return [ + reviewers[currentUserIndex], + ...reviewers.filter((_, index) => index !== currentUserIndex), + ]; +} + +export function buildReviewerOptions( + reviewers: AvailableSuggestedReviewer[], + currentUserUuid: string | undefined, +): ReviewerOption[] { + const seen = new Set(); + const options: ReviewerOption[] = []; + + for (const reviewer of reviewers) { + if (!reviewer.uuid || seen.has(reviewer.uuid)) continue; + seen.add(reviewer.uuid); + options.push({ + uuid: reviewer.uuid, + name: reviewer.name?.trim() || "", + email: reviewer.email?.trim() || "", + github_login: reviewer.github_login?.trim() || "", + isMe: reviewer.uuid === currentUserUuid, + }); + } + + options.sort((first, second) => { + if (first.isMe && !second.isMe) return -1; + if (!first.isMe && second.isMe) return 1; + return (first.name || first.email).localeCompare( + second.name || second.email, + ); + }); + + return options; +} + +export function reviewerOptionLabel(reviewer: ReviewerOption): string { + const base = reviewer.name || reviewer.email || "Unknown user"; + return reviewer.isMe ? `${base} (Me)` : base; +} + +export function reviewerMatchesAvailable( + reviewer: SuggestedReviewer, + available: AvailableSuggestedReviewer, +): boolean { + if (reviewer.user?.uuid && reviewer.user.uuid === available.uuid) { + return true; + } + return ( + !!reviewer.github_login && + !!available.github_login && + reviewer.github_login.toLowerCase() === available.github_login.toLowerCase() + ); +} + +export function toSuggestedReviewerWriteContent( + reviewers: SuggestedReviewer[], +): SuggestedReviewerWriteEntry[] { + return reviewers + .map((reviewer): SuggestedReviewerWriteEntry | null => { + if (reviewer.github_login) return { github_login: reviewer.github_login }; + if (reviewer.user?.uuid) return { user_uuid: reviewer.user.uuid }; + return null; + }) + .filter((entry): entry is SuggestedReviewerWriteEntry => entry !== null); +} + const AVATAR_PALETTE = [ "bg-(--orange-9) text-white", "bg-(--blue-9) text-white", diff --git a/packages/core/src/inbox/reportFiltering.test.ts b/packages/core/src/inbox/reportFiltering.test.ts index f2be318fac..3362ca5cde 100644 --- a/packages/core/src/inbox/reportFiltering.test.ts +++ b/packages/core/src/inbox/reportFiltering.test.ts @@ -6,8 +6,18 @@ import { buildSignalReportListOrdering, buildSuggestedReviewerFilterParam, filterReportsBySearch, + INBOX_PIPELINE_STATUS_FILTER, + INBOX_PIPELINE_STATUSES, } from "./reportFiltering"; +describe("inbox pipeline statuses", () => { + it("derives the API filter from the typed status list", () => { + expect(INBOX_PIPELINE_STATUS_FILTER).toBe( + INBOX_PIPELINE_STATUSES.join(","), + ); + }); +}); + function makeReport(overrides: Partial = {}): SignalReport { return { id: "1", diff --git a/packages/core/src/inbox/reportFiltering.ts b/packages/core/src/inbox/reportFiltering.ts index 06d36038b5..2a8271b2cb 100644 --- a/packages/core/src/inbox/reportFiltering.ts +++ b/packages/core/src/inbox/reportFiltering.ts @@ -5,12 +5,20 @@ import type { SignalReportStatus, } from "@posthog/shared/types"; +export const INBOX_PIPELINE_STATUSES = [ + "ready", + "pending_input", + "in_progress", + "failed", + "candidate", + "potential", +] as const satisfies readonly SignalReportStatus[]; + /** * Comma-separated statuses for the inbox query. We pull `failed` so the Runs * tab can surface failed runs in its Recently finished section. */ -export const INBOX_PIPELINE_STATUS_FILTER = - "potential,candidate,in_progress,ready,pending_input,failed"; +export const INBOX_PIPELINE_STATUS_FILTER = INBOX_PIPELINE_STATUSES.join(","); /** * Status filter for the Archive tab — the two terminal, not-in-inbox states: diff --git a/packages/core/src/integrations/repositories.test.ts b/packages/core/src/integrations/repositories.test.ts index ce6af19540..80eec8ad36 100644 --- a/packages/core/src/integrations/repositories.test.ts +++ b/packages/core/src/integrations/repositories.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + buildTeamRepositoryOptions, + buildUserRepositoryOptions, combineGithubRepositories, combineRepositoryPicker, combineUserGithubRepositories, @@ -7,8 +9,11 @@ import { isEmptyRepositoryMap, isRepoInIntegration, normalizeRepoKey, + normalizeRepositoryNames, type RepositoryCacheAction, type RepositoryQueryResult, + repositoryLoadWarning, + repositoryOptionsEqual, resolveEffectiveUserRepositoryMap, resolveUserRepositoryCacheAction, sameUserRepositoryMap, @@ -18,6 +23,67 @@ import { type UserRepositoryIntegrationRef, } from "./repositories"; +describe("repository options", () => { + it("normalizes repository names", () => { + expect(normalizeRepositoryNames(["PostHog/Code", ""])).toEqual([ + "posthog/code", + ]); + }); + + it("builds sorted team options with integration labels", () => { + expect( + buildTeamRepositoryOptions( + [ + { id: 2, display_name: "Work" }, + { id: 1, config: { account: { login: "personal" } } }, + ], + { 1: ["z/repo"], 2: ["a/repo"] }, + ), + ).toEqual([ + { integrationId: 2, integrationLabel: "Work", repository: "a/repo" }, + { + integrationId: 1, + integrationLabel: "personal", + repository: "z/repo", + }, + ]); + }); + + it("builds user options with the same shape", () => { + expect( + buildUserRepositoryOptions( + [{ id: "user-1", installation_id: "42", account: { name: "Me" } }], + { 42: ["posthog/code"] }, + ), + ).toEqual([ + { integrationId: 42, integrationLabel: "Me", repository: "posthog/code" }, + ]); + }); + + it.each([ + [0, 2, null], + [1, 2, "Some GitHub repositories could not be loaded. Pull to retry."], + [2, 2, "Could not load GitHub repositories. Pull to retry."], + ])( + "describes %i of %i failed repository loads", + (failed, total, expected) => { + expect(repositoryLoadWarning(failed, total)).toBe(expected); + }, + ); + + it("compares option lists by content", () => { + const options = [ + { integrationId: 1, integrationLabel: "Me", repository: "a/repo" }, + ]; + expect( + repositoryOptionsEqual( + options, + options.map((option) => ({ ...option })), + ), + ).toBe(true); + }); +}); + function result( data: T | undefined, flags: Partial, "data">> = {}, diff --git a/packages/core/src/integrations/repositories.ts b/packages/core/src/integrations/repositories.ts index 43a7fbf74e..ea9bb6d202 100644 --- a/packages/core/src/integrations/repositories.ts +++ b/packages/core/src/integrations/repositories.ts @@ -5,6 +5,130 @@ export interface RepositoryQueryResult { isRefetching: boolean; } +export interface RepositoryOption { + integrationId: number; + integrationLabel: string; + repository: string; +} + +export interface RepositorySelection { + integrationId: number | null; + repository: string | null; +} + +export interface TeamRepositoryIntegration { + id: number; + display_name?: string; + config?: { account?: { login?: string } }; +} + +export interface UserRepositoryIntegration { + id: string; + installation_id: string; + account?: { name?: string | null } | null; +} + +export function normalizeRepositoryNames( + repositories: ReadonlyArray, +): string[] { + return repositories + .map((repository) => repository.toLowerCase()) + .filter((repository) => repository.length > 0); +} + +export function repositoryLoadWarning( + failedCount: number, + totalCount: number, +): string | null { + if (failedCount === 0) return null; + return failedCount === totalCount + ? "Could not load GitHub repositories. Pull to retry." + : "Some GitHub repositories could not be loaded. Pull to retry."; +} + +export function buildTeamRepositoryOptions( + integrations: ReadonlyArray, + repositoriesByIntegration: Readonly>, +): RepositoryOption[] { + return integrations + .flatMap((integration) => + (repositoriesByIntegration[integration.id] ?? []).map((repository) => ({ + integrationId: integration.id, + integrationLabel: + integration.display_name ?? + integration.config?.account?.login ?? + `GitHub ${integration.id}`, + repository, + })), + ) + .sort((left, right) => left.repository.localeCompare(right.repository)); +} + +export function buildUserRepositoryOptions( + integrations: ReadonlyArray, + repositoriesByInstallation: Readonly>, +): RepositoryOption[] { + return integrations + .flatMap((integration) => + (repositoriesByInstallation[integration.installation_id] ?? []).map( + (repository) => ({ + integrationId: Number(integration.installation_id), + integrationLabel: + integration.account?.name ?? + `GitHub ${integration.installation_id}`, + repository, + }), + ), + ) + .sort((left, right) => left.repository.localeCompare(right.repository)); +} + +export function repositoryOptionsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((option, index) => { + const other = right[index]; + return ( + other?.integrationId === option.integrationId && + other.integrationLabel === option.integrationLabel && + other.repository === option.repository + ); + }) + ); +} + +export function findRepositoryOption( + options: ReadonlyArray, + selection: RepositorySelection, +): RepositoryOption | null { + if (!selection.integrationId || !selection.repository) return null; + return ( + options.find( + (option) => + option.integrationId === selection.integrationId && + option.repository === selection.repository, + ) ?? null + ); +} + +export function toRepositorySelection( + option: RepositoryOption | null, +): RepositorySelection { + return { + integrationId: option?.integrationId ?? null, + repository: option?.repository ?? null, + }; +} + +export function isRepositorySelectionComplete( + selection: RepositorySelection, +): boolean { + return !!selection.integrationId && !!selection.repository; +} + export interface TeamRepositoriesResult { integrationId: number; repos?: string[] | null; diff --git a/packages/core/src/sessions/cloudSessionConfig.ts b/packages/core/src/sessions/cloudSessionConfig.ts index ecb989b446..b6a9e4f8f4 100644 --- a/packages/core/src/sessions/cloudSessionConfig.ts +++ b/packages/core/src/sessions/cloudSessionConfig.ts @@ -1,6 +1,10 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import type { Adapter, StoredLogEntry } from "@posthog/shared"; -import { getAvailableCodexModes, getAvailableModes } from "./executionModes"; +import { + DEFAULT_CLAUDE_EXECUTION_MODE, + getAvailableCodexModes, + getAvailableModes, +} from "./executionModes"; /** * Pure derivations of cloud session config options. No store or host access — @@ -54,7 +58,8 @@ export function buildCloudDefaultConfigOptions( ): SessionConfigOption[] { const modes = adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); - const fallbackMode = adapter === "codex" ? "auto" : "plan"; + const fallbackMode = + adapter === "codex" ? "auto" : DEFAULT_CLAUDE_EXECUTION_MODE; const currentMode = typeof initialMode === "string" && modes.some((mode) => mode.id === initialMode) diff --git a/packages/core/src/sessions/executionModes.ts b/packages/core/src/sessions/executionModes.ts index 4a32413c12..2ccbadf354 100644 --- a/packages/core/src/sessions/executionModes.ts +++ b/packages/core/src/sessions/executionModes.ts @@ -1,4 +1,4 @@ -import { CODEX_MODE_PRESETS } from "@posthog/shared"; +import { CODEX_MODE_PRESETS, type ExecutionMode } from "@posthog/shared"; export interface ModeInfo { id: string; @@ -6,6 +6,8 @@ export interface ModeInfo { description: string; } +export const DEFAULT_CLAUDE_EXECUTION_MODE: ExecutionMode = "plan"; + const availableModes: ModeInfo[] = [ { id: "default", diff --git a/packages/core/src/sessions/portableSessionEvents.test.ts b/packages/core/src/sessions/portableSessionEvents.test.ts new file mode 100644 index 0000000000..d777a0ec8b --- /dev/null +++ b/packages/core/src/sessions/portableSessionEvents.test.ts @@ -0,0 +1,71 @@ +import type { StoredLogEntry } from "@posthog/shared"; +import { describe, expect, it, vi } from "vitest"; +import { + convertStoredEntriesToPortableSessionEvents, + inferStoredLogEntryDirection, +} from "./portableSessionEvents"; + +describe("inferStoredLogEntryDirection", () => { + it.each([ + [ + "client requests", + { notification: { id: 1, method: "session/prompt" } }, + "client", + ], + ["agent responses", { notification: { id: 1, result: {} } }, "agent"], + [ + "agent notifications", + { notification: { method: "session/update" } }, + "agent", + ], + ["missing messages", {}, "agent"], + ] as const)("classifies %s", (_name, entry, expected) => { + expect(inferStoredLogEntryDirection(entry as StoredLogEntry)).toBe( + expected, + ); + }); +}); + +describe("convertStoredEntriesToPortableSessionEvents", () => { + it("projects session updates alongside their raw ACP message", () => { + const notification = { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "hello" }, + }, + }; + const events = convertStoredEntriesToPortableSessionEvents([ + { + type: "notification", + timestamp: "2026-07-21T12:00:00.000Z", + notification: { method: "session/update", params: notification }, + }, + ]); + + expect(events).toEqual([ + { + type: "acp_message", + direction: "agent", + ts: 1_784_635_200_000, + message: { method: "session/update", params: notification }, + }, + { + type: "session_update", + ts: 1_784_635_200_000, + notification, + }, + ]); + }); + + it("uses the current time when an entry has no timestamp", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-21T12:00:00.000Z")); + + const events = convertStoredEntriesToPortableSessionEvents([ + { type: "response", notification: { id: 1, result: {} } }, + ]); + + expect(events[0]?.ts).toBe(1_784_635_200_000); + vi.useRealTimers(); + }); +}); diff --git a/packages/core/src/sessions/portableSessionEvents.ts b/packages/core/src/sessions/portableSessionEvents.ts new file mode 100644 index 0000000000..5ee36c7642 --- /dev/null +++ b/packages/core/src/sessions/portableSessionEvents.ts @@ -0,0 +1,98 @@ +import type { JsonRpcMessage, StoredLogEntry } from "@posthog/shared"; + +export type PortableSessionToolCallStatus = + | "pending" + | "in_progress" + | "completed" + | "failed" + | null; + +export interface PortableSessionUpdate { + sessionUpdate?: string; + content?: { type: string; text: string }; + attachments?: Array<{ + kind: "image" | "document"; + uri: string; + fileName: string; + mimeType?: string; + }>; + title?: string; + toolCallId?: string; + status?: PortableSessionToolCallStatus; + rawInput?: Record; + rawOutput?: unknown; + entries?: Array<{ + content: string; + status: "pending" | "in_progress" | "completed" | "failed"; + priority: string; + }>; + _meta?: { + claudeCode?: { + toolName?: string; + parentToolCallId?: string; + }; + }; +} + +export interface PortableSessionNotification { + update?: PortableSessionUpdate; +} + +export interface PortableSessionAcpMessage { + type: "acp_message"; + direction: "client" | "agent"; + ts: number; + message: JsonRpcMessage; +} + +export interface PortableSessionUpdateEvent { + type: "session_update"; + ts: number; + notification: PortableSessionNotification; +} + +export type PortableSessionEvent = + | PortableSessionAcpMessage + | PortableSessionUpdateEvent; + +export function inferStoredLogEntryDirection( + entry: StoredLogEntry, +): "client" | "agent" { + const message = entry.notification; + if (!message) return "agent"; + if (message.id !== undefined && message.method !== undefined) return "client"; + return "agent"; +} + +export function convertStoredEntriesToPortableSessionEvents( + entries: readonly StoredLogEntry[], +): PortableSessionEvent[] { + const events: PortableSessionEvent[] = []; + + for (const entry of entries) { + const ts = entry.timestamp + ? new Date(entry.timestamp).getTime() + : Date.now(); + + events.push({ + type: "acp_message", + direction: inferStoredLogEntryDirection(entry), + ts, + message: (entry.notification ?? {}) as JsonRpcMessage, + }); + + if ( + entry.type === "notification" && + entry.notification?.method === "session/update" && + entry.notification.params + ) { + events.push({ + type: "session_update", + ts, + notification: entry.notification.params as PortableSessionNotification, + }); + } + } + + return events; +} diff --git a/packages/core/src/sessions/sessionActivity.test.ts b/packages/core/src/sessions/sessionActivity.test.ts new file mode 100644 index 0000000000..db795f2118 --- /dev/null +++ b/packages/core/src/sessions/sessionActivity.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import type { + PortableSessionEvent, + PortableSessionToolCallStatus, +} from "./portableSessionEvents"; +import { + countUserMessages, + getSessionActivityPhase, + isSessionAwaitingUserInput, +} from "./sessionActivity"; + +function userMessage(ts = 1): PortableSessionEvent { + return { + type: "session_update", + ts, + notification: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "Yes" }, + }, + }, + }; +} + +function questionToolCall( + status: PortableSessionToolCallStatus, + sessionUpdate = "tool_call", +): PortableSessionEvent { + return { + type: "session_update", + ts: 1, + notification: { + update: { + sessionUpdate, + toolCallId: "question-1", + status, + rawInput: { questions: [{ question: "Proceed?", options: [] }] }, + _meta: { claudeCode: { toolName: "AskUserQuestion" } }, + }, + }, + }; +} + +function acpNotification(method: string): PortableSessionEvent { + return { + type: "acp_message", + direction: "agent", + ts: 1, + message: { method }, + }; +} + +describe("isSessionAwaitingUserInput", () => { + it("tracks question tools until a metadata-free completion update", () => { + const completion: PortableSessionEvent = { + type: "session_update", + ts: 2, + notification: { + update: { + sessionUpdate: "tool_call_update", + toolCallId: "question-1", + status: "completed", + }, + }, + }; + + expect( + isSessionAwaitingUserInput([questionToolCall("pending"), completion]), + ).toBe(false); + }); + + it("clears questions when the user responds", () => { + expect( + isSessionAwaitingUserInput([questionToolCall("pending"), userMessage(2)]), + ).toBe(false); + }); + + it("honors explicit waiting and terminal backend markers", () => { + expect( + isSessionAwaitingUserInput([ + acpNotification("_posthog/awaiting_user_input"), + ]), + ).toBe(true); + expect( + isSessionAwaitingUserInput([ + acpNotification("_posthog/awaiting_user_input"), + acpNotification("_posthog/turn_complete"), + ]), + ).toBe(false); + }); +}); + +describe("countUserMessages", () => { + it("counts only projected user message updates", () => { + expect( + countUserMessages([ + userMessage(), + questionToolCall("pending"), + userMessage(2), + ]), + ).toBe(2); + }); +}); + +describe("getSessionActivityPhase", () => { + it.each([ + ["retrying", true, undefined, "connecting"], + [ + "awaiting agent output", + false, + { isPromptPending: true, awaitingAgentOutput: true }, + "connecting", + ], + [ + "working", + false, + { isPromptPending: true, awaitingAgentOutput: false }, + "working", + ], + [ + "not pending", + false, + { isPromptPending: false, awaitingAgentOutput: false }, + "idle", + ], + [ + "terminal", + false, + { + isPromptPending: true, + awaitingAgentOutput: false, + terminalStatus: "completed" as const, + }, + "idle", + ], + [ + "waiting for user", + false, + { + isPromptPending: true, + awaitingAgentOutput: false, + events: [questionToolCall("pending")], + }, + "idle", + ], + ] as const)( + "returns the expected phase while %s", + (_name, retrying, session, expected) => { + expect(getSessionActivityPhase({ retrying, session })).toBe(expected); + }, + ); +}); diff --git a/packages/core/src/sessions/sessionActivity.ts b/packages/core/src/sessions/sessionActivity.ts new file mode 100644 index 0000000000..6e2804ff47 --- /dev/null +++ b/packages/core/src/sessions/sessionActivity.ts @@ -0,0 +1,129 @@ +import { isNotification, POSTHOG_NOTIFICATIONS } from "./acpNotifications"; +import type { + PortableSessionEvent, + PortableSessionNotification, + PortableSessionToolCallStatus, +} from "./portableSessionEvents"; + +export type SessionActivityPhase = "idle" | "connecting" | "working"; + +export interface SessionActivityState { + isPromptPending?: boolean; + awaitingAgentOutput?: boolean; + terminalStatus?: "failed" | "completed"; + events?: readonly PortableSessionEvent[]; +} + +function isQuestionNotification( + notification: PortableSessionNotification, +): boolean { + const update = notification.update; + if (!update) return false; + + const rawToolName = update._meta?.claudeCode?.toolName; + if (typeof rawToolName === "string" && /question/i.test(rawToolName)) { + return true; + } + + const rawInput = update.rawInput; + if (!rawInput) return false; + if (Array.isArray(rawInput.questions)) return true; + + const nestedInput = rawInput.input; + return ( + typeof nestedInput === "object" && + nestedInput !== null && + Array.isArray((nestedInput as { questions?: unknown }).questions) + ); +} + +function isPendingQuestionStatus( + status: PortableSessionToolCallStatus | undefined, +): boolean { + return status === null || status === "pending" || status === "in_progress"; +} + +export function isSessionAwaitingUserInput( + events: readonly PortableSessionEvent[] = [], +): boolean { + let awaitingUserInput = false; + const questionStatuses = new Map< + string, + PortableSessionToolCallStatus | undefined + >(); + + for (const event of events) { + if (event.type === "session_update") { + const update = event.notification.update; + const sessionUpdate = update?.sessionUpdate; + + if (sessionUpdate === "user_message_chunk") { + awaitingUserInput = false; + questionStatuses.clear(); + continue; + } + + if ( + sessionUpdate === "tool_call" || + sessionUpdate === "tool_call_update" + ) { + const toolCallId = update?.toolCallId; + const isKnownQuestion = toolCallId + ? questionStatuses.has(toolCallId) + : false; + if (!isKnownQuestion && !isQuestionNotification(event.notification)) { + continue; + } + + questionStatuses.set( + toolCallId ?? `question-${event.ts}`, + update?.status, + ); + awaitingUserInput = [...questionStatuses.values()].some( + isPendingQuestionStatus, + ); + } + + continue; + } + + const method = "method" in event.message ? event.message.method : undefined; + if (method === "_posthog/awaiting_user_input") { + awaitingUserInput = true; + continue; + } + + if ( + isNotification(method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE) || + isNotification(method, POSTHOG_NOTIFICATIONS.TASK_COMPLETE) || + isNotification(method, POSTHOG_NOTIFICATIONS.ERROR) + ) { + awaitingUserInput = false; + questionStatuses.clear(); + } + } + + return awaitingUserInput; +} + +export function countUserMessages( + events: readonly PortableSessionEvent[] = [], +): number { + return events.filter( + (event) => + event.type === "session_update" && + event.notification.update?.sessionUpdate === "user_message_chunk", + ).length; +} + +export function getSessionActivityPhase(args: { + retrying: boolean; + session?: SessionActivityState | null; +}): SessionActivityPhase { + const { retrying, session } = args; + + if (retrying) return "connecting"; + if (!session?.isPromptPending || session.terminalStatus) return "idle"; + if (isSessionAwaitingUserInput(session.events)) return "idle"; + return session.awaitingAgentOutput ? "connecting" : "working"; +} diff --git a/packages/core/src/tasks/taskActivity.test.ts b/packages/core/src/tasks/taskActivity.test.ts new file mode 100644 index 0000000000..35d1eadaff --- /dev/null +++ b/packages/core/src/tasks/taskActivity.test.ts @@ -0,0 +1,132 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { filterAndSortTasks, taskActivityTimestamp } from "./taskActivity"; + +function makeTask(overrides: Partial = {}): Task { + return { + id: "task-1", + task_number: 1, + slug: "task-1", + title: "A real task", + description: "Do the thing", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + origin_product: "tasks", + ...overrides, + }; +} + +describe("taskActivityTimestamp", () => { + it("uses creation time in created mode", () => { + const task = makeTask({ + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-03T00:00:00Z", + }); + + expect(taskActivityTimestamp(task, "created")).toBe( + new Date("2026-01-01T00:00:00Z").getTime(), + ); + }); + + it("uses the latest task or run update in updated mode", () => { + const task = makeTask({ + updated_at: "2026-01-02T00:00:00Z", + latest_run: { + id: "run-1", + task: "task-1", + team: 1, + branch: null, + status: "completed", + log_url: "", + error_message: null, + output: null, + state: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-04T00:00:00Z", + completed_at: "2026-01-04T00:00:00Z", + }, + }); + + expect(taskActivityTimestamp(task, "updated")).toBe( + new Date("2026-01-04T00:00:00Z").getTime(), + ); + }); +}); + +describe("filterAndSortTasks", () => { + it.each([ + { title: "", description: "" }, + { title: " ", description: "\n\t" }, + ])("hides contentless placeholder tasks", ({ title, description }) => { + const placeholder = makeTask({ id: "placeholder", title, description }); + const realTask = makeTask({ id: "real" }); + + expect( + filterAndSortTasks([placeholder, realTask], "updated", false, "").map( + (task) => task.id, + ), + ).toEqual(["real"]); + }); + + it("selects internal or external tasks", () => { + const externalTask = makeTask({ id: "external", internal: false }); + const internalTask = makeTask({ id: "internal", internal: true }); + + expect( + filterAndSortTasks( + [externalTask, internalTask], + "updated", + false, + "", + ).map((task) => task.id), + ).toEqual(["external"]); + expect( + filterAndSortTasks([externalTask, internalTask], "updated", true, "").map( + (task) => task.id, + ), + ).toEqual(["internal"]); + }); + + it.each([ + ["title", { title: "Fix Login" }], + ["slug", { slug: "fix-login" }], + ["description", { description: "Fix Login" }], + ] as const)("matches a case-insensitive %s filter", (_field, overrides) => { + const matchingTask = makeTask({ id: "matching", ...overrides }); + const otherTask = makeTask({ id: "other", title: "Unrelated" }); + + expect( + filterAndSortTasks( + [otherTask, matchingTask], + "updated", + false, + "LOGIN", + ).map((task) => task.id), + ).toEqual(["matching"]); + }); + + it("sorts by the selected activity timestamp without mutating input", () => { + const olderCreated = makeTask({ + id: "older-created", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-04T00:00:00Z", + }); + const newerCreated = makeTask({ + id: "newer-created", + created_at: "2026-01-02T00:00:00Z", + updated_at: "2026-01-03T00:00:00Z", + }); + const tasks = [olderCreated, newerCreated]; + + expect( + filterAndSortTasks(tasks, "created", false, "").map((task) => task.id), + ).toEqual(["newer-created", "older-created"]); + expect( + filterAndSortTasks(tasks, "updated", false, "").map((task) => task.id), + ).toEqual(["older-created", "newer-created"]); + expect(tasks.map((task) => task.id)).toEqual([ + "older-created", + "newer-created", + ]); + }); +}); diff --git a/packages/core/src/tasks/taskActivity.ts b/packages/core/src/tasks/taskActivity.ts new file mode 100644 index 0000000000..6a52168e63 --- /dev/null +++ b/packages/core/src/tasks/taskActivity.ts @@ -0,0 +1,45 @@ +import { isContentlessTask, type Task } from "@posthog/shared/domain-types"; + +export type TaskActivitySortMode = "created" | "updated"; + +export function taskActivityTimestamp( + task: Pick, + sortMode: TaskActivitySortMode, +): number { + if (sortMode === "created") { + return new Date(task.created_at).getTime(); + } + + const runUpdatedAt = task.latest_run?.updated_at; + return Math.max( + runUpdatedAt ? new Date(runUpdatedAt).getTime() : 0, + new Date(task.updated_at ?? task.created_at).getTime(), + ); +} + +export function filterAndSortTasks( + tasks: readonly Task[], + sortMode: TaskActivitySortMode, + showInternal: boolean, + filter: string, +): Task[] { + const normalizedFilter = filter.toLowerCase(); + + return tasks + .filter((task) => !isContentlessTask(task)) + .filter((task) => + showInternal ? task.internal === true : task.internal !== true, + ) + .filter( + (task) => + !normalizedFilter || + task.title.toLowerCase().includes(normalizedFilter) || + task.slug.toLowerCase().includes(normalizedFilter) || + task.description?.toLowerCase().includes(normalizedFilter), + ) + .sort( + (firstTask, secondTask) => + taskActivityTimestamp(secondTask, sortMode) - + taskActivityTimestamp(firstTask, sortMode), + ); +} diff --git a/packages/core/src/tasks/taskArchive.test.ts b/packages/core/src/tasks/taskArchive.test.ts new file mode 100644 index 0000000000..ab22bbd459 --- /dev/null +++ b/packages/core/src/tasks/taskArchive.test.ts @@ -0,0 +1,44 @@ +import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { isTaskRunning } from "./taskArchive"; + +function makeTask(status?: TaskRunStatus): Pick { + return { + latest_run: status + ? { + id: "run-1", + task: "task-1", + team: 1, + branch: null, + status, + log_url: "", + error_message: null, + output: null, + state: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + completed_at: null, + } + : undefined, + }; +} + +describe("isTaskRunning", () => { + it("returns false when a task has no run", () => { + expect(isTaskRunning(makeTask())).toBe(false); + }); + + it.each(["not_started", "queued", "in_progress"] as const)( + "returns true for %s", + (status) => { + expect(isTaskRunning(makeTask(status))).toBe(true); + }, + ); + + it.each(["completed", "failed", "cancelled"] as const)( + "returns false for %s", + (status) => { + expect(isTaskRunning(makeTask(status))).toBe(false); + }, + ); +}); diff --git a/packages/core/src/tasks/taskArchive.ts b/packages/core/src/tasks/taskArchive.ts new file mode 100644 index 0000000000..db52bb6310 --- /dev/null +++ b/packages/core/src/tasks/taskArchive.ts @@ -0,0 +1,6 @@ +import { isTerminalStatus, type Task } from "@posthog/shared/domain-types"; + +export function isTaskRunning(task: Pick): boolean { + const status = task.latest_run?.status; + return status !== undefined && !isTerminalStatus(status); +} diff --git a/packages/core/src/tasks/taskStatusPresentation.test.ts b/packages/core/src/tasks/taskStatusPresentation.test.ts new file mode 100644 index 0000000000..a7895cf59e --- /dev/null +++ b/packages/core/src/tasks/taskStatusPresentation.test.ts @@ -0,0 +1,69 @@ +import type { Task, TaskRun } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { getTaskStatusPresentationKind } from "./taskStatusPresentation"; + +function makeTask(latestRun?: Partial): Pick { + return { + latest_run: latestRun + ? { + id: "run-1", + task: "task-1", + team: 1, + branch: null, + status: "not_started", + log_url: "", + error_message: null, + output: null, + state: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + completed_at: null, + ...latestRun, + } + : undefined, + }; +} + +describe("getTaskStatusPresentationKind", () => { + it("prioritizes a pull request over cloud presentation", () => { + expect( + getTaskStatusPresentationKind( + makeTask({ + environment: "cloud", + status: "in_progress", + output: { pr_url: "https://github.com/PostHog/code/pull/123" }, + }), + ), + ).toBe("pr"); + }); + + it.each([ + "not_started", + "queued", + "in_progress", + "completed", + "failed", + "cancelled", + ] as const)("uses chat presentation for cloud status %s", (status) => { + expect( + getTaskStatusPresentationKind(makeTask({ environment: "cloud", status })), + ).toBe("chat"); + }); + + it.each([ + ["completed", "completed"], + ["failed", "failed"], + ["in_progress", "running"], + ["queued", "started"], + ["not_started", "chat"], + ["cancelled", "chat"], + ] as const)("maps local status %s to %s", (status, expected) => { + expect( + getTaskStatusPresentationKind(makeTask({ environment: "local", status })), + ).toBe(expected); + }); + + it("falls back to chat when a task has no run", () => { + expect(getTaskStatusPresentationKind(makeTask())).toBe("chat"); + }); +}); diff --git a/packages/core/src/tasks/taskStatusPresentation.ts b/packages/core/src/tasks/taskStatusPresentation.ts new file mode 100644 index 0000000000..968a17bb76 --- /dev/null +++ b/packages/core/src/tasks/taskStatusPresentation.ts @@ -0,0 +1,37 @@ +import { readPrUrls } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; + +export type TaskStatusPresentationKind = + | "pr" + | "completed" + | "failed" + | "running" + | "started" + | "chat"; + +export function getTaskStatusPresentationKind( + task: Pick, +): TaskStatusPresentationKind { + const latestRun = task.latest_run; + + if (readPrUrls(latestRun?.output)[0]) { + return "pr"; + } + + if (latestRun?.environment === "cloud") { + return "chat"; + } + + switch (latestRun?.status) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "in_progress": + return "running"; + case "queued": + return "started"; + default: + return "chat"; + } +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000000..bed14b24e0 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vitest/config"; +import { trunkTestOptions } from "../../vitest.config.base"; + +export default defineConfig({ + oxc: false, + esbuild: { + tsconfigRaw: { + compilerOptions: { + experimentalDecorators: true, + target: "ES2022", + useDefineForClassFields: false, + verbatimModuleSyntax: true, + }, + }, + }, + test: { + globals: true, + ...trunkTestOptions, + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/packages/shared/src/cloud-task-models.test.ts b/packages/shared/src/cloud-task-models.test.ts new file mode 100644 index 0000000000..9039c29746 --- /dev/null +++ b/packages/shared/src/cloud-task-models.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from "vitest"; +import { + buildCloudTaskConfigOptions, + compareModelsForPicker, + formatGatewayModelName, + type GatewayModel, + getClaudeModelRecency, + isAnthropicModel, + isBlockedModelId, + isCloudflareModel, + isModalModel, + isModalModelId, + normalizeGatewayModelsResponse, + pickAllowedModel, +} from "./cloud-task-models"; + +const model = ( + id: string, + owned_by = "anthropic", + allowed = true, +): GatewayModel => ({ + id, + owned_by, + context_window: 128000, + supports_streaming: true, + supports_vision: false, + allowed, +}); + +describe("formatGatewayModelName", () => { + it.each([ + [model("claude-opus-4-8"), "Claude Opus 4.8"], + [model("GPT-5.5", "openai"), "GPT-5.5"], + [model("openai/gpt-5.6-sol", "openai"), "GPT-5.6 Sol"], + [model("moonshotai/kimi-k3", "modal"), "Kimi K3"], + [model("@cf/zai-org/glm-5.2", "cloudflare"), "GLM-5.2"], + [ + model("@cf/meta/llama-3.1-8b-instruct", "cloudflare"), + "llama-3.1-8b-instruct", + ], + ])("formats $id", (gatewayModel, expected) => { + expect(formatGatewayModelName(gatewayModel)).toBe(expected); + }); +}); + +describe("normalizeGatewayModelsResponse", () => { + it("corrects stale GLM 5.2 context-window metadata", () => { + const models = normalizeGatewayModelsResponse([ + model("@cf/zai-org/glm-5.2", "cloudflare"), + ]); + + expect(models[0]?.context_window).toBe(1_000_000); + }); +}); + +describe("isBlockedModelId", () => { + it.each([ + "claude-opus-4-5", + "claude-opus-4-6", + "claude-sonnet-4-5", + "ANTHROPIC/CLAUDE-HAIKU-4-5", + "gpt-5.2", + "gpt-5.3", + "gpt-5.3-codex", + "OPENAI/GPT-5.3-CODEX", + ])("blocks %s", (modelId) => { + expect(isBlockedModelId(modelId)).toBe(true); + }); +}); + +describe("getClaudeModelRecency", () => { + it.each([ + ["claude-haiku-4-5", 4005], + ["claude-sonnet-4-6", 4006], + ["claude-opus-4-7", 4007], + ["claude-opus-4-8", 4008], + ["claude-sonnet-5", 5000], + ])("ranks %s", (modelId, expected) => { + expect(getClaudeModelRecency(modelId)).toBe(expected); + }); + + it("ignores trailing dates and ranks unknown versions newest", () => { + expect(getClaudeModelRecency("claude-haiku-4-5-20251001")).toBe(4005); + expect(getClaudeModelRecency("claude-mystery")).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); +}); + +describe("compareModelsForPicker", () => { + it("groups by capability and sorts newest first", () => { + const displayed = [ + "claude-fable-5", + "claude-opus-4-7", + "claude-mystery", + "claude-sonnet-5", + "claude-haiku-4-5", + "claude-sonnet-4-6", + "claude-opus-4-8", + ].sort(compareModelsForPicker); + + expect(displayed).toEqual([ + "claude-fable-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-haiku-4-5", + "claude-mystery", + ]); + }); +}); + +describe("model classification", () => { + it("keeps Cloudflare models distinct from Anthropic", () => { + const gatewayModel = model("@cf/openai/gpt-oss", "openai"); + expect(isCloudflareModel(gatewayModel)).toBe(true); + expect(isAnthropicModel(gatewayModel)).toBe(false); + }); + + it("recognizes Modal models by owner and id", () => { + const gatewayModel = model("moonshotai/kimi-k3", "modal"); + expect(isModalModel(gatewayModel)).toBe(true); + expect(isModalModelId(gatewayModel.id)).toBe(true); + }); +}); + +describe("pickAllowedModel", () => { + const entry = (id: string, allowed: boolean) => ({ id, allowed }); + + it.each([ + [[entry("claude-opus-4-8", true)], "claude-opus-4-8", "claude-opus-4-8"], + [[entry("claude-opus-4-8", true)], "claude-sonnet-5", "claude-sonnet-5"], + [ + [ + entry("claude-opus-4-8", false), + entry("claude-sonnet-4-6", true), + entry("@cf/zai-org/glm-5.2", true), + ], + "claude-opus-4-8", + "@cf/zai-org/glm-5.2", + ], + [[entry("claude-opus-4-8", false)], "claude-opus-4-8", "claude-opus-4-8"], + [[], "claude-opus-4-8", "claude-opus-4-8"], + ] as const)("selects an allowed default", (models, preferred, expected) => { + expect(pickAllowedModel(models, preferred)).toBe(expected); + }); +}); + +describe("buildCloudTaskConfigOptions", () => { + it("builds Claude options with restrictions and reasoning policy", () => { + const options = buildCloudTaskConfigOptions( + [ + model("gpt-5.5", "openai"), + model("claude-opus-4-7", "anthropic"), + model("claude-opus-4-8", "anthropic", false), + model("@cf/zai-org/glm-5.2", "cloudflare"), + ], + "claude", + ); + + expect(options).toMatchObject([ + { id: "mode", currentValue: "plan" }, + { + id: "model", + currentValue: "@cf/zai-org/glm-5.2", + options: [ + { value: "claude-opus-4-7" }, + { + value: "claude-opus-4-8", + _meta: { "posthog.code/restrictedModel": true }, + }, + { value: "@cf/zai-org/glm-5.2" }, + ], + }, + { + id: "effort", + currentValue: "high", + options: [{ value: "high" }, { value: "max" }], + }, + ]); + expect(options.map((option) => option.id)).toEqual([ + "mode", + "model", + "effort", + ]); + }); + + it("builds Codex options with the shared default and reasoning levels", () => { + const options = buildCloudTaskConfigOptions( + [ + model("claude-opus-4-8"), + model("gpt-5.6", "openai"), + model("gpt-5.5", "openai"), + ], + "codex", + ); + + expect(options).toMatchObject([ + { id: "mode", currentValue: "auto" }, + { + id: "model", + currentValue: "gpt-5.5", + options: [{ value: "gpt-5.6" }, { value: "gpt-5.5" }], + }, + { + id: "reasoning_effort", + currentValue: "high", + options: [ + { value: "low" }, + { value: "medium" }, + { value: "high" }, + { value: "xhigh" }, + ], + }, + ]); + }); + + it("offers Modal models to Claude sessions", () => { + const options = buildCloudTaskConfigOptions( + [model("moonshotai/kimi-k3", "modal")], + "claude", + ); + + expect(options.find((option) => option.id === "model")?.options).toEqual([ + expect.objectContaining({ + value: "moonshotai/kimi-k3", + name: "Kimi K3", + }), + ]); + }); +}); diff --git a/packages/shared/src/cloud-task-models.ts b/packages/shared/src/cloud-task-models.ts new file mode 100644 index 0000000000..0375aa1033 --- /dev/null +++ b/packages/shared/src/cloud-task-models.ts @@ -0,0 +1,398 @@ +import type { Adapter } from "./adapter"; +import { CODEX_MODE_PRESETS } from "./execution-modes"; +import { restrictedModelMeta } from "./models"; +import { getReasoningEffortOptions } from "./reasoning-effort"; + +export interface GatewayModel { + id: string; + owned_by: string; + context_window: number; + supports_streaming: boolean; + supports_vision: boolean; + allowed: boolean; + restriction_reason?: string | null; +} + +interface GatewayModelsResponse { + data?: unknown[]; + models?: unknown[]; +} + +export interface CloudTaskConfigSelectOption { + value: string; + name: string; + description?: string; + _meta?: Record; +} + +export interface CloudTaskConfigOption { + id: string; + name: string; + type: "select"; + currentValue: string; + options: CloudTaskConfigSelectOption[]; + category: "mode" | "model" | "thought_level"; + description: string; +} + +export interface CloudTaskModePreset { + id: string; + name: string; + description: string; +} + +export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-8"; + +export const DEFAULT_CODEX_MODEL = "gpt-5.5"; + +export const BLOCKED_GATEWAY_MODEL_IDS = [ + "gpt-5-mini", + "openai/gpt-5-mini", + "gpt-5.2", + "openai/gpt-5.2", + "gpt-5.3", + "openai/gpt-5.3", + "gpt-5.3-codex", + "openai/gpt-5.3-codex", + "claude-opus-4-5", + "anthropic/claude-opus-4-5", + "claude-opus-4-6", + "anthropic/claude-opus-4-6", + "claude-sonnet-4-5", + "anthropic/claude-sonnet-4-5", + "claude-haiku-4-5", + "anthropic/claude-haiku-4-5", +] as const; + +const BLOCKED_GATEWAY_MODELS = new Set(BLOCKED_GATEWAY_MODEL_IDS); + +const CLAUDE_MODE_PRESETS: readonly CloudTaskModePreset[] = [ + { + id: "default", + name: "Default", + description: "Standard behavior, prompts for dangerous operations", + }, + { + id: "acceptEdits", + name: "Accept Edits", + description: "Auto-accept file edit operations", + }, + { + id: "plan", + name: "Plan Mode", + description: "Planning mode, no actual tool execution", + }, + { + id: "bypassPermissions", + name: "Bypass Permissions", + description: "Auto-accept all permission requests", + }, + { + id: "auto", + name: "Auto Mode", + description: "Auto-approve file edits and shell commands", + }, +]; + +const PROVIDER_NAMES: Record = { + anthropic: "Anthropic", + openai: "OpenAI", + "google-vertex": "Gemini", +}; + +const MODEL_FAMILY_ORDER = ["fable", "opus", "sonnet", "haiku"]; +const PROVIDER_PREFIXES = ["anthropic/", "openai/", "google-vertex/"]; +const KNOWN_ACRONYMS = new Set(["gpt", "glm"]); +const MODEL_CONTEXT_WINDOW_OVERRIDES: Readonly> = { + "@cf/zai-org/glm-5.2": 1_000_000, +}; + +export function getCloudTaskGatewayUrl(posthogHost: string): string { + const url = new URL(posthogHost); + let gatewayBaseUrl: string; + + if (url.hostname === "localhost" || url.hostname === "127.0.0.1") { + gatewayBaseUrl = `${url.protocol}//localhost:3308`; + } else if (url.hostname === "host.docker.internal") { + gatewayBaseUrl = `${url.protocol}//host.docker.internal:3308`; + } else if (url.hostname === "app.dev.posthog.dev") { + gatewayBaseUrl = "https://gateway.dev.posthog.dev"; + } else { + const region = url.hostname.match(/^(us|eu)\.posthog\.com$/)?.[1] ?? "us"; + gatewayBaseUrl = `https://gateway.${region}.posthog.com`; + } + + return `${gatewayBaseUrl}/posthog_code`; +} + +function isGatewayModel(value: unknown): value is Partial & { + id: string; +} { + return ( + typeof value === "object" && + value !== null && + typeof (value as { id?: unknown }).id === "string" + ); +} + +export function normalizeGatewayModelsResponse(value: unknown): GatewayModel[] { + const response = value as GatewayModelsResponse; + const entries = Array.isArray(value) + ? value + : Array.isArray(response?.data) + ? response.data + : Array.isArray(response?.models) + ? response.models + : []; + + return entries + .filter(isGatewayModel) + .filter((model) => !isBlockedModelId(model.id)) + .map((model) => ({ + id: model.id, + owned_by: model.owned_by ?? "", + context_window: Math.max( + model.context_window ?? 0, + MODEL_CONTEXT_WINDOW_OVERRIDES[model.id] ?? 0, + ), + supports_streaming: model.supports_streaming ?? false, + supports_vision: model.supports_vision ?? false, + allowed: model.allowed !== false, + restriction_reason: model.restriction_reason ?? null, + })); +} + +export function isBlockedModelId(modelId: string): boolean { + return BLOCKED_GATEWAY_MODELS.has(modelId.toLowerCase()); +} + +export function isAnthropicModel(model: GatewayModel): boolean { + if (model.owned_by) { + return model.owned_by === "anthropic"; + } + return model.id.startsWith("claude-") || model.id.startsWith("anthropic/"); +} + +export function isOpenAIModel(model: GatewayModel): boolean { + if (model.owned_by) { + return model.owned_by === "openai"; + } + return model.id.startsWith("gpt-") || model.id.startsWith("openai/"); +} + +export function isCloudflareModelId(modelId: string): boolean { + return modelId.startsWith("@cf/"); +} + +export function isGlmModelId(modelId: string): boolean { + return modelId.toLowerCase().includes("glm"); +} + +export function isCloudflareModel(model: GatewayModel): boolean { + return isCloudflareModelId(model.id) || model.owned_by === "cloudflare"; +} + +export function isModalModelId(modelId: string): boolean { + return modelId === "moonshotai/kimi-k3"; +} + +export function isModalModel(model: GatewayModel): boolean { + return isModalModelId(model.id) || model.owned_by === "modal"; +} + +export function pickAllowedModel( + models: ReadonlyArray>, + preferred: string, +): string { + if (models.length === 0) return preferred; + const preferredEntry = models.find((model) => model.id === preferred); + if (!preferredEntry || preferredEntry.allowed) return preferred; + const allowed = models.filter((model) => model.allowed); + if (allowed.length === 0) return preferred; + return allowed.reduce((best, candidate) => + getClaudeModelRecency(candidate.id) >= getClaudeModelRecency(best.id) + ? candidate + : best, + ).id; +} + +export function getProviderName(ownedBy: string): string { + return PROVIDER_NAMES[ownedBy] ?? ownedBy; +} + +export function getClaudeModelRecency(modelId: string): number { + const match = modelId.toLowerCase().match(/-(\d+)(?:[-.](\d+))?/); + if (!match) return Number.MAX_SAFE_INTEGER; + const major = Number(match[1]); + const minor = match[2] ? Number(match[2]) : 0; + return major * 1000 + minor; +} + +function getModelFamilyRank(modelId: string): number { + const normalizedModelId = modelId.toLowerCase(); + const index = MODEL_FAMILY_ORDER.findIndex((family) => + normalizedModelId.includes(family), + ); + return index === -1 ? MODEL_FAMILY_ORDER.length : index; +} + +export function compareModelsForPicker(a: string, b: string): number { + const familyDiff = getModelFamilyRank(a) - getModelFamilyRank(b); + if (familyDiff !== 0) return familyDiff; + return getClaudeModelRecency(b) - getClaudeModelRecency(a); +} + +function stripProviderPrefix(modelId: string): string { + for (const prefix of PROVIDER_PREFIXES) { + if (modelId.startsWith(prefix)) { + return modelId.slice(prefix.length); + } + } + return modelId; +} + +function formatProviderModelName(modelId: string): string { + const [acronym, version, ...suffix] = modelId.split("-"); + if (!KNOWN_ACRONYMS.has(acronym.toLowerCase())) return modelId.toLowerCase(); + const head = version + ? `${acronym.toUpperCase()}-${version}` + : acronym.toUpperCase(); + const tail = suffix.map( + (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), + ); + return [head, ...tail].join(" "); +} + +export function formatGatewayModelName(model: GatewayModel): string { + if (isCloudflareModel(model)) { + return formatProviderModelName(model.id.split("/").pop() ?? model.id); + } + if (isModalModel(model)) { + return formatModelId(model.id.split("/").pop() ?? model.id); + } + if (isOpenAIModel(model)) { + return formatProviderModelName(stripProviderPrefix(model.id)); + } + return formatModelId(model.id); +} + +export function formatModelId(modelId: string): string { + const cleanId = stripProviderPrefix(modelId).replace(/(\d)-(\d)/g, "$1.$2"); + return cleanId + .split(/[-_]/) + .map((word) => { + if (/^[0-9.]+$/.test(word)) return word; + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }) + .join(" "); +} + +function getAdapterModels( + models: readonly GatewayModel[], + adapter: Adapter, +): GatewayModel[] { + return models.filter((model) => + adapter === "codex" + ? isOpenAIModel(model) + : isAnthropicModel(model) || + isCloudflareModel(model) || + isModalModel(model), + ); +} + +function getModeOptions( + adapter: Adapter, + modePresets?: readonly CloudTaskModePreset[], +): CloudTaskConfigSelectOption[] { + const modes = + modePresets ?? + (adapter === "codex" ? CODEX_MODE_PRESETS : CLAUDE_MODE_PRESETS); + return modes.map((mode) => ({ + value: mode.id, + name: mode.name, + description: mode.description, + })); +} + +export function buildCloudTaskConfigOptions( + models: readonly GatewayModel[], + adapter: Adapter, + modePresets?: readonly CloudTaskModePreset[], +): CloudTaskConfigOption[] { + const adapterModels = getAdapterModels(models, adapter); + const modelOptions: CloudTaskConfigSelectOption[] = adapterModels.map( + (model) => ({ + value: model.id, + name: formatGatewayModelName(model), + description: `Context: ${model.context_window.toLocaleString()} tokens`, + ...(model.allowed ? {} : { _meta: restrictedModelMeta() }), + }), + ); + + if (adapter === "claude") { + modelOptions.sort( + (a, b) => getClaudeModelRecency(a.value) - getClaudeModelRecency(b.value), + ); + } + + const defaultModel = + adapter === "codex" + ? (modelOptions.find((option) => option.value === DEFAULT_CODEX_MODEL) + ?.value ?? + modelOptions[0]?.value ?? + "") + : DEFAULT_GATEWAY_MODEL; + const preferredModelId = modelOptions.some( + (option) => option.value === defaultModel, + ) + ? defaultModel + : (modelOptions[0]?.value ?? defaultModel); + const resolvedModelId = pickAllowedModel(adapterModels, preferredModelId); + + if (!modelOptions.some((option) => option.value === resolvedModelId)) { + modelOptions.unshift({ + value: resolvedModelId, + name: resolvedModelId, + description: "Custom model", + }); + } + + const configOptions: CloudTaskConfigOption[] = [ + { + id: "mode", + name: "Approval Preset", + type: "select", + currentValue: adapter === "codex" ? "auto" : "plan", + options: getModeOptions(adapter, modePresets), + category: "mode", + description: "Choose an approval and sandboxing preset for your session", + }, + { + id: "model", + name: "Model", + type: "select", + currentValue: resolvedModelId, + options: modelOptions, + category: "model", + description: "Choose which model the agent should use", + }, + ]; + + const reasoningOptions = getReasoningEffortOptions(adapter, resolvedModelId); + if (reasoningOptions) { + configOptions.push({ + id: adapter === "codex" ? "reasoning_effort" : "effort", + name: adapter === "codex" ? "Reasoning Level" : "Effort", + type: "select", + currentValue: "high", + options: reasoningOptions, + category: "thought_level", + description: + adapter === "codex" + ? "Controls how much reasoning effort the model uses" + : "Controls how much effort Claude puts into its response", + }); + } + + return configOptions; +} diff --git a/packages/shared/src/domain-types.test.ts b/packages/shared/src/domain-types.test.ts index f9b060cbed..1264ae0462 100644 --- a/packages/shared/src/domain-types.test.ts +++ b/packages/shared/src/domain-types.test.ts @@ -1,5 +1,22 @@ import { describe, expect, it } from "vitest"; -import { isContentlessTask } from "./domain-types"; +import { + isContentlessTask, + isTerminalStatus, + TERMINAL_STATUSES, +} from "./domain-types"; + +describe("task run statuses", () => { + it.each(TERMINAL_STATUSES)("identifies %s as terminal", (status) => { + expect(isTerminalStatus(status)).toBe(true); + }); + + it.each(["not_started", "queued", "in_progress", "unknown", null, undefined])( + "identifies %s as non-terminal", + (status) => { + expect(isTerminalStatus(status)).toBe(false); + }, + ); +}); describe("isContentlessTask", () => { it.each([ diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 4aa90fd2c7..8f1e8dc56d 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -3,7 +3,7 @@ import type { Adapter } from "./adapter"; import type { AgentRuntime } from "./agent-runtime"; import type { DismissalReasonOptionValue } from "./dismissal-reasons"; import type { StoredLogEntry } from "./session-events"; -import type { TaskRunArtifact } from "./task"; +import type { UploadableSkillSource } from "./skills"; // Execution mode schema and type - shared between main and renderer export const executionModeSchema = z.enum([ @@ -192,6 +192,37 @@ export type TaskRunStatus = | "failed" | "cancelled"; +export type TaskRunEnvironment = "local" | "cloud"; + +export type ArtifactType = + | "plan" + | "context" + | "reference" + | "output" + | "artifact" + | "user_attachment" + | "skill_bundle"; + +export interface TaskRunArtifactMetadata { + skill_name: string; + skill_source: UploadableSkillSource; + content_sha256: string; + bundle_format: "zip"; + schema_version: number; +} + +export interface TaskRunArtifact { + id?: string; + name: string; + type: ArtifactType; + source?: string; + size?: number; + content_type?: string; + metadata?: TaskRunArtifactMetadata; + storage_path?: string; + uploaded_at?: string; +} + export const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const; export function isTerminalStatus( @@ -220,7 +251,7 @@ export interface TaskRun { model?: string | null; reasoning_effort?: "low" | "medium" | "high" | "xhigh" | "max" | null; stage?: string | null; // Current stage (e.g., 'research', 'plan', 'build') - environment?: "local" | "cloud"; + environment?: TaskRunEnvironment; status: TaskRunStatus; log_url: string; error_message: string | null; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ff03602e7e..043cc19625 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -71,6 +71,32 @@ export { promptBlocksToText, serializeCloudPrompt, } from "./cloud-prompt"; +export { + BLOCKED_GATEWAY_MODEL_IDS, + buildCloudTaskConfigOptions, + type CloudTaskConfigOption, + type CloudTaskConfigSelectOption, + type CloudTaskModePreset, + compareModelsForPicker, + DEFAULT_CODEX_MODEL, + DEFAULT_GATEWAY_MODEL, + formatGatewayModelName, + formatModelId, + type GatewayModel, + getClaudeModelRecency, + getCloudTaskGatewayUrl, + getProviderName, + isAnthropicModel, + isBlockedModelId, + isCloudflareModel, + isCloudflareModelId, + isGlmModelId, + isModalModel, + isModalModelId, + isOpenAIModel, + normalizeGatewayModelsResponse, + pickAllowedModel, +} from "./cloud-task-models"; export { buildInboxDeeplink, buildScoutDeeplink, @@ -87,9 +113,28 @@ export { export { DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, + dismissalReasonLabel, isDismissalReasonSnooze, } from "./dismissal-reasons"; -export type { SignalReportPriority, Task } from "./domain-types"; +export { + type ArtifactType, + type CloudPermissionOption, + type CloudTaskErrorUpdate, + type CloudTaskLogsUpdate, + type CloudTaskPermissionRequestUpdate, + type CloudTaskSnapshotUpdate, + type CloudTaskStatusUpdate, + type CloudTaskUpdatePayload, + isTerminalStatus, + type SignalReportPriority, + type Task, + type TaskRun, + type TaskRunArtifact, + type TaskRunArtifactMetadata, + type TaskRunEnvironment, + type TaskRunStatus, + TERMINAL_STATUSES, +} from "./domain-types"; export * from "./enrichment"; export { classifyGatewayLimitError, @@ -213,6 +258,13 @@ export { isPrivateIpv4Octets, isPrivateIpv6Literal, } from "./private-network"; +export { + DEFAULT_REASONING_EFFORT, + getReasoningEffortOptions, + isSupportedReasoningEffort, + type ReasoningEffortOption, + type SupportedReasoningEffort, +} from "./reasoning-effort"; export { type CloudRegion, formatRegionBadge, @@ -276,15 +328,19 @@ export { serializeSkillMarkdown, stripFrontmatter, } from "./skills"; -export type { - ArtifactType, - PostHogAPIConfig, - TaskRun, - TaskRunArtifact, - TaskRunArtifactMetadata, - TaskRunEnvironment, - TaskRunStatus, -} from "./task"; +export type { PostHogAPIConfig } from "./task"; +export { + type CreateTaskAutomationOptions, + createTaskAutomationSchema, + type TaskAutomation, + type TaskAutomationList, + type TaskAutomationValidationErrorDetails, + taskAutomationListSchema, + taskAutomationSchema, + taskAutomationValidationErrorSchema, + type UpdateTaskAutomationOptions, + updateTaskAutomationSchema, +} from "./task-automation"; export type { TaskCreationInput, TaskCreationOutput, diff --git a/packages/shared/src/reasoning-effort.test.ts b/packages/shared/src/reasoning-effort.test.ts new file mode 100644 index 0000000000..2ac4a9bbff --- /dev/null +++ b/packages/shared/src/reasoning-effort.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { isSupportedReasoningEffort } from "./reasoning-effort"; + +describe("isSupportedReasoningEffort", () => { + it.each([ + ["codex", "gpt-5.5", "xhigh", true], + ["codex", "gpt-5.6-sol", "max", true], + ["codex", "gpt-5.4", "max", false], + ["claude", "claude-opus-4-8", "xhigh", true], + ["claude", "claude-sonnet-4-6", "xhigh", false], + ["claude", "@cf/zai-org/glm-5.2", "high", true], + ["claude", "@cf/zai-org/glm-5.2", "max", true], + ["claude", "@cf/zai-org/glm-5.2", "medium", false], + ["claude", "claude-opus-4-8", "minimal", false], + ] as const)( + "validates %s %s effort %s", + (adapter, modelId, effort, expected) => { + expect(isSupportedReasoningEffort(adapter, modelId, effort)).toBe( + expected, + ); + }, + ); +}); diff --git a/packages/shared/src/reasoning-effort.ts b/packages/shared/src/reasoning-effort.ts new file mode 100644 index 0000000000..2fe12b7232 --- /dev/null +++ b/packages/shared/src/reasoning-effort.ts @@ -0,0 +1,79 @@ +import type { Adapter } from "./adapter"; + +export type SupportedReasoningEffort = + | "low" + | "medium" + | "high" + | "xhigh" + | "max"; + +export const DEFAULT_REASONING_EFFORT: SupportedReasoningEffort = "high"; + +export interface ReasoningEffortOption { + value: SupportedReasoningEffort; + name: string; +} + +const BASE_OPTIONS: ReasoningEffortOption[] = [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, +]; + +const CLAUDE_MODEL_EFFORTS: Readonly< + Record +> = { + "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"], + "claude-sonnet-4-6": ["low", "medium", "high"], + "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"], + "claude-fable-5": ["low", "medium", "high", "xhigh", "max"], + "@cf/zai-org/glm-5.2": ["high", "max"], +}; + +const EFFORT_NAMES: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +}; + +export function getReasoningEffortOptions( + adapter: Adapter, + modelId: string, +): ReasoningEffortOption[] | null { + if (adapter === "claude") { + const efforts = CLAUDE_MODEL_EFFORTS[modelId]; + return ( + efforts?.map((value) => ({ value, name: EFFORT_NAMES[value] })) ?? null + ); + } + + const options = [...BASE_OPTIONS]; + const normalizedModelId = modelId.toLowerCase(); + const supportsXhigh = + normalizedModelId.includes("gpt-5.5") || + normalizedModelId.includes("gpt-5.6"); + + if (supportsXhigh) { + options.push({ value: "xhigh", name: "Extra High" }); + } + if (adapter === "codex" && normalizedModelId.includes("gpt-5.6")) { + options.push({ value: "max", name: "Max" }); + } + + return options; +} + +export function isSupportedReasoningEffort( + adapter: Adapter, + modelId: string, + value: string, +): value is SupportedReasoningEffort { + return ( + getReasoningEffortOptions(adapter, modelId)?.some( + (option) => option.value === value, + ) ?? false + ); +} diff --git a/packages/shared/src/sessions.ts b/packages/shared/src/sessions.ts index 76a49cd736..181ff36c20 100644 --- a/packages/shared/src/sessions.ts +++ b/packages/shared/src/sessions.ts @@ -8,9 +8,9 @@ import type { } from "@agentclientprotocol/sdk"; import type { Adapter } from "./adapter"; import type { SkillButtonId } from "./analytics-events"; +import type { TaskRunArtifact, TaskRunStatus } from "./domain-types"; import type { ExecutionMode } from "./exec-types"; import type { AcpMessage } from "./session-events"; -import type { TaskRunArtifact, TaskRunStatus } from "./task"; export type { Adapter }; diff --git a/packages/shared/src/task-automation.test.ts b/packages/shared/src/task-automation.test.ts new file mode 100644 index 0000000000..57e6fcaa97 --- /dev/null +++ b/packages/shared/src/task-automation.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + type CreateTaskAutomationOptions, + createTaskAutomationSchema, + taskAutomationSchema, + taskAutomationValidationErrorSchema, + type UpdateTaskAutomationOptions, + updateTaskAutomationSchema, +} from "./task-automation"; + +describe("task automation contracts", () => { + it("normalizes optional automation response fields", () => { + expect( + taskAutomationSchema.parse({ + id: "automation-1", + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + cron_expression: "0 9 * * *", + last_run_at: null, + last_run_status: null, + last_task_id: null, + last_task_run_id: null, + last_error: null, + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:00:00Z", + }), + ).toMatchObject({ + github_integration: null, + timezone: null, + template_id: null, + enabled: true, + }); + }); + + it("keeps create fields required and update fields partial", () => { + const create = createTaskAutomationSchema.parse({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + cron_expression: "0 9 * * *", + timezone: "Europe/London", + }); + const update = updateTaskAutomationSchema.parse({ enabled: false }); + + expect(create.timezone).toBe("Europe/London"); + expect(update).toEqual({ enabled: false }); + expectTypeOf(create).toEqualTypeOf(); + expectTypeOf(update).toEqualTypeOf(); + }); + + it("preserves backend validation field attribution", () => { + expect( + taskAutomationValidationErrorSchema.parse({ + type: "validation_error", + detail: "Enter a valid cron expression.", + attr: "cron_expression", + }), + ).toEqual({ + type: "validation_error", + code: "invalid_input", + detail: "Enter a valid cron expression.", + attr: "cron_expression", + }); + }); +}); diff --git a/packages/shared/src/task-automation.ts b/packages/shared/src/task-automation.ts new file mode 100644 index 0000000000..0f3e4a4bde --- /dev/null +++ b/packages/shared/src/task-automation.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +export const taskAutomationSchema = z.object({ + id: z.string(), + name: z.string(), + prompt: z.string(), + repository: z.string(), + github_integration: z.number().nullable().default(null), + cron_expression: z.string(), + timezone: z.string().nullable().default(null), + template_id: z.string().nullable().default(null), + enabled: z.boolean().default(true), + last_run_at: z.string().nullable(), + last_run_status: z.string().nullable(), + last_task_id: z.string().nullable(), + last_task_run_id: z.string().nullable(), + last_error: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}); +export type TaskAutomation = z.infer; + +export const taskAutomationListSchema = z.object({ + count: z.number(), + next: z.string().nullable().optional(), + previous: z.string().nullable().optional(), + results: z.array(taskAutomationSchema), +}); +export type TaskAutomationList = z.infer; + +export const createTaskAutomationSchema = z.object({ + name: z.string(), + prompt: z.string(), + repository: z.string(), + github_integration: z.number().nullable().optional(), + cron_expression: z.string(), + timezone: z.string(), + template_id: z.string().nullable().optional(), + enabled: z.boolean().optional(), +}); +export type CreateTaskAutomationOptions = z.infer< + typeof createTaskAutomationSchema +>; + +export const updateTaskAutomationSchema = createTaskAutomationSchema.partial(); +export type UpdateTaskAutomationOptions = z.infer< + typeof updateTaskAutomationSchema +>; + +export const taskAutomationValidationErrorSchema = z.object({ + type: z.string().optional(), + code: z.string().default("invalid_input"), + detail: z.string(), + attr: z.string().nullable().default(null), +}); +export type TaskAutomationValidationErrorDetails = z.infer< + typeof taskAutomationValidationErrorSchema +>; diff --git a/packages/shared/src/task.test.ts b/packages/shared/src/task.test.ts new file mode 100644 index 0000000000..8d666f2b11 --- /dev/null +++ b/packages/shared/src/task.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { + Task, + TaskRun, + TaskRunArtifact, + TaskRunStatus, +} from "./domain-types"; +import { + type CloudPermissionOption, + type CloudTaskUpdatePayload, + isTerminalStatus, + type Task as RootTask, + type TaskRun as RootTaskRun, + type TaskRunArtifact as RootTaskRunArtifact, + type TaskRunStatus as RootTaskRunStatus, + TERMINAL_STATUSES, +} from "./index"; +import type { + Task as LegacyTask, + TaskRun as LegacyTaskRun, + TaskRunArtifact as LegacyTaskRunArtifact, + TaskRunStatus as LegacyTaskRunStatus, +} from "./task"; + +describe("cloud task contract exports", () => { + it("keeps legacy and root task exports canonical", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("exports cloud permission and update payload contracts from the root", () => { + expectTypeOf().toMatchTypeOf<{ + kind: string; + optionId: string; + name: string; + }>(); + expectTypeOf().toEqualTypeOf< + "logs" | "status" | "snapshot" | "error" | "permission_request" + >(); + }); + + it("exports terminal status helpers from the root", () => { + expect(TERMINAL_STATUSES).toEqual(["completed", "failed", "cancelled"]); + expect(isTerminalStatus("completed")).toBe(true); + expect(isTerminalStatus("in_progress")).toBe(false); + }); +}); diff --git a/packages/shared/src/task.ts b/packages/shared/src/task.ts index 9e4c1c898f..f6593d21bf 100644 --- a/packages/shared/src/task.ts +++ b/packages/shared/src/task.ts @@ -1,98 +1,12 @@ -// PostHog Task model (matches the desktop task API's OpenAPI schema) -import type { AgentRuntime } from "./agent-runtime"; -import type { UploadableSkillSource } from "./skills"; - -export interface Task { - id: string; - task_number?: number; - slug?: string; - title: string; - description: string; - origin_product: - | "error_tracking" - | "eval_clusters" - | "user_created" - | "support_queue" - | "session_summaries" - | "signal_report" - | "signals_scout" - | "slack" - | "loop"; - signal_report?: string | null; // Inbox report UUID when origin_product is "signal_report" - github_integration?: number | null; - repository: string; // Format: "organization/repository" (e.g., "posthog/posthog-js") - json_schema?: Record | null; // JSON schema for task output validation - internal?: boolean; - runtime?: AgentRuntime; - created_at: string; - updated_at: string; - created_by?: { - id: number; - uuid: string; - distinct_id: string; - first_name: string; - email: string; - }; - latest_run?: TaskRun; -} - -export type ArtifactType = - | "plan" - | "context" - | "reference" - | "output" - | "artifact" - | "user_attachment" - | "skill_bundle"; - -export interface TaskRunArtifactMetadata { - skill_name: string; - skill_source: UploadableSkillSource; - content_sha256: string; - bundle_format: "zip"; - schema_version: number; -} - -export interface TaskRunArtifact { - id?: string; - name: string; - type: ArtifactType; - source?: string; - size?: number; - content_type?: string; - metadata?: TaskRunArtifactMetadata; - storage_path?: string; - uploaded_at?: string; -} - -export type TaskRunStatus = - | "not_started" - | "queued" - | "in_progress" - | "completed" - | "failed" - | "cancelled"; - -export type TaskRunEnvironment = "local" | "cloud"; - -// TaskRun model - represents individual execution runs of tasks -export interface TaskRun { - id: string; - task: string; // Task ID - team: number; - branch: string | null; - stage: string | null; // Current stage (e.g., 'research', 'plan', 'build') - environment: TaskRunEnvironment; - status: TaskRunStatus; - log_url: string; - error_message: string | null; - output: Record | null; // Structured output (PR URL, commit SHA, etc.) - state: Record; // Intermediate run state (defaults to {}, never null) - artifacts?: TaskRunArtifact[]; - created_at: string; - updated_at: string; - completed_at: string | null; -} +export type { + ArtifactType, + Task, + TaskRun, + TaskRunArtifact, + TaskRunArtifactMetadata, + TaskRunEnvironment, + TaskRunStatus, +} from "./domain-types"; export interface PostHogAPIConfig { apiUrl: string; diff --git a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx index ef705ba77c..3bd1add8e8 100644 --- a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx @@ -2,9 +2,13 @@ import type { ToolCall } from "@posthog/ui/features/sessions/types"; import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { PlanApprovalView } from "./PlanApprovalView"; +vi.mock("../../../permissions/PlanContent", () => ({ + PlanContent: ({ plan }: { plan: string }) =>
{plan}
, +})); + const PLAN_MARKER = "Sentinel plan body for testing"; function makeToolCall(overrides: Partial = {}): ToolCall { @@ -110,7 +114,7 @@ describe("PlanApprovalView", () => { ).toBeInTheDocument(); }); - it("uses updated content instead of stale raw input while streaming", () => { + it("uses updated content instead of stale raw input while streaming", async () => { renderView({ toolCall: makeToolCall({ status: "in_progress", diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index f23d47859a..bd0821e254 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -20,25 +20,16 @@ import { } from "@posthog/agent"; import type { McpToolApprovals } from "@posthog/agent/adapters/claude/mcp/tool-metadata"; import { hydrateSessionJsonl } from "@posthog/agent/adapters/claude/session/jsonl-hydration"; -import { getReasoningEffortOptions } from "@posthog/agent/adapters/reasoning-effort"; import { Agent } from "@posthog/agent/agent"; import { getAvailableCodexModes, getAvailableModes, } from "@posthog/agent/execution-mode"; import { - DEFAULT_CODEX_MODEL, - DEFAULT_GATEWAY_MODEL, fetchGatewayModels, formatGatewayModelName, - type GatewayModel, getClaudeModelRecency, getProviderName, - isAnthropicModel, - isCloudflareModel, - isModalModel, - isOpenAIModel, - pickAllowedModel, } from "@posthog/agent/gateway-models"; import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; import { @@ -73,10 +64,10 @@ import { import { type AcpMessage, type Adapter, + buildCloudTaskConfigOptions, type ExecutionMode, isAuthError, resolveCloudInitialPermissionMode, - restrictedModelMeta, serializeError, TypedEventEmitter, } from "@posthog/shared"; @@ -2396,112 +2387,14 @@ For git operations while detached: adapter: Adapter = "claude", ): Promise { const gatewayUrl = getLlmGatewayUrl(apiHost); - // Authenticated so the gateway can mark plan-restricted models; falls - // back to an anonymous fetch (everything allowed) without auth. const gatewayModels = await fetchGatewayModels({ gatewayUrl, authToken: (await this.agentAuthAdapter.gatewayAuthToken()) ?? undefined, }); - - // The Claude adapter can drive non-Anthropic models that the gateway exposes through its - // Anthropic-Messages surface, so preview filtering must match the session adapter. - const modelFilter = - adapter === "codex" - ? isOpenAIModel - : (model: GatewayModel) => - isAnthropicModel(model) || - isCloudflareModel(model) || - isModalModel(model); - - const adapterModels = gatewayModels.filter((model) => modelFilter(model)); - const modelOptions = adapterModels.map((model) => ({ - value: model.id, - name: formatGatewayModelName(model), - description: `Context: ${model.context_window.toLocaleString()} tokens`, - // Locked models stay listed so the picker can gate them instead of - // silently dropping them. - ...(model.allowed ? {} : { _meta: restrictedModelMeta() }), - })); - - // The gateway returns models in an arbitrary order. Sort Claude models - // oldest-to-newest so the picker is deterministic and the newest model - // lands at the end of the list, closest to the trigger. - if (adapter === "claude") { - modelOptions.sort( - (a, b) => - getClaudeModelRecency(a.value) - getClaudeModelRecency(b.value), - ); - } - - const defaultModel = - adapter === "codex" - ? (modelOptions.find((o) => o.value === DEFAULT_CODEX_MODEL)?.value ?? - modelOptions[0]?.value ?? - "") - : DEFAULT_GATEWAY_MODEL; - - const preferredModelId = modelOptions.some((o) => o.value === defaultModel) - ? defaultModel - : (modelOptions[0]?.value ?? defaultModel); - // Never preselect a model the org's plan can't use — it would 403 on the - // first message. - const resolvedModelId = pickAllowedModel(adapterModels, preferredModelId); - - if (!modelOptions.some((o) => o.value === resolvedModelId)) { - modelOptions.unshift({ - value: resolvedModelId, - name: resolvedModelId, - description: "Custom model", - }); - } - - const modes = - adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); - const modeOptions = modes.map((mode) => ({ - value: mode.id, - name: mode.name, - description: mode.description ?? undefined, - })); - const defaultMode = adapter === "codex" ? "auto" : "plan"; - - const configOptions: SessionConfigOption[] = [ - { - id: "mode", - name: "Approval Preset", - type: "select", - currentValue: defaultMode, - options: modeOptions, - category: "mode", - description: - "Choose an approval and sandboxing preset for your session", - }, - { - id: "model", - name: "Model", - type: "select", - currentValue: resolvedModelId, - options: modelOptions, - category: "model", - description: "Choose which model Claude should use", - }, - ]; - - const effortOpts = getReasoningEffortOptions(adapter, resolvedModelId); - if (effortOpts) { - configOptions.push({ - id: adapter === "codex" ? "reasoning_effort" : "effort", - name: adapter === "codex" ? "Reasoning Level" : "Effort", - type: "select", - currentValue: "high", - options: effortOpts, - category: "thought_level", - description: - adapter === "codex" - ? "Controls how much reasoning effort the model uses" - : "Controls how much effort Claude puts into its response", - }); - } - - return configOptions; + return buildCloudTaskConfigOptions( + gatewayModels, + adapter, + adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(), + ) as SessionConfigOption[]; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65ef2c8cbf..5cdfefdf93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -869,9 +869,6 @@ importers: packages/api-client: dependencies: - '@posthog/agent': - specifier: workspace:* - version: link:../agent '@posthog/shared': specifier: workspace:* version: link:../shared @@ -19215,13 +19212,6 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.3 - optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -22659,7 +22649,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':