From f08bac5378246231102ffa925988f9c5510e29a1 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 21 Jul 2026 15:30:02 +0300 Subject: [PATCH 01/10] Share cloud task contracts and model policy Generated-By: PostHog Code Task-Id: 40c57a59-b4e1-4760-8e56-ecd03e9c2f0f --- .../agent/src/adapters/reasoning-effort.ts | 45 +-- packages/agent/src/gateway-models.test.ts | 223 +---------- packages/agent/src/gateway-models.ts | 260 ++---------- packages/agent/src/utils/gateway.ts | 23 +- packages/shared/src/cloud-task-models.test.ts | 189 +++++++++ packages/shared/src/cloud-task-models.ts | 379 ++++++++++++++++++ packages/shared/src/domain-types.test.ts | 19 +- packages/shared/src/domain-types.ts | 35 +- packages/shared/src/index.ts | 74 +++- packages/shared/src/reasoning-effort.test.ts | 20 + packages/shared/src/reasoning-effort.ts | 77 ++++ packages/shared/src/sessions.ts | 2 +- packages/shared/src/task-automation.test.ts | 66 +++ packages/shared/src/task-automation.ts | 58 +++ packages/shared/src/task.test.ts | 53 +++ packages/shared/src/task.ts | 103 +---- .../src/services/agent/agent.ts | 117 +----- 17 files changed, 1018 insertions(+), 725 deletions(-) create mode 100644 packages/shared/src/cloud-task-models.test.ts create mode 100644 packages/shared/src/cloud-task-models.ts create mode 100644 packages/shared/src/reasoning-effort.test.ts create mode 100644 packages/shared/src/reasoning-effort.ts create mode 100644 packages/shared/src/task-automation.test.ts create mode 100644 packages/shared/src/task-automation.ts create mode 100644 packages/shared/src/task.test.ts 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 a92614c462..8c988ec1e9 100644 --- a/packages/agent/src/gateway-models.test.ts +++ b/packages/agent/src/gateway-models.test.ts @@ -1,160 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { - compareModelsForPicker, - fetchGatewayModels, - fetchModelsList, - formatGatewayModelName, - type GatewayModel, - getClaudeModelRecency, - isAnthropicModel, - isBlockedModelId, - isCloudflareModel, - 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("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-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, most capable first, newest version first", () => { - // 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-fable-5", - "claude-opus-4-8", - "claude-opus-4-7", - "claude-sonnet-5", - "claude-sonnet-4-6", - "claude-haiku-4-5", - "claude-mystery", - ]); - }); -}); +import { fetchGatewayModels, fetchModelsList } from "./gateway-models"; describe("gateway model fetch timeout", () => { afterEach(() => { @@ -240,69 +85,3 @@ describe("gateway models cache", () => { expect(cached[0]?.allowed).toBe(false); }); }); - -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 9faf9e5f7d..35ae10df5a 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -1,20 +1,28 @@ -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, + isOpenAIModel, + pickAllowedModel, +} from "@posthog/shared"; export interface FetchGatewayModelsOptions { gatewayUrl: string; @@ -22,47 +30,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 @@ -121,10 +88,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, allowed: m.allowed !== false })); + const models = normalizeGatewayModelsResponse(await response.json()); gatewayModelsCache = { models, expiry: Date.now() + CACHE_TTL, @@ -137,36 +101,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 interface ModelInfo { id: string; owned_by?: string; @@ -197,22 +131,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, @@ -224,121 +150,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 most-capable first; unknown families sort last. -const MODEL_FAMILY_ORDER = ["fable", "opus", "sonnet", "haiku"]; - -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 (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 b258086070..5e97841742 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" @@ -37,26 +39,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/shared/src/cloud-task-models.test.ts b/packages/shared/src/cloud-task-models.test.ts new file mode 100644 index 0000000000..f86052bd84 --- /dev/null +++ b/packages/shared/src/cloud-task-models.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { + buildCloudTaskConfigOptions, + compareModelsForPicker, + formatGatewayModelName, + type GatewayModel, + getClaudeModelRecency, + isAnthropicModel, + isBlockedModelId, + isCloudflareModel, + 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("@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("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); + }); +}); + +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" }, + ], + }, + ]); + expect(options.map((option) => option.id)).toEqual(["mode", "model"]); + }); + + 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" }, + ], + }, + ]); + }); +}); diff --git a/packages/shared/src/cloud-task-models.ts b/packages/shared/src/cloud-task-models.ts new file mode 100644 index 0000000000..6092d585d7 --- /dev/null +++ b/packages/shared/src/cloud-task-models.ts @@ -0,0 +1,379 @@ +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"]); + +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: model.context_window ?? 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 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 (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), + ); +} + +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 273bf9a87b..cb61eef0d5 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -3,6 +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 { UploadableSkillSource } from "./skills"; // Execution mode schema and type - shared between main and renderer export const executionModeSchema = z.enum([ @@ -146,6 +147,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( @@ -174,12 +206,13 @@ 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; 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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2158b3eae3..245f93adf8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -71,6 +71,30 @@ 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, + isOpenAIModel, + normalizeGatewayModelsResponse, + pickAllowedModel, +} from "./cloud-task-models"; export { buildInboxDeeplink, buildScoutDeeplink, @@ -87,9 +111,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, @@ -200,6 +243,13 @@ export { isPrivateIpv4Octets, isPrivateIpv6Literal, } from "./private-network"; +export { + DEFAULT_REASONING_EFFORT, + getReasoningEffortOptions, + isSupportedReasoningEffort, + type ReasoningEffortOption, + type SupportedReasoningEffort, +} from "./reasoning-effort"; export { type CloudRegion, formatRegionBadge, @@ -262,15 +312,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..5b1e301dd3 --- /dev/null +++ b/packages/shared/src/reasoning-effort.test.ts @@ -0,0 +1,20 @@ +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", "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..15f534a612 --- /dev/null +++ b/packages/shared/src/reasoning-effort.ts @@ -0,0 +1,77 @@ +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_MODELS_WITH_EFFORT = new Set([ + "claude-opus-4-7", + "claude-opus-4-8", + "claude-sonnet-4-6", + "claude-sonnet-5", + "claude-fable-5", +]); + +const CLAUDE_MODELS_WITH_XHIGH_EFFORT = new Set([ + "claude-opus-4-7", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-fable-5", +]); + +export function getReasoningEffortOptions( + adapter: Adapter, + modelId: string, +): ReasoningEffortOption[] | null { + if (adapter === "claude" && !CLAUDE_MODELS_WITH_EFFORT.has(modelId)) { + return null; + } + + const options = [...BASE_OPTIONS]; + const normalizedModelId = modelId.toLowerCase(); + const supportsXhigh = + adapter === "claude" + ? CLAUDE_MODELS_WITH_XHIGH_EFFORT.has(modelId) + : normalizedModelId.includes("gpt-5.5") || + normalizedModelId.includes("gpt-5.6"); + + if (supportsXhigh) { + options.push({ value: "xhigh", name: "Extra High" }); + } + if ( + (adapter === "claude" && supportsXhigh) || + (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 fd4fca99c3..60c26695de 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 { TaskRunStatus } from "./domain-types"; import type { ExecutionMode } from "./exec-types"; import type { AcpMessage } from "./session-events"; -import type { 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 58f9e7975f..f6593d21bf 100644 --- a/packages/shared/src/task.ts +++ b/packages/shared/src/task.ts @@ -1,97 +1,12 @@ -// PostHog Task model (matches PostHog Code'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"; - 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/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 9af21e716d..a0b4252f09 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -20,24 +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, - isOpenAIModel, - pickAllowedModel, } from "@posthog/agent/gateway-models"; import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; import { @@ -68,10 +60,10 @@ import { import { type AcpMessage, type Adapter, + buildCloudTaskConfigOptions, type ExecutionMode, isAuthError, resolveCloudInitialPermissionMode, - restrictedModelMeta, serializeError, TypedEventEmitter, } from "@posthog/shared"; @@ -2275,111 +2267,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 also drive Cloudflare `@cf/` models the gateway serves over its - // Anthropic-Messages surface, so the preview/default-model path must offer them too — otherwise an - // advertised `@cf/*` model is dropped here and the pre-session run falls back to Opus. - const modelFilter = - adapter === "codex" - ? isOpenAIModel - : (model: GatewayModel) => - isAnthropicModel(model) || isCloudflareModel(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[]; } } From e57abcb29020e1e2b6d3874d8a7c1aa96fff7a36 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 21 Jul 2026 16:27:57 +0300 Subject: [PATCH 02/10] refactor(mobile): adopt shared task contracts Generated-By: PostHog Code Task-Id: 40c57a59-b4e1-4760-8e56-ecd03e9c2f0f --- apps/mobile/src/app/task/[id].tsx | 2 +- .../components/SwipeableArchivedDrawerRow.tsx | 2 +- apps/mobile/src/features/tasks/api.ts | 16 +- .../tasks/components/AutomationDetail.tsx | 2 +- .../tasks/components/AutomationForm.tsx | 6 +- .../tasks/components/AutomationItem.tsx | 2 +- .../tasks/components/AutomationList.tsx | 2 +- .../components/AutomationStatusBadge.tsx | 2 +- .../components/CustomImageBadge.test.tsx | 2 +- .../tasks/components/CustomImageBadge.tsx | 2 +- .../tasks/components/SwipeableTaskItem.tsx | 2 +- .../tasks/components/TaskItem.test.tsx | 2 +- .../features/tasks/components/TaskItem.tsx | 2 +- .../features/tasks/components/TaskList.tsx | 2 +- .../tasks/components/TaskStatusIcon.test.ts | 8 +- .../tasks/components/TaskStatusIcon.tsx | 2 +- .../tasks/components/taskStatusIconKind.ts | 4 +- .../features/tasks/composer/options.test.ts | 27 +++ .../src/features/tasks/composer/options.ts | 39 ++-- .../features/tasks/hooks/useAutomations.ts | 10 +- .../src/features/tasks/hooks/useTasks.ts | 3 +- .../src/features/tasks/lib/cloudTaskStream.ts | 12 +- .../tasks/stores/taskSessionStore.test.ts | 12 +- .../features/tasks/stores/taskSessionStore.ts | 20 +- .../features/tasks/stores/taskStore.test.ts | 2 +- .../src/features/tasks/stores/taskStore.ts | 3 +- apps/mobile/src/features/tasks/types.ts | 187 +----------------- .../features/tasks/utils/archiveGuard.test.ts | 4 +- .../src/features/tasks/utils/archiveGuard.ts | 2 +- .../tasks/utils/automationSchedule.ts | 2 +- .../features/tasks/utils/automationStatus.ts | 3 +- .../utils/automationTemplatePresentation.ts | 2 +- .../features/tasks/utils/parseSessionLogs.ts | 14 +- 33 files changed, 136 insertions(+), 266 deletions(-) create mode 100644 apps/mobile/src/features/tasks/composer/options.test.ts diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index cd0a4c4843..c13ac713d2 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import type { Task } from "@posthog/shared"; import { useQueryClient } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -51,7 +52,6 @@ import { } from "@/features/tasks/stores/pendingTaskPromptStore"; import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore"; import { useTaskStore } from "@/features/tasks/stores/taskStore"; -import type { Task } from "@/features/tasks/types"; import { confirmStopRun, isTaskRunning, diff --git a/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx b/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx index 4bb2583879..6e942a2688 100644 --- a/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx +++ b/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import type { Task } from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { ArrowCounterClockwise } from "phosphor-react-native"; import { useEffect, useRef } from "react"; @@ -11,7 +12,6 @@ import { View, } from "react-native"; import { TaskStatusIcon } from "@/features/tasks/components/TaskStatusIcon"; -import type { Task } from "@/features/tasks/types"; import { useThemeColors } from "@/lib/theme"; const SWIPE_THRESHOLD = 60; diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index 6952fde9f6..169b1e4cd9 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -1,4 +1,12 @@ -import type { Adapter } from "@posthog/shared"; +import type { + Adapter, + CreateTaskAutomationOptions, + StoredLogEntry, + Task, + TaskAutomation, + TaskRun, + UpdateTaskAutomationOptions, +} from "@posthog/shared"; import type { SandboxCustomImage, SandboxEnvironment, @@ -13,14 +21,8 @@ import { } from "@/lib/api"; import { logger } from "@/lib/logger"; import type { - CreateTaskAutomationOptions, CreateTaskOptions, Integration, - StoredLogEntry, - Task, - TaskAutomation, - TaskRun, - UpdateTaskAutomationOptions, UserGithubIntegration, } from "./types"; diff --git a/apps/mobile/src/features/tasks/components/AutomationDetail.tsx b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx index 6838b40761..d5d761ee06 100644 --- a/apps/mobile/src/features/tasks/components/AutomationDetail.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx @@ -1,6 +1,6 @@ import { Text } from "@components/text"; +import type { TaskAutomation, TaskRun } from "@posthog/shared"; import { ActivityIndicator, Pressable, View } from "react-native"; -import type { TaskAutomation, TaskRun } from "../types"; import { formatAutomationScheduleSummary } from "../utils/automationSchedule"; import { getAutomationTemplatePresentation } from "../utils/automationTemplatePresentation"; import { AutomationStatusBadge } from "./AutomationStatusBadge"; diff --git a/apps/mobile/src/features/tasks/components/AutomationForm.tsx b/apps/mobile/src/features/tasks/components/AutomationForm.tsx index d6f076ef82..2530066210 100644 --- a/apps/mobile/src/features/tasks/components/AutomationForm.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationForm.tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import type { CreateTaskAutomationOptions } from "@posthog/shared"; import { CaretDown, GithubLogo } from "phosphor-react-native"; import { type MutableRefObject, useEffect, useMemo, useState } from "react"; import { @@ -12,10 +13,7 @@ import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { useThemeColors } from "@/lib/theme"; import { RepositoryPickerInline } from "../composer/RepositoryPickerInline"; import { useIntegrations } from "../hooks/useIntegrations"; -import type { - CreateTaskAutomationOptions, - RepositorySelection, -} from "../types"; +import type { RepositorySelection } from "../types"; import { type AutomationScheduleDraft, buildCronExpression, diff --git a/apps/mobile/src/features/tasks/components/AutomationItem.tsx b/apps/mobile/src/features/tasks/components/AutomationItem.tsx index 5ce8a7fe80..d06c969492 100644 --- a/apps/mobile/src/features/tasks/components/AutomationItem.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationItem.tsx @@ -1,8 +1,8 @@ import { Text } from "@components/text"; +import type { TaskAutomation, TaskRun } from "@posthog/shared"; import { format, formatDistanceToNow } from "date-fns"; import { memo } from "react"; import { Pressable, View } from "react-native"; -import type { TaskAutomation, TaskRun } from "../types"; import { formatAutomationScheduleSummary } from "../utils/automationSchedule"; import { getAutomationTemplatePresentation } from "../utils/automationTemplatePresentation"; import { AutomationStatusBadge } from "./AutomationStatusBadge"; diff --git a/apps/mobile/src/features/tasks/components/AutomationList.tsx b/apps/mobile/src/features/tasks/components/AutomationList.tsx index 31a5821962..a9bace4303 100644 --- a/apps/mobile/src/features/tasks/components/AutomationList.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationList.tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import type { TaskAutomation } from "@posthog/shared"; import { Plus } from "phosphor-react-native"; import { ActivityIndicator, @@ -10,7 +11,6 @@ import { import { useThemeColors } from "@/lib/theme"; import { useAutomations } from "../hooks/useAutomations"; import { useTasks } from "../hooks/useTasks"; -import type { TaskAutomation } from "../types"; import { AutomationItem } from "./AutomationItem"; interface AutomationListProps { diff --git a/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx index 970de00d64..600b36302e 100644 --- a/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx @@ -1,6 +1,6 @@ import { Text } from "@components/text"; +import type { TaskRun } from "@posthog/shared"; import { View } from "react-native"; -import type { TaskRun } from "../types"; import { getAutomationStatusPresentation } from "../utils/automationStatus"; interface AutomationStatusBadgeProps { diff --git a/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx b/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx index 09b0531b6c..9189b6eec0 100644 --- a/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx +++ b/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx @@ -1,8 +1,8 @@ +import type { Task, TaskRun } from "@posthog/shared"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createElement } from "react"; import { act, create } from "react-test-renderer"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { Task, TaskRun } from "../types"; const { mockUseAuthStore, mockGetImages, mockGetEnvironments } = vi.hoisted( () => ({ diff --git a/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx b/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx index 2e382c228b..13bf4e2d4a 100644 --- a/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx +++ b/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx @@ -1,9 +1,9 @@ import { Text } from "@components/text"; +import type { Task } from "@posthog/shared"; import { Cube } from "phosphor-react-native"; import { View } from "react-native"; import { toRgba } from "@/lib/theme"; import { useCustomImageName } from "../hooks/useCustomImageName"; -import type { Task } from "../types"; // Theme tokens have no violet; a fixed Radix violet-9 mirrors the desktop // custom-image badge and reads well in both light and dark. diff --git a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx index a65b4c2576..fca9d75e79 100644 --- a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx @@ -1,3 +1,4 @@ +import type { Task } from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { Archive, ArrowCounterClockwise } from "phosphor-react-native"; import { useEffect, useRef } from "react"; @@ -10,7 +11,6 @@ import { View, } from "react-native"; import { useThemeColors } from "@/lib/theme"; -import type { Task } from "../types"; import { confirmArchiveRunningTask, isTaskRunning, diff --git a/apps/mobile/src/features/tasks/components/TaskItem.test.tsx b/apps/mobile/src/features/tasks/components/TaskItem.test.tsx index d62cfd7665..38cac02b57 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.test.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.test.tsx @@ -1,7 +1,7 @@ +import type { Task } from "@posthog/shared"; import { createElement } from "react"; import { act, create } from "react-test-renderer"; import { describe, expect, it, vi } from "vitest"; -import type { Task } from "../types"; import { TaskItem } from "./TaskItem"; vi.mock("phosphor-react-native", () => ({ diff --git a/apps/mobile/src/features/tasks/components/TaskItem.tsx b/apps/mobile/src/features/tasks/components/TaskItem.tsx index e99bdfb768..c235722b37 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.tsx @@ -1,11 +1,11 @@ import { Text } from "@components/text"; +import type { Task } from "@posthog/shared"; import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import { Check, GitPullRequest } from "phosphor-react-native"; import { memo } from "react"; import { Linking, Pressable, View } from "react-native"; import { parseGithubIssueUrl } from "@/lib/githubIssueUrl"; import { useThemeColors } from "@/lib/theme"; -import type { Task } from "../types"; import { TaskStatusIcon } from "./TaskStatusIcon"; function PrBadge({ prUrl, number }: { prUrl: string; number: number }) { diff --git a/apps/mobile/src/features/tasks/components/TaskList.tsx b/apps/mobile/src/features/tasks/components/TaskList.tsx index 403d9c6836..779b35535b 100644 --- a/apps/mobile/src/features/tasks/components/TaskList.tsx +++ b/apps/mobile/src/features/tasks/components/TaskList.tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import type { Task } from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { Archive, GitBranch, Plus, Sparkle, X } from "phosphor-react-native"; import { useCallback, useMemo, useState } from "react"; @@ -14,7 +15,6 @@ import { useTasks } from "../hooks/useTasks"; import { useUserIntegrations } from "../hooks/useUserIntegrations"; import { useArchivedTasksStore } from "../stores/archivedTasksStore"; import { taskActivityTimestamp, useTaskStore } from "../stores/taskStore"; -import type { Task } from "../types"; import { GitHubConnectionPrompt } from "./GitHubConnectionPrompt"; import { GitHubLoadNotice } from "./GitHubLoadNotice"; import { SwipeableTaskItem } from "./SwipeableTaskItem"; diff --git a/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts b/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts index 080e0f1ac9..c2ae0c3b33 100644 --- a/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts +++ b/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts @@ -1,5 +1,5 @@ +import type { Task } from "@posthog/shared"; import { describe, expect, it } from "vitest"; -import type { Task } from "../types"; import { getTaskStatusIconKind } from "./taskStatusIconKind"; function makeTask(latestRun?: Partial>): Task { @@ -58,12 +58,6 @@ describe("getTaskStatusIconKind", () => { ), ).toBe("chat"); - expect( - getTaskStatusIconKind( - makeTask({ environment: "cloud", status: "started" }), - ), - ).toBe("chat"); - expect( getTaskStatusIconKind( makeTask({ environment: "cloud", status: "completed" }), diff --git a/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx b/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx index 06c992f047..8736b203b0 100644 --- a/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx +++ b/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx @@ -1,3 +1,4 @@ +import type { Task } from "@posthog/shared"; import { ChatCircle, CheckCircle, @@ -9,7 +10,6 @@ import { import { memo, useEffect, useRef } from "react"; import { Animated, Easing } from "react-native"; import { useThemeColors } from "@/lib/theme"; -import type { Task } from "../types"; import { getTaskStatusIconKind } from "./taskStatusIconKind"; interface TaskStatusIconProps { diff --git a/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts b/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts index fa7fbcd357..96447d5859 100644 --- a/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts +++ b/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts @@ -1,4 +1,4 @@ -import type { Task } from "../types"; +import type { Task } from "@posthog/shared"; export type TaskStatusIconKind = | "pr" @@ -34,7 +34,7 @@ export function getTaskStatusIconKind(task: Task): TaskStatusIconKind { return "running"; } - if (status === "queued" || status === "started") { + if (status === "queued") { return "started"; } diff --git a/apps/mobile/src/features/tasks/composer/options.test.ts b/apps/mobile/src/features/tasks/composer/options.test.ts new file mode 100644 index 0000000000..75328ebf04 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/options.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MODEL, + DEFAULT_REASONING, + modelSupportsReasoning, + REASONING_LEVELS, +} from "./options"; + +describe("task composer options", () => { + it("uses an eligible non-premium default model", () => { + expect(DEFAULT_MODEL).toBe("claude-opus-4-8"); + expect(DEFAULT_MODEL).not.toContain("fable"); + }); + + it("derives reasoning defaults and options from shared policy", () => { + expect(DEFAULT_REASONING).toBe("high"); + expect(REASONING_LEVELS.map((option) => option.value)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(modelSupportsReasoning("claude-opus-4-8")).toBe(true); + expect(modelSupportsReasoning("claude-haiku-4-5")).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts index b1de507a61..b3056b41dc 100644 --- a/apps/mobile/src/features/tasks/composer/options.ts +++ b/apps/mobile/src/features/tasks/composer/options.ts @@ -1,5 +1,17 @@ -export type ExecutionMode = "default" | "acceptEdits" | "plan" | "auto"; -export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; +import { + DEFAULT_GATEWAY_MODEL, + DEFAULT_REASONING_EFFORT, + defaultEligibleModel, + getReasoningEffortOptions, + type ExecutionMode as SharedExecutionMode, + type SupportedReasoningEffort, +} from "@posthog/shared"; + +export type ExecutionMode = Extract< + SharedExecutionMode, + "default" | "acceptEdits" | "plan" | "auto" +>; +export type ReasoningEffort = SupportedReasoningEffort; export const EXECUTION_MODES: { value: ExecutionMode; @@ -62,20 +74,19 @@ export const MODELS: ModelOption[] = [ }, ]; +export const DEFAULT_EXECUTION_MODE: ExecutionMode = "plan"; +export const DEFAULT_MODEL = + defaultEligibleModel(DEFAULT_GATEWAY_MODEL) ?? + MODELS.find((model) => defaultEligibleModel(model.value))?.value ?? + DEFAULT_GATEWAY_MODEL; +export const DEFAULT_REASONING: ReasoningEffort = DEFAULT_REASONING_EFFORT; + export const REASONING_LEVELS: { value: ReasoningEffort; label: string; -}[] = [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, -]; - -export const DEFAULT_EXECUTION_MODE: ExecutionMode = "plan"; -export const DEFAULT_MODEL = "claude-opus-4-8"; -export const DEFAULT_REASONING: ReasoningEffort = "high"; +}[] = (getReasoningEffortOptions("claude", DEFAULT_MODEL) ?? []).map( + (option) => ({ value: option.value, label: option.name }), +); export function modelLabel(value: string): string { return MODELS.find((m) => m.value === value)?.label ?? value; @@ -90,5 +101,5 @@ export function reasoningLabel(value: ReasoningEffort): string { } export function modelSupportsReasoning(value: string): boolean { - return MODELS.find((m) => m.value === value)?.supportsReasoning ?? false; + return getReasoningEffortOptions("claude", value) !== null; } diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.ts index e22d3e6d16..a4667af875 100644 --- a/apps/mobile/src/features/tasks/hooks/useAutomations.ts +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.ts @@ -1,3 +1,8 @@ +import type { + CreateTaskAutomationOptions, + TaskAutomation, + UpdateTaskAutomationOptions, +} from "@posthog/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; import { logger } from "@/lib/logger"; @@ -9,11 +14,6 @@ import { runTaskAutomation, updateTaskAutomation, } from "../api"; -import type { - CreateTaskAutomationOptions, - TaskAutomation, - UpdateTaskAutomationOptions, -} from "../types"; import { taskKeys } from "./useTasks"; const log = logger.scope("automations-mutations"); diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index 1af7d5aa84..19b13fe570 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -1,3 +1,4 @@ +import type { Task } from "@posthog/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore, useUserQuery } from "@/features/auth"; import { logger } from "@/lib/logger"; @@ -10,7 +11,7 @@ import { updateTask, } from "../api"; import { filterAndSortTasks, useTaskStore } from "../stores/taskStore"; -import type { CreateTaskOptions, Task } from "../types"; +import type { CreateTaskOptions } from "../types"; const log = logger.scope("tasks-mutations"); const ACTIVE_TASK_POLLING_INTERVAL_MS = 5_000; diff --git a/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts b/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts index 2541eed7e7..7ec96e3851 100644 --- a/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts +++ b/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts @@ -1,3 +1,10 @@ +import { + type CloudTaskUpdatePayload, + isTerminalStatus, + type StoredLogEntry, + type TaskRun, + type TaskRunStatus, +} from "@posthog/shared"; import { fetch } from "expo/fetch"; import { createTimeoutSignal } from "@/lib/api"; import { logger } from "@/lib/logger"; @@ -8,16 +15,11 @@ import { streamCloudTask, } from "../api"; import { - type CloudTaskUpdatePayload, isKeepaliveEvent, isPermissionRequestEvent, isSseErrorEvent, isTaskRunStateEvent, - isTerminalStatus, - type StoredLogEntry, - type TaskRun, type TaskRunStateEvent, - type TaskRunStatus, } from "../types"; import { parseSessionLogs } from "../utils/parseSessionLogs"; import { type SseEvent, SseEventParser } from "./sseParser"; diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts index ddc5da1459..590312e815 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts @@ -1,3 +1,9 @@ +import type { + CloudTaskUpdatePayload, + StoredLogEntry, + Task, + TaskRun, +} from "@posthog/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("expo-haptics", () => ({ @@ -25,12 +31,6 @@ vi.mock("../api", () => ({ import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { getTask, runTaskInCloud } from "../api"; -import type { - CloudTaskUpdatePayload, - StoredLogEntry, - Task, - TaskRun, -} from "../types"; import { useMessageQueueStore } from "./messageQueueStore"; import { type TaskSession, useTaskSessionStore } from "./taskSessionStore"; import { useTaskStore } from "./taskStore"; diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index fdf91dfe9d..beac7d35a9 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,3 +1,9 @@ +import { + type CloudTaskUpdatePayload, + isTerminalStatus, + type StoredLogEntry, + type Task, +} from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { AppState } from "react-native"; import { create } from "zustand"; @@ -18,15 +24,11 @@ import { type WatchCloudTaskHandle, watchCloudTask, } from "../lib/cloudTaskStream"; -import { - type CloudPendingPermissionRequest, - type CloudTaskUpdatePayload, - isTerminalStatus, - type SessionEvent, - type SessionNotification, - type SessionNotificationAttachment, - type StoredLogEntry, - type Task, +import type { + CloudPendingPermissionRequest, + SessionEvent, + SessionNotification, + SessionNotificationAttachment, } from "../types"; import { convertStoredEntriesToEvents } from "../utils/parseSessionLogs"; import { playbackRateForTaskDuration } from "../utils/playbackRate"; diff --git a/apps/mobile/src/features/tasks/stores/taskStore.test.ts b/apps/mobile/src/features/tasks/stores/taskStore.test.ts index 19f990c105..3a39177ec6 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.test.ts @@ -1,5 +1,5 @@ +import type { Task } from "@posthog/shared"; import { describe, expect, it } from "vitest"; -import type { Task } from "../types"; import { filterAndSortTasks } from "./taskStore"; function makeTask(overrides: Partial): Task { diff --git a/apps/mobile/src/features/tasks/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts index 6c8daa14f2..ba7203e80f 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.ts @@ -1,9 +1,10 @@ +import type { Task } from "@posthog/shared"; import { isContentlessTask } from "@posthog/shared/domain-types"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import type { ExecutionMode, ReasoningEffort } from "../composer/options"; -import type { RepositorySelection, Task } from "../types"; +import type { RepositorySelection } from "../types"; export type OrganizeMode = "by-project" | "chronological"; export type SortMode = "created" | "updated"; diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 18c31142ea..29a2754d7f 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -1,88 +1,11 @@ -export interface Task { - id: string; - task_number: number | null; - slug: string; - title: string; - description: string; - created_at: string; - updated_at: string; - origin_product: string; - /** Inbox report UUID when origin_product is "signal_report". */ - signal_report?: string | null; - repository?: string | null; - github_integration?: number | null; - internal?: boolean; - latest_run?: TaskRun; -} - -export interface TaskAutomation { - id: string; - name: string; - prompt: string; - repository: string; - github_integration?: number | null; - cron_expression: string; - timezone?: string | null; - template_id?: string | null; - enabled: boolean; - last_run_at: string | null; - last_run_status: string | null; - last_task_id: string | null; - last_task_run_id: string | null; - last_error: string | null; - created_at: string; - updated_at: string; -} - -export type TaskRunStatus = - | "not_started" - | "queued" - | "started" - | "in_progress" - | "completed" - | "failed" - | "cancelled"; - -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 interface TaskRun { - id: string; - task: string; - team: number; - branch: string | null; - stage?: string | null; - environment?: "local" | "cloud"; - status: TaskRunStatus; - log_url: string; - error_message: string | null; - reasoning_effort?: string | null; - output: Record | null; - state: Record; - created_at: string; - updated_at: string; - completed_at: string | null; -} - -export interface StoredLogEntry { - type: string; - timestamp?: string; - notification?: { - id?: number; - method?: string; - params?: unknown; - result?: unknown; - error?: unknown; - }; +import type { + CloudPermissionOption, + CloudTaskPermissionRequestUpdate, + StoredLogEntry as SharedStoredLogEntry, + TaskRunStatus, +} from "@posthog/shared"; + +export interface MobileStoredLogEntry extends SharedStoredLogEntry { direction?: "client" | "agent"; } @@ -138,22 +61,6 @@ export interface SessionUpdateEvent { export type SessionEvent = AcpMessage | SessionUpdateEvent; -export interface CloudPermissionOption { - kind: string; - optionId: string; - name: string; - _meta?: Record; -} - -export interface CloudPermissionToolCall { - toolCallId: string; - title: string; - kind: string; - content?: unknown[]; - rawInput?: Record; - _meta?: Record; -} - export interface CloudPermissionResponseSelection { optionId: string; displayText: string; @@ -163,63 +70,11 @@ export interface CloudPermissionResponseSelection { export interface CloudPendingPermissionRequest { requestId: string; - toolCall: CloudPermissionToolCall; + toolCall: CloudTaskPermissionRequestUpdate["toolCall"]; options: CloudPermissionOption[]; response?: CloudPermissionResponseSelection; } -interface CloudTaskUpdateBase { - taskId: string; - runId: string; -} - -export interface CloudTaskLogsUpdate extends CloudTaskUpdateBase { - kind: "logs"; - newEntries: StoredLogEntry[]; - totalEntryCount: number; -} - -export interface CloudTaskStatusUpdate extends CloudTaskUpdateBase { - kind: "status"; - status?: TaskRunStatus; - stage?: string | null; - output?: Record | null; - errorMessage?: string | null; - branch?: string | null; -} - -export interface CloudTaskSnapshotUpdate extends CloudTaskUpdateBase { - kind: "snapshot"; - newEntries: StoredLogEntry[]; - totalEntryCount: number; - status?: TaskRunStatus; - stage?: string | null; - output?: Record | null; - errorMessage?: string | null; - branch?: string | null; -} - -export interface CloudTaskErrorUpdate extends CloudTaskUpdateBase { - kind: "error"; - errorTitle: string; - errorMessage: string; - retryable: boolean; -} - -export interface CloudTaskPermissionRequestUpdate extends CloudTaskUpdateBase { - kind: "permission_request"; - requestId: string; - toolCall: CloudPermissionToolCall; - options: CloudPermissionOption[]; -} - -export type CloudTaskUpdatePayload = - | CloudTaskLogsUpdate - | CloudTaskStatusUpdate - | CloudTaskSnapshotUpdate - | CloudTaskErrorUpdate - | CloudTaskPermissionRequestUpdate; - export interface TaskRunStateEvent { type: "task_run_state"; status?: TaskRunStatus; @@ -234,7 +89,7 @@ export interface TaskRunStateEvent { export interface PermissionRequestEventData { type: "permission_request"; requestId: string; - toolCall: CloudPermissionToolCall; + toolCall: CloudTaskPermissionRequestUpdate["toolCall"]; options: CloudPermissionOption[]; } @@ -325,25 +180,3 @@ export interface CreateTaskOptions { * cloud runs. Preferred over `github_integration` for interactive tasks. */ github_user_integration?: string; } - -export interface CreateTaskAutomationOptions { - name: string; - prompt: string; - repository: string; - github_integration?: number | null; - cron_expression: string; - timezone: string; - enabled?: boolean; - template_id?: string | null; -} - -export interface UpdateTaskAutomationOptions { - name?: string; - prompt?: string; - repository?: string; - github_integration?: number | null; - cron_expression?: string; - timezone?: string; - enabled?: boolean; - template_id?: string | null; -} diff --git a/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts b/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts index 6ad491c61f..b1a9b2ec3d 100644 --- a/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts +++ b/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts @@ -1,6 +1,6 @@ +import type { Task, TaskRunStatus } from "@posthog/shared"; import { Alert } from "react-native"; import { describe, expect, it, vi } from "vitest"; -import type { Task, TaskRunStatus } from "../types"; import { confirmArchiveRunningTask, isTaskRunning } from "./archiveGuard"; function makeTask(status?: TaskRunStatus): Task { @@ -39,7 +39,7 @@ describe("isTaskRunning", () => { expect(isTaskRunning(makeTask())).toBe(false); }); - it.each(["not_started", "queued", "started", "in_progress"] as const)( + it.each(["not_started", "queued", "in_progress"] as const)( "treats %s as running", (status) => { expect(isTaskRunning(makeTask(status))).toBe(true); diff --git a/apps/mobile/src/features/tasks/utils/archiveGuard.ts b/apps/mobile/src/features/tasks/utils/archiveGuard.ts index 377b13dd47..d537075cf1 100644 --- a/apps/mobile/src/features/tasks/utils/archiveGuard.ts +++ b/apps/mobile/src/features/tasks/utils/archiveGuard.ts @@ -1,5 +1,5 @@ +import { isTerminalStatus, type Task } from "@posthog/shared"; import { Alert } from "react-native"; -import { isTerminalStatus, type Task } from "../types"; export function isTaskRunning(task: Task): boolean { const status = task.latest_run?.status; diff --git a/apps/mobile/src/features/tasks/utils/automationSchedule.ts b/apps/mobile/src/features/tasks/utils/automationSchedule.ts index 06cec5faf3..312616ba09 100644 --- a/apps/mobile/src/features/tasks/utils/automationSchedule.ts +++ b/apps/mobile/src/features/tasks/utils/automationSchedule.ts @@ -1,4 +1,4 @@ -import type { TaskAutomation } from "../types"; +import type { TaskAutomation } from "@posthog/shared"; export type AutomationScheduleMode = | "hourly" diff --git a/apps/mobile/src/features/tasks/utils/automationStatus.ts b/apps/mobile/src/features/tasks/utils/automationStatus.ts index e5dd7c8fe3..5e8b7f553f 100644 --- a/apps/mobile/src/features/tasks/utils/automationStatus.ts +++ b/apps/mobile/src/features/tasks/utils/automationStatus.ts @@ -1,4 +1,4 @@ -import type { TaskRun } from "../types"; +import type { TaskRun } from "@posthog/shared"; export interface AutomationStatusInput { lastRunStatus: string | null; @@ -21,7 +21,6 @@ export function getAutomationStatusPresentation({ label: "Queued", className: "bg-status-warning/20 text-status-warning", }; - case "started": case "in_progress": return null; case "completed": diff --git a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts index d899a29b9b..682bfeab64 100644 --- a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts +++ b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts @@ -1,5 +1,5 @@ +import type { TaskAutomation } from "@posthog/shared"; import { parseSkillTemplateId } from "../skills/skillTemplateIds"; -import type { TaskAutomation } from "../types"; export interface AutomationTemplatePresentation { templateName: string | null; diff --git a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts index 9cce3995d2..6ff4044539 100644 --- a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts +++ b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts @@ -1,12 +1,12 @@ import type { + MobileStoredLogEntry, SessionEvent, SessionNotification, - StoredLogEntry, } from "../types"; export interface ParsedSessionLogs { notifications: SessionNotification[]; - rawEntries: StoredLogEntry[]; + rawEntries: MobileStoredLogEntry[]; } export function parseSessionLogs(content: string): ParsedSessionLogs { @@ -15,11 +15,11 @@ export function parseSessionLogs(content: string): ParsedSessionLogs { } const notifications: SessionNotification[] = []; - const rawEntries: StoredLogEntry[] = []; + const rawEntries: MobileStoredLogEntry[] = []; for (const line of content.trim().split("\n")) { try { - const stored = JSON.parse(line) as StoredLogEntry; + const stored = JSON.parse(line) as MobileStoredLogEntry; const msg = stored.notification; if (msg) { @@ -54,7 +54,7 @@ export function parseSessionLogs(content: string): ParsedSessionLogs { } export function convertRawEntriesToEvents( - rawEntries: StoredLogEntry[], + rawEntries: MobileStoredLogEntry[], notifications: SessionNotification[], ): SessionEvent[] { const events: SessionEvent[] = []; @@ -89,7 +89,7 @@ export function convertRawEntriesToEvents( return events; } -function inferDirection(entry: StoredLogEntry): "client" | "agent" { +function inferDirection(entry: MobileStoredLogEntry): "client" | "agent" { if (entry.direction) return entry.direction; const msg = entry.notification; if (!msg) return "agent"; @@ -102,7 +102,7 @@ function inferDirection(entry: StoredLogEntry): "client" | "agent" { } export function convertStoredEntriesToEvents( - entries: StoredLogEntry[], + entries: MobileStoredLogEntry[], ): SessionEvent[] { const events: SessionEvent[] = []; for (const entry of entries) { From 5e255d344d1411f0ed46865b86bd18d1d87337c3 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 21 Jul 2026 16:28:54 +0300 Subject: [PATCH 03/10] Share cloud task API transport Generated-By: PostHog Code Task-Id: 40c57a59-b4e1-4760-8e56-ecd03e9c2f0f --- apps/mobile/package.json | 2 + apps/mobile/src/lib/analytics.ts | 10 +- apps/mobile/src/lib/api.ts | 15 +- apps/mobile/src/lib/posthogApiClient.test.ts | 190 +++++++++ apps/mobile/src/lib/posthogApiClient.ts | 84 ++++ packages/api-client/package.json | 1 - packages/api-client/src/fetcher.test.ts | 39 ++ packages/api-client/src/fetcher.ts | 25 +- packages/api-client/src/index.ts | 6 +- .../src/posthog-client.automations.test.ts | 192 +++++++++ .../api-client/src/posthog-client.test.ts | 349 ++++++++++++++++- packages/api-client/src/posthog-client.ts | 369 +++++++++++++++--- .../api-client/src/task-normalization.test.ts | 115 ++++++ packages/api-client/src/task-normalization.ts | 203 ++++++++++ pnpm-lock.yaml | 9 +- 15 files changed, 1542 insertions(+), 67 deletions(-) create mode 100644 apps/mobile/src/lib/posthogApiClient.test.ts create mode 100644 apps/mobile/src/lib/posthogApiClient.ts create mode 100644 packages/api-client/src/posthog-client.automations.test.ts create mode 100644 packages/api-client/src/task-normalization.test.ts create mode 100644 packages/api-client/src/task-normalization.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f1057c0146..09ebacf3d0 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -27,6 +27,8 @@ "@expo/ui": "0.2.0-beta.9", "@modelcontextprotocol/ext-apps": "^1.2.2", "@modelcontextprotocol/sdk": "^1.29.0", + "@posthog/api-client": "workspace:*", + "@posthog/core": "workspace:*", "@posthog/shared": "workspace:*", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/netinfo": "^12.0.1", diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index 0bee789344..e7665ba661 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -1,5 +1,4 @@ -import type { PostHogEventProperties } from "@posthog/core"; -import { usePostHog } from "posthog-react-native"; +import { type PostHog, usePostHog } from "posthog-react-native"; import { useEffect, useMemo } from "react"; /** @@ -199,6 +198,8 @@ export interface Analytics { ): void; } +type PostHogCaptureProperties = Parameters[1]; + // Client discriminator stamped on inbox events so the shared PostHog project // can be sliced by surface (desktop sends "code", the web frontend sends // "cloud"). Mirrors packages/ui/src/shell/posthogAnalyticsImpl.ts. @@ -221,12 +222,9 @@ export function useAnalytics(): Analytics { const enriched = INBOX_ANALYTICS_EVENT_NAMES.has(eventName) ? { inbox_client: INBOX_CLIENT, ...properties } : properties; - // Our typed property interfaces don't carry an index signature; cast - // to the wider PostHog event-properties shape without losing the - // narrower call-site type-check. posthog?.capture( eventName, - enriched as unknown as PostHogEventProperties, + enriched as unknown as PostHogCaptureProperties, ); }, }), diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index 58603788d3..ccc2a97a09 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -3,9 +3,20 @@ import Constants from "expo-constants"; import { useAuthStore } from "@/features/auth"; import { logger } from "@/lib/logger"; +export class HttpError extends Error { + constructor( + readonly status: number, + readonly statusText: string, + message: string, + ) { + super(message); + this.name = "HttpError"; + } +} + // Derive the init shape directly from expo/fetch so we don't import from // expo's internal build output (which can move between versions). -type FetchInit = NonNullable[1]>; +export type FetchInit = NonNullable[1]>; const log = logger.scope("api"); @@ -66,7 +77,7 @@ export function createTimeoutSignal(ms: number): AbortSignal { // pending refresh across all callers and reset it once it settles. let pendingRefresh: Promise | null = null; -async function refreshAccessTokenOnce(): Promise { +export async function refreshAccessTokenOnce(): Promise { if (pendingRefresh) return pendingRefresh; const promise = useAuthStore .getState() diff --git a/apps/mobile/src/lib/posthogApiClient.test.ts b/apps/mobile/src/lib/posthogApiClient.test.ts new file mode 100644 index 0000000000..2aae2a0ed2 --- /dev/null +++ b/apps/mobile/src/lib/posthogApiClient.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authState: { + cloudRegion: "us" as string | null, + getCloudUrlFromRegion: vi.fn(() => "https://us.posthog.com"), + oauthAccessToken: "access-token" as string | null, + projectId: 123 as number | null, + refreshAccessToken: vi.fn(async () => {}), + }, + expoApplication: { + nativeApplicationVersion: "1.2.3" as string | null, + }, + expoConstants: { + expoConfig: { version: "9.9.9" } as { version?: string } | null, + }, + expoFetch: vi.fn(), + instances: [] as Array<{ + apiHost: string; + getAccessToken: () => Promise; + refreshAccessToken: () => Promise; + teamId: number | undefined; + options: Record; + setTeamId: ReturnType; + }>, +})); + +vi.mock("@posthog/api-client/posthog-client", () => ({ + PostHogAPIClient: class { + setTeamId = vi.fn(); + + constructor( + apiHost: string, + getAccessToken: () => Promise, + refreshAccessToken: () => Promise, + teamId: number | undefined, + options: Record, + ) { + mocks.instances.push({ + apiHost, + getAccessToken, + refreshAccessToken, + teamId, + options, + setTeamId: this.setTeamId, + }); + } + }, +})); + +vi.mock("expo-application", () => ({ + get nativeApplicationVersion() { + return mocks.expoApplication.nativeApplicationVersion; + }, +})); + +vi.mock("expo-constants", () => ({ + default: { + get expoConfig() { + return mocks.expoConstants.expoConfig; + }, + }, +})); + +vi.mock("expo/fetch", () => ({ fetch: mocks.expoFetch })); + +vi.mock("@/features/auth", () => ({ + useAuthStore: { + getState: () => mocks.authState, + }, +})); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.instances.length = 0; + mocks.authState.cloudRegion = "us"; + mocks.authState.oauthAccessToken = "access-token"; + mocks.authState.projectId = 123; + mocks.authState.getCloudUrlFromRegion.mockReturnValue( + "https://us.posthog.com", + ); + mocks.authState.refreshAccessToken.mockImplementation(async () => {}); + mocks.expoApplication.nativeApplicationVersion = "1.2.3"; + mocks.expoConstants.expoConfig = { version: "9.9.9" }; +}); + +describe("createPostHogApiClient", () => { + it("configures the shared client for the mobile host", async () => { + const { createPostHogApiClient } = await import("./posthogApiClient"); + + createPostHogApiClient(); + + expect(mocks.instances).toHaveLength(1); + expect(mocks.instances[0]).toMatchObject({ + apiHost: "https://us.posthog.com", + teamId: 123, + options: { + appVersion: "1.2.3", + fetch: mocks.expoFetch, + githubConnectFrom: "posthog_mobile", + userAgent: "posthog/mobile.hog.dev; version: 1.2.3", + }, + }); + }); + + it("falls back to the Expo config version", async () => { + mocks.expoApplication.nativeApplicationVersion = null; + mocks.expoConstants.expoConfig = { version: "4.5.6" }; + const { createPostHogApiClient } = await import("./posthogApiClient"); + + createPostHogApiClient(); + + expect(mocks.instances[0]?.options).toMatchObject({ + appVersion: "4.5.6", + userAgent: "posthog/mobile.hog.dev; version: 4.5.6", + }); + }); + + it("returns the refreshed token from the current auth store state", async () => { + mocks.authState.refreshAccessToken.mockImplementation(async () => { + mocks.authState.oauthAccessToken = "refreshed-token"; + }); + const { createPostHogApiClient } = await import("./posthogApiClient"); + createPostHogApiClient(); + + await expect(mocks.instances[0]?.refreshAccessToken()).resolves.toBe( + "refreshed-token", + ); + expect(mocks.authState.refreshAccessToken).toHaveBeenCalledOnce(); + }); + + it("shares one refresh across concurrent client retries", async () => { + let resolveRefresh: (() => void) | undefined; + mocks.authState.refreshAccessToken.mockImplementation( + () => + new Promise((resolve) => { + resolveRefresh = () => { + mocks.authState.oauthAccessToken = "refreshed-token"; + resolve(); + }; + }), + ); + const { createPostHogApiClient } = await import("./posthogApiClient"); + createPostHogApiClient(); + + const refreshes = [ + mocks.instances[0]?.refreshAccessToken(), + mocks.instances[0]?.refreshAccessToken(), + ]; + expect(mocks.authState.refreshAccessToken).toHaveBeenCalledOnce(); + resolveRefresh?.(); + + await expect(Promise.all(refreshes)).resolves.toEqual([ + "refreshed-token", + "refreshed-token", + ]); + }); +}); + +describe("getPostHogApiClient", () => { + it("reuses the regional client and updates its project", async () => { + const { getPostHogApiClient } = await import("./posthogApiClient"); + + const first = getPostHogApiClient(); + mocks.authState.projectId = 456; + const second = getPostHogApiClient(); + + expect(second).toBe(first); + expect(mocks.instances).toHaveLength(1); + expect(mocks.instances[0]?.setTeamId).toHaveBeenCalledWith(456); + }); + + it("creates a new client when the cloud region changes", async () => { + const { getPostHogApiClient } = await import("./posthogApiClient"); + + const first = getPostHogApiClient(); + mocks.authState.cloudRegion = "eu"; + mocks.authState.getCloudUrlFromRegion.mockReturnValue( + "https://eu.posthog.com", + ); + const second = getPostHogApiClient(); + + expect(second).not.toBe(first); + expect(mocks.instances.map(({ apiHost }) => apiHost)).toEqual([ + "https://us.posthog.com", + "https://eu.posthog.com", + ]); + }); +}); diff --git a/apps/mobile/src/lib/posthogApiClient.ts b/apps/mobile/src/lib/posthogApiClient.ts new file mode 100644 index 0000000000..c094452f44 --- /dev/null +++ b/apps/mobile/src/lib/posthogApiClient.ts @@ -0,0 +1,84 @@ +import type { FetchImplementation } from "@posthog/api-client/fetcher"; +import { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { fetch } from "expo/fetch"; +import * as Application from "expo-application"; +import Constants from "expo-constants"; +import { useAuthStore } from "@/features/auth"; +import { refreshAccessTokenOnce } from "@/lib/api"; + +const MOBILE_GITHUB_CONNECT_FROM = "posthog_mobile"; + +let posthogApiClient: PostHogAPIClient | null = null; +let posthogApiHost: string | null = null; + +function getAppVersion(): string { + return ( + Application.nativeApplicationVersion ?? + Constants.expoConfig?.version ?? + "unknown" + ); +} + +function getAuthenticatedContext(): { + apiHost: string; + projectId: number; +} { + const { cloudRegion, getCloudUrlFromRegion, projectId } = + useAuthStore.getState(); + + if (!cloudRegion) { + throw new Error("No cloud region set"); + } + if (!projectId) { + throw new Error("No project ID set"); + } + + return { + apiHost: getCloudUrlFromRegion(cloudRegion), + projectId, + }; +} + +async function getAccessToken(): Promise { + const { oauthAccessToken } = useAuthStore.getState(); + if (!oauthAccessToken) { + throw new Error("Not authenticated"); + } + return oauthAccessToken; +} + +async function refreshAccessToken(): Promise { + await refreshAccessTokenOnce(); + return getAccessToken(); +} + +export function createPostHogApiClient(): PostHogAPIClient { + const { apiHost, projectId } = getAuthenticatedContext(); + const appVersion = getAppVersion(); + + return new PostHogAPIClient( + apiHost, + getAccessToken, + refreshAccessToken, + projectId, + { + appVersion, + fetch: fetch as FetchImplementation, + githubConnectFrom: MOBILE_GITHUB_CONNECT_FROM, + userAgent: `posthog/mobile.hog.dev; version: ${appVersion}`, + }, + ); +} + +export function getPostHogApiClient(): PostHogAPIClient { + const { apiHost, projectId } = getAuthenticatedContext(); + + if (!posthogApiClient || posthogApiHost !== apiHost) { + posthogApiClient = createPostHogApiClient(); + posthogApiHost = apiHost; + } else { + posthogApiClient.setTeamId(projectId); + } + + return posthogApiClient; +} 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 d1cf7861c4..fc5053928e 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,4 +1,8 @@ import "./generated.augment"; -export { type ApiFetcherConfig, buildApiFetcher } from "./fetcher"; +export { + type ApiFetcherConfig, + buildApiFetcher, + type FetchImplementation, +} from "./fetcher"; export { createApiClient, type Schemas } from "./generated"; 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 472a8eeb74..cd109805a5 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -1,7 +1,336 @@ import { describe, expect, it, vi } from "vitest"; -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", @@ -224,7 +553,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({ @@ -402,7 +737,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 29c687a4fb..1511865147 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -1,19 +1,31 @@ import "./generated.augment"; -import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort"; import type { Adapter, CloudMcpServerImport, CloudMcpServerRelayDesignation, CloudRunSource, + CreateTaskAutomationOptions, ExecutionMode, PrAuthorshipMode, 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, @@ -96,9 +108,17 @@ import { type HogQLGrid, shapeAgentAnalytics, } from "./agent-analytics"; -import { buildApiFetcher } from "./fetcher"; +import { + ApiRequestError, + buildApiFetcher, + type FetchImplementation, +} 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; } @@ -117,6 +137,13 @@ export function setPosthogApiClientAppVersion(version: string): void { clientAppVersion = version; } +export interface PostHogAPIClientOptions { + fetch?: FetchImplementation; + appVersion?: string; + userAgent?: string | null; + githubConnectFrom?: string; +} + export class SandboxCustomImagesDisabledError extends Error { constructor(message?: string) { super(message ?? "Custom sandbox images are not enabled"); @@ -154,6 +181,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" }, @@ -590,7 +647,7 @@ export interface FinalizedTaskArtifactUpload { uploaded_at?: string; } -interface CloudRunOptions { +export interface CloudRunOptions { adapter?: Adapter; model?: string; reasoningLevel?: string; @@ -612,6 +669,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"; @@ -1355,19 +1462,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, ); @@ -1404,6 +1520,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. @@ -1768,7 +1899,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) { @@ -2346,7 +2480,7 @@ export class PostHogAPIClient { originProduct?: string; internal?: boolean; channel?: string; - }) { + }): Promise { const teamId = await this.getTeamId(); const params: Record = { limit: 500, @@ -2377,7 +2511,9 @@ export class PostHogAPIClient { query: params, }); - return data.results ?? []; + return (data.results ?? []).map((task) => + normalizeTaskResponse(task, { teamId }), + ); } async getTaskSummaries(ids: string[]) { @@ -2419,7 +2555,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( @@ -2446,7 +2677,7 @@ export class PostHogAPIClient { pending_user_artifact_ids?: string[]; auto_publish?: boolean; }, - ) { + ): Promise { const teamId = await this.getTeamId(); const { origin_product: originProduct, ...taskOptions } = options; @@ -2458,10 +2689,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}/`, @@ -2471,7 +2705,7 @@ export class PostHogAPIClient { }, ); - return data; + return normalizeTaskResponse(data, { teamId }); } async deleteTask(taskId: string) { @@ -2715,9 +2949,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/`, @@ -2725,7 +2978,7 @@ export class PostHogAPIClient { const body = { jsonrpc: "2.0", method, - params: params ?? {}, + params, id: `posthog-code-${Date.now()}`, }; @@ -2739,39 +2992,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, @@ -2795,7 +3063,7 @@ export class PostHogAPIClient { }), ); - return data as unknown as Task; + return normalizeTaskResponse(data, { teamId }); } async warmTask(options: { @@ -3007,7 +3275,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 { @@ -3025,8 +3294,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 { @@ -3044,7 +3316,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( @@ -3076,7 +3349,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( @@ -3106,7 +3380,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( @@ -3131,7 +3406,7 @@ export class PostHogAPIClient { body: updates as Record, }, ); - return data as unknown as TaskRun; + return normalizeTaskRunResponse(data, { teamId, taskId }); } /** @@ -3221,14 +3496,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/pnpm-lock.yaml b/pnpm-lock.yaml index bea5e28250..ece21aa003 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -476,6 +476,12 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@4.4.3) + '@posthog/api-client': + specifier: workspace:* + version: link:../../packages/api-client + '@posthog/core': + specifier: workspace:* + version: link:../../packages/core '@posthog/shared': specifier: workspace:* version: link:../../packages/shared @@ -833,9 +839,6 @@ importers: packages/api-client: dependencies: - '@posthog/agent': - specifier: workspace:* - version: link:../agent '@posthog/shared': specifier: workspace:* version: link:../shared From fe1c27f27d6326b6e4e9fa6abd9e0920f313acf4 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 21 Jul 2026 16:28:56 +0300 Subject: [PATCH 04/10] refactor(mobile): adopt shared task API client Generated-By: PostHog Code Task-Id: 40c57a59-b4e1-4760-8e56-ecd03e9c2f0f --- apps/mobile/package.json | 1 - apps/mobile/src/app/automation/[id].tsx | 2 +- apps/mobile/src/app/automation/create.tsx | 2 +- apps/mobile/src/app/task/[id].tsx | 9 +- apps/mobile/src/app/task/index.tsx | 5 +- .../features/chat/components/ToolMessage.tsx | 7 +- apps/mobile/src/features/inbox/api.ts | 3 +- .../features/inbox/components/TinderView.tsx | 5 +- .../features/tasks/api.automations.test.ts | 280 --------- apps/mobile/src/features/tasks/api.test.ts | 10 + apps/mobile/src/features/tasks/api.ts | 594 +----------------- .../src/features/tasks/api.warm.test.ts | 135 ---- .../CreateAutomationScreen.test.tsx | 2 +- .../components/CustomImageBadge.test.tsx | 8 +- .../components/GitHubConnectionPrompt.tsx | 5 +- .../tasks/hooks/useAutomations.test.ts | 24 +- .../features/tasks/hooks/useAutomations.ts | 23 +- .../tasks/hooks/useCustomImageName.ts | 6 +- .../tasks/hooks/useIntegrations.test.ts | 8 +- .../features/tasks/hooks/useIntegrations.ts | 31 +- .../src/features/tasks/hooks/useTasks.test.ts | 15 +- .../src/features/tasks/hooks/useTasks.ts | 27 +- .../tasks/hooks/useUserIntegrations.ts | 13 +- .../features/tasks/hooks/useWarmTask.test.tsx | 4 +- .../src/features/tasks/hooks/useWarmTask.ts | 26 +- apps/mobile/src/features/tasks/index.ts | 2 - .../tasks/stores/taskSessionStore.test.ts | 10 +- .../features/tasks/stores/taskSessionStore.ts | 4 +- packages/api-client/src/posthog-client.ts | 7 +- pnpm-lock.yaml | 3 - 30 files changed, 160 insertions(+), 1111 deletions(-) delete mode 100644 apps/mobile/src/features/tasks/api.automations.test.ts delete mode 100644 apps/mobile/src/features/tasks/api.warm.test.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 09ebacf3d0..915c2bdad7 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -28,7 +28,6 @@ "@modelcontextprotocol/ext-apps": "^1.2.2", "@modelcontextprotocol/sdk": "^1.29.0", "@posthog/api-client": "workspace:*", - "@posthog/core": "workspace:*", "@posthog/shared": "workspace:*", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/netinfo": "^12.0.1", diff --git a/apps/mobile/src/app/automation/[id].tsx b/apps/mobile/src/app/automation/[id].tsx index 5983ecdf28..55f9bd5c6d 100644 --- a/apps/mobile/src/app/automation/[id].tsx +++ b/apps/mobile/src/app/automation/[id].tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import { TaskAutomationValidationError } from "@posthog/api-client/posthog-client"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useState } from "react"; import { @@ -10,7 +11,6 @@ import { ScrollView, View, } from "react-native"; -import { TaskAutomationValidationError } from "@/features/tasks/api"; import { AutomationDetail } from "@/features/tasks/components/AutomationDetail"; import { AutomationForm } from "@/features/tasks/components/AutomationForm"; import { diff --git a/apps/mobile/src/app/automation/create.tsx b/apps/mobile/src/app/automation/create.tsx index 2233cd688a..1f4d662bff 100644 --- a/apps/mobile/src/app/automation/create.tsx +++ b/apps/mobile/src/app/automation/create.tsx @@ -1,3 +1,4 @@ +import { TaskAutomationValidationError } from "@posthog/api-client/posthog-client"; import { getCalendars } from "expo-localization"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useMemo, useRef, useState } from "react"; @@ -10,7 +11,6 @@ import { View, } from "react-native"; import { Text } from "@/components/text"; -import { TaskAutomationValidationError } from "@/features/tasks/api"; import { AutomationForm } from "@/features/tasks/components/AutomationForm"; import { useCreateTaskAutomation } from "@/features/tasks/hooks/useAutomations"; import { useSkillStoreSkill } from "@/features/tasks/skills/hooks"; diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index c13ac713d2..3276288884 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -15,7 +15,7 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { FloatingBackButton } from "@/components/FloatingBackButton"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { getTask, runTaskInCloud } from "@/features/tasks/api"; +import { runTaskInCloud } from "@/features/tasks/api"; import { CustomImageBadge } from "@/features/tasks/components/CustomImageBadge"; import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader"; import { PrDiffStatsBadge } from "@/features/tasks/components/PrDiffStatsBadge"; @@ -67,6 +67,7 @@ import { useAnalytics, } from "@/lib/analytics"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useThemeColors } from "@/lib/theme"; const log = logger.scope("task-detail"); @@ -221,7 +222,8 @@ export default function TaskDetailScreen() { setLoading(true); setError(null); - getTask(taskId) + getPostHogApiClient() + .getTask(taskId) .then((fetchedTask) => { if (cancelled) return; setTask(fetchedTask); @@ -252,7 +254,8 @@ export default function TaskDetailScreen() { if (retrying) return; let cancelled = false; - getTask(taskId) + getPostHogApiClient() + .getTask(taskId) .then((freshTask) => { if (cancelled) return; setTask(freshTask); diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 75fe35e96a..012b206dcc 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -30,7 +30,7 @@ import { import Animated, { runOnJS, useAnimatedStyle } from "react-native-reanimated"; import { useVoiceRecording } from "@/features/chat"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { createTask, runTaskInCloud } from "@/features/tasks/api"; +import { runTaskInCloud } from "@/features/tasks/api"; import { GitHubConnectionPrompt } from "@/features/tasks/components/GitHubConnectionPrompt"; import { GitHubLoadNotice } from "@/features/tasks/components/GitHubLoadNotice"; import { AttachmentSheet } from "@/features/tasks/composer/attachments/AttachmentSheet"; @@ -80,6 +80,7 @@ import { } from "@/features/tasks/utils/repositorySelection"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { toRgba, useThemeColors } from "@/lib/theme"; const log = logger.scope("task-create"); @@ -308,7 +309,7 @@ export default function NewTaskScreen() { ? `Attached: ${attachments[0].fileName}` : `Attached ${attachments.length} files`); - const task = await createTask({ + const task = await getPostHogApiClient().createTask({ description: descriptionText, title: descriptionText.slice(0, 100), repository: selection.repository ?? undefined, diff --git a/apps/mobile/src/features/chat/components/ToolMessage.tsx b/apps/mobile/src/features/chat/components/ToolMessage.tsx index 479849a51b..3859504777 100644 --- a/apps/mobile/src/features/chat/components/ToolMessage.tsx +++ b/apps/mobile/src/features/chat/components/ToolMessage.tsx @@ -659,9 +659,12 @@ function CreateTaskPreview({ try { // Dynamic import to avoid circular dependency - const { createTask, runTaskInCloud } = await import("../../tasks/api"); + const [{ runTaskInCloud }, { getPostHogApiClient }] = await Promise.all([ + import("../../tasks/api"), + import("@/lib/posthogApiClient"), + ]); - const task = await createTask({ + const task = await getPostHogApiClient().createTask({ title: args.title, description: args.description, repository: args.repository, diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts index ce7bbeec0e..9b37b82725 100644 --- a/apps/mobile/src/features/inbox/api.ts +++ b/apps/mobile/src/features/inbox/api.ts @@ -1,5 +1,4 @@ -import { HttpError } from "@/features/tasks/api"; -import { authedFetch, getBaseUrl, getProjectId } from "@/lib/api"; +import { authedFetch, getBaseUrl, getProjectId, HttpError } from "@/lib/api"; import { logger } from "@/lib/logger"; import type { DismissalReasonOptionValue } from "./constants"; diff --git a/apps/mobile/src/features/inbox/components/TinderView.tsx b/apps/mobile/src/features/inbox/components/TinderView.tsx index cc034d6ceb..a8c99da1e4 100644 --- a/apps/mobile/src/features/inbox/components/TinderView.tsx +++ b/apps/mobile/src/features/inbox/components/TinderView.tsx @@ -17,7 +17,7 @@ import { } from "react-native-safe-area-context"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { createTask, runTaskInCloud } from "@/features/tasks/api"; +import { runTaskInCloud } from "@/features/tasks/api"; import { DEFAULT_MODEL } from "@/features/tasks/composer/options"; import type { CreateTaskOptions, @@ -29,6 +29,7 @@ import { useAnalytics, } from "@/lib/analytics"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useThemeColors } from "@/lib/theme"; import { getReportRepository } from "../api"; import { useDismissedReportsStore } from "../stores/dismissedReportsStore"; @@ -239,7 +240,7 @@ export function TinderView({ // 3. Create the task const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; - const task = await createTask({ + const task = await getPostHogApiClient().createTask({ description: prompt, title: prompt.slice(0, 255), repository: match?.repository ?? repo ?? undefined, diff --git a/apps/mobile/src/features/tasks/api.automations.test.ts b/apps/mobile/src/features/tasks/api.automations.test.ts deleted file mode 100644 index c4390a5d14..0000000000 --- a/apps/mobile/src/features/tasks/api.automations.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { mockFetch } = vi.hoisted(() => ({ - mockFetch: vi.fn(), -})); - -vi.mock("expo/fetch", () => ({ - fetch: mockFetch, -})); - -vi.mock("@/lib/api", () => ({ - getBaseUrl: () => "https://app.posthog.test", - getProjectId: () => 42, - authedFetch: (url: string, init?: RequestInit) => - mockFetch(url, { - ...init, - headers: { - Authorization: "Bearer token", - "Content-Type": "application/json", - ...((init?.headers as Record | undefined) ?? {}), - }, - }), -})); - -import { - createTaskAutomation, - deleteTaskAutomation, - getTaskAutomation, - getTaskAutomations, - runTaskAutomation, - TaskAutomationValidationError, - updateTaskAutomation, -} from "./api"; - -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:shared-daily-brief", - enabled: true, - last_run_at: null, - last_run_status: null, - last_task_id: "task-1", - last_task_run_id: null, - last_error: null, - created_at: "2026-05-13T00:00:00Z", - updated_at: "2026-05-13T00:00:00Z", -}; - -describe("task automation api", () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it("lists task automations from the existing backend endpoint", async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - results: [automationPayload], - }), - { status: 200 }, - ), - ); - - const automations = await getTaskAutomations(); - - expect(automations).toHaveLength(1); - expect(automations[0]?.id).toBe("automation-1"); - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/task_automations/?limit=500", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer token", - }), - }), - ); - }); - - it("serializes automation creation payloads with the existing backend contract", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify(automationPayload), { status: 200 }), - ); - - await createTaskAutomation({ - name: "Daily PRs", - prompt: "Check PRs", - repository: "posthog/posthog", - github_integration: 7, - cron_expression: "0 9 * * *", - timezone: "Europe/London", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }); - - expect(mockFetch).toHaveBeenCalledWith( - "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", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }), - }), - ); - }); - - it("serializes skill-backed automation payloads with a prefixed template id", async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - ...automationPayload, - id: "automation-2", - name: "Shared daily brief", - template_id: "llm-skill:shared-daily-brief", - }), - { status: 200 }, - ), - ); - - await createTaskAutomation({ - name: "Shared daily brief", - prompt: "Summarize feature usage for my product areas.", - repository: "posthog/posthog", - github_integration: 7, - cron_expression: "0 8 * * 1-5", - timezone: "America/New_York", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }); - - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/task_automations/", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - name: "Shared daily brief", - prompt: "Summarize feature usage for my product areas.", - repository: "posthog/posthog", - github_integration: 7, - cron_expression: "0 8 * * 1-5", - timezone: "America/New_York", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }), - }), - ); - }); - - it("retains backend field attribution for validation errors", async () => { - mockFetch.mockImplementation(() => - Promise.resolve( - new Response( - JSON.stringify({ - type: "validation_error", - code: "invalid_input", - detail: - "Only standard 5-field cron expressions are supported (minute hour day month weekday). Example: '0 9 * * 1-5'.", - attr: "cron_expression", - }), - { status: 400, statusText: "Bad Request" }, - ), - ), - ); - - await expect( - createTaskAutomation({ - name: "Daily PRs", - prompt: "Check PRs", - repository: "posthog/posthog", - cron_expression: "not a cron", - timezone: "Europe/London", - }), - ).rejects.toBeInstanceOf(TaskAutomationValidationError); - - await expect( - createTaskAutomation({ - name: "Daily PRs", - prompt: "Check PRs", - repository: "posthog/posthog", - cron_expression: "not a cron", - timezone: "Europe/London", - }), - ).rejects.toMatchObject({ - attr: "cron_expression", - code: "invalid_input", - }); - }); - - it("surfaces skill-backed validation failures without losing backend attr info", async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - type: "validation_error", - code: "invalid_input", - detail: "Repository is still required for this template.", - attr: "repository", - }), - { status: 400, statusText: "Bad Request" }, - ), - ); - - await expect( - createTaskAutomation({ - name: "Shared daily brief", - prompt: "Summarize feature usage for my product areas.", - repository: "", - github_integration: null, - cron_expression: "0 8 * * 1-5", - timezone: "America/New_York", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }), - ).rejects.toMatchObject({ - attr: "repository", - code: "invalid_input", - message: "Repository is still required for this template.", - }); - }); - - it("supports retrieve, update, delete, and run-now automation flows", async () => { - mockFetch - .mockResolvedValueOnce( - new Response(JSON.stringify(automationPayload), { status: 200 }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify(automationPayload), { status: 200 }), - ) - .mockResolvedValueOnce(new Response(null, { status: 204 })) - .mockResolvedValueOnce( - new Response(JSON.stringify(automationPayload), { status: 200 }), - ); - - const retrieved = await getTaskAutomation("automation-1"); - const updated = await updateTaskAutomation("automation-1", { - enabled: false, - cron_expression: "30 14 * * *", - }); - await deleteTaskAutomation("automation-1"); - const ran = await runTaskAutomation("automation-1"); - - expect(retrieved.id).toBe("automation-1"); - expect(updated.id).toBe("automation-1"); - expect(ran.id).toBe("automation-1"); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - "https://app.posthog.test/api/projects/42/task_automations/automation-1/", - expect.objectContaining({ - method: "PATCH", - body: JSON.stringify({ - enabled: false, - cron_expression: "30 14 * * *", - }), - }), - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 3, - "https://app.posthog.test/api/projects/42/task_automations/automation-1/", - expect.objectContaining({ - method: "DELETE", - }), - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 4, - "https://app.posthog.test/api/projects/42/task_automations/automation-1/run/", - expect.objectContaining({ - method: "POST", - }), - ); - }); -}); diff --git a/apps/mobile/src/features/tasks/api.test.ts b/apps/mobile/src/features/tasks/api.test.ts index 19e6f93dc1..ee7dcf02c8 100644 --- a/apps/mobile/src/features/tasks/api.test.ts +++ b/apps/mobile/src/features/tasks/api.test.ts @@ -9,6 +9,16 @@ vi.mock("expo/fetch", () => ({ })); vi.mock("@/lib/api", () => ({ + HttpError: class HttpError extends Error { + constructor( + readonly status: number, + readonly statusText: string, + message: string, + ) { + super(message); + this.name = "HttpError"; + } + }, getBaseUrl: () => "https://app.posthog.test", getProjectId: () => 42, getAccessToken: () => "token", diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index 169b1e4cd9..2408f688c6 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -1,16 +1,9 @@ import type { Adapter, - CreateTaskAutomationOptions, StoredLogEntry, Task, - TaskAutomation, TaskRun, - UpdateTaskAutomationOptions, } from "@posthog/shared"; -import type { - SandboxCustomImage, - SandboxEnvironment, -} from "@posthog/shared/domain-types"; import { fetch } from "expo/fetch"; import { authedFetch, @@ -18,69 +11,10 @@ import { getAccessToken, getBaseUrl, getProjectId, + HttpError, } from "@/lib/api"; -import { logger } from "@/lib/logger"; -import type { - CreateTaskOptions, - Integration, - UserGithubIntegration, -} from "./types"; - -const log = logger.scope("tasks-api"); - -export class HttpError extends Error { - readonly status: number; - - constructor(status: number, statusText: string, prefix: string) { - super(`${prefix}: ${status} ${statusText}`); - this.name = "HttpError"; - this.status = status; - } -} - -export class TaskAutomationValidationError extends Error { - readonly code: string; - readonly attr: string | null; - - constructor(message: string, code: string, attr: string | null) { - super(message); - this.name = "TaskAutomationValidationError"; - this.code = code; - this.attr = attr; - } -} - -async function parseJsonResponse(response: Response): Promise { - return (await response.json()) as T; -} - -async function parseTaskAutomationError(response: Response): Promise { - let payload: { - code?: string; - detail?: string; - attr?: string; - } | null = null; - - try { - payload = await response.json(); - } catch { - payload = null; - } - - if (response.status === 400 && payload?.detail) { - throw new TaskAutomationValidationError( - payload.detail, - payload.code ?? "invalid_input", - payload.attr ?? null, - ); - } - throw new HttpError( - response.status, - response.statusText, - "Task automation request failed", - ); -} +export { HttpError } from "@/lib/api"; async function withRetry( fn: () => Promise, @@ -127,301 +61,6 @@ function isRetryableError(error: unknown): boolean { return false; } -export async function getTasks(filters?: { - repository?: string; - createdBy?: number; - originProduct?: string; -}): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const params = new URLSearchParams({ limit: "500" }); - if (filters?.repository) { - params.set("repository", filters.repository); - } - if (filters?.createdBy) { - params.set("created_by", String(filters.createdBy)); - } - if (filters?.originProduct) { - params.set("origin_product", filters.originProduct); - } - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/?${params}`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch tasks", - ); - } - - const data = await parseJsonResponse<{ results?: Task[] }>(response); - return data.results ?? []; -} - -export async function getTask(taskId: string): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch task", - ); - } - - return await parseJsonResponse(response); -} - -export async function getTaskAutomations(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/?limit=500`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch task automations", - ); - } - - const data = await parseJsonResponse<{ results?: TaskAutomation[] }>( - response, - ); - return data.results ?? []; -} - -export async function getTaskAutomation( - automationId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch task automation", - ); - } - - return await parseJsonResponse(response); -} - -export async function createTaskAutomation( - options: CreateTaskAutomationOptions, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/`, - { - method: "POST", - body: JSON.stringify(options), - }, - ); - - if (!response.ok) { - await parseTaskAutomationError(response); - } - - return await parseJsonResponse(response); -} - -export async function updateTaskAutomation( - automationId: string, - updates: UpdateTaskAutomationOptions, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, - { - method: "PATCH", - body: JSON.stringify(updates), - }, - ); - - if (!response.ok) { - await parseTaskAutomationError(response); - } - - return await parseJsonResponse(response); -} - -export async function deleteTaskAutomation( - automationId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, - { method: "DELETE" }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to delete task automation", - ); - } -} - -export async function runTaskAutomation( - automationId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/run/`, - { method: "POST" }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to run task automation", - ); - } - - return await parseJsonResponse(response); -} - -export async function warmTask(options: { - repository: string; - github_integration: number; - branch?: string | null; - runtime_adapter?: string | null; - model?: string | null; - reasoning_effort?: string | null; -}): Promise<{ task_id: string; run_id: string } | null> { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/warm/`, - { - method: "POST", - body: JSON.stringify({ - repository: options.repository, - github_integration: options.github_integration, - branch: options.branch ?? null, - runtime_adapter: options.runtime_adapter ?? null, - model: options.model ?? null, - reasoning_effort: options.reasoning_effort ?? null, - }), - }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to warm task", - ); - } - - const text = await response.text(); - if (!text) { - return null; - } - return JSON.parse(text) as { task_id: string; run_id: string }; -} - -export async function createTask(options: CreateTaskOptions): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/`, - { - method: "POST", - body: JSON.stringify({ - origin_product: "user_created", - ...options, - }), - }, - ); - - if (!response.ok) { - const errorText = await response.text(); - log.error("Create task error", errorText); - throw new HttpError( - response.status, - `${response.statusText} - ${errorText}`, - "Failed to create task", - ); - } - - return await parseJsonResponse(response); -} - -export async function updateTask( - taskId: string, - updates: Partial, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/`, - { - method: "PATCH", - body: JSON.stringify(updates), - }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to update task", - ); - } - - return await parseJsonResponse(response); -} - -export async function deleteTask(taskId: string): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/`, - { method: "DELETE" }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to delete task", - ); - } -} - export interface RunTaskInCloudOptions { branch?: string | null; resumeFromRunId?: string; @@ -784,232 +423,3 @@ export async function streamCloudTask( signal: options.signal, }); } - -export async function getSandboxCustomImages(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/sandbox_custom_images/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch sandbox custom images", - ); - } - - const data = await parseJsonResponse<{ results?: SandboxCustomImage[] }>( - response, - ); - return data.results ?? []; -} - -export async function getSandboxEnvironments(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/sandbox_environments/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch sandbox environments", - ); - } - - const data = await parseJsonResponse<{ results?: SandboxEnvironment[] }>( - response, - ); - return data.results ?? []; -} - -export async function getIntegrations(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/environments/${projectId}/integrations/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch integrations", - ); - } - - const data = await parseJsonResponse< - | { - results?: Integration[]; - } - | Integration[] - >(response); - return Array.isArray(data) ? data : (data.results ?? []); -} - -const GITHUB_REPOS_PAGE_SIZE = 500; - -export async function getGithubRepositories( - integrationId: number, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const allRepos: string[] = []; - let offset = 0; - - while (true) { - const params = new URLSearchParams({ - limit: String(GITHUB_REPOS_PAGE_SIZE), - offset: String(offset), - }); - const response = await authedFetch( - `${baseUrl}/api/environments/${projectId}/integrations/${integrationId}/github_repos/?${params}`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch repositories", - ); - } - - const data = await response.json(); - const repos: Array = - data.repositories ?? data.results ?? data ?? []; - - const normalized = repos - .map((repo) => { - if (typeof repo === "string") return repo.toLowerCase(); - return (repo.full_name ?? repo.name ?? "").toLowerCase(); - }) - .filter((name) => name.length > 0); - - allRepos.push(...normalized); - - if (!data.has_more || repos.length === 0) { - return allRepos; - } - - offset += repos.length; - } -} - -export interface GithubUserConnectResult { - install_url: string; - connect_flow?: "oauth_authorize" | "oauth_discover" | "app_install"; -} - -/** - * Starts the user-scoped GitHub connection flow (mirrors desktop). The backend - * picks the lightweight OAuth flow when the team already has the GitHub App - * installed, otherwise a discover/install flow, and returns the URL to open. - * - * `connect_from: "posthog_mobile"` tells the backend to redirect the OAuth - * callback to `posthog://github/callback` so the in-app browser auto-closes. - */ -export async function startGithubUserIntegrationConnect(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/users/@me/integrations/github/start/`, - { - method: "POST", - body: JSON.stringify({ - team_id: projectId, - connect_from: "posthog_mobile", - }), - }, - ); - - if (!response.ok) { - const payload = (await response.json().catch(() => ({}))) as { - detail?: unknown; - }; - const detail = - typeof payload.detail === "string" - ? payload.detail - : "Failed to start GitHub connection"; - throw new HttpError(response.status, response.statusText, detail); - } - - return parseJsonResponse(response); -} - -export async function getUserGithubIntegrations(): Promise< - UserGithubIntegration[] -> { - const baseUrl = getBaseUrl(); - - const response = await authedFetch( - `${baseUrl}/api/users/@me/integrations/?kind=github`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch personal GitHub integrations", - ); - } - - const data = await parseJsonResponse<{ results?: UserGithubIntegration[] }>( - response, - ); - return data.results ?? []; -} - -export async function getUserGithubRepositories( - installationId: string, -): Promise { - const baseUrl = getBaseUrl(); - - const allRepos: string[] = []; - let offset = 0; - - while (true) { - const params = new URLSearchParams({ - limit: String(GITHUB_REPOS_PAGE_SIZE), - offset: String(offset), - }); - const response = await authedFetch( - `${baseUrl}/api/users/@me/integrations/github/${installationId}/repos/?${params}`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch repositories", - ); - } - - const data = await response.json(); - const repos: Array = - data.repositories ?? data.results ?? data ?? []; - - const normalized = repos - .map((repo) => { - if (typeof repo === "string") return repo.toLowerCase(); - return (repo.full_name ?? repo.name ?? "").toLowerCase(); - }) - .filter((name) => name.length > 0); - - allRepos.push(...normalized); - - if (!data.has_more || repos.length === 0) { - return allRepos; - } - - offset += repos.length; - } -} diff --git a/apps/mobile/src/features/tasks/api.warm.test.ts b/apps/mobile/src/features/tasks/api.warm.test.ts deleted file mode 100644 index d8e8f882ee..0000000000 --- a/apps/mobile/src/features/tasks/api.warm.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { mockFetch } = vi.hoisted(() => ({ - mockFetch: vi.fn(), -})); - -vi.mock("expo/fetch", () => ({ - fetch: mockFetch, -})); - -vi.mock("@/lib/api", () => ({ - getBaseUrl: () => "https://app.posthog.test", - getProjectId: () => 42, - authedFetch: (url: string, init?: RequestInit) => - mockFetch(url, { - ...init, - headers: { - Authorization: "Bearer token", - "Content-Type": "application/json", - ...((init?.headers as Record | undefined) ?? {}), - }, - }), -})); - -import { HttpError, warmTask } from "./api"; - -describe("warmTask", () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it("posts the warm request with the backend contract", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { - status: 200, - }), - ); - - const result = await warmTask({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - }); - - expect(result).toEqual({ task_id: "task-1", run_id: "run-1" }); - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/tasks/warm/", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - runtime_adapter: null, - model: null, - reasoning_effort: null, - }), - }), - ); - }); - - it("forwards the selected runtime, model, and reasoning effort", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { - status: 200, - }), - ); - - await warmTask({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - runtime_adapter: "claude", - model: "claude-opus-4-8", - reasoning_effort: "high", - }); - - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/tasks/warm/", - expect.objectContaining({ - body: JSON.stringify({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - runtime_adapter: "claude", - model: "claude-opus-4-8", - reasoning_effort: "high", - }), - }), - ); - }); - - it("serializes a missing branch as null", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { - status: 200, - }), - ); - - await warmTask({ repository: "posthog/posthog", github_integration: 7 }); - - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/tasks/warm/", - expect.objectContaining({ - body: JSON.stringify({ - repository: "posthog/posthog", - github_integration: 7, - branch: null, - runtime_adapter: null, - model: null, - reasoning_effort: null, - }), - }), - ); - }); - - it("returns null when the response body is empty", async () => { - mockFetch.mockResolvedValueOnce(new Response("", { status: 200 })); - - const result = await warmTask({ - repository: "posthog/posthog", - github_integration: 7, - }); - - expect(result).toBeNull(); - }); - - it("throws an HttpError on a failed response", async () => { - mockFetch.mockResolvedValueOnce(new Response("nope", { status: 500 })); - - await expect( - warmTask({ repository: "posthog/posthog", github_integration: 7 }), - ).rejects.toBeInstanceOf(HttpError); - }); -}); diff --git a/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx b/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx index 29934cada1..87fb997014 100644 --- a/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx +++ b/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx @@ -60,7 +60,7 @@ vi.mock("@/features/tasks/components/AutomationForm", () => ({ createElement("AutomationForm", props), })); -vi.mock("@/features/tasks/api", () => ({ +vi.mock("@posthog/api-client/posthog-client", () => ({ TaskAutomationValidationError: class TaskAutomationValidationError extends Error { code: string; attr: string | null; diff --git a/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx b/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx index 9189b6eec0..cc6d38b3bd 100644 --- a/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx +++ b/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx @@ -14,9 +14,11 @@ const { mockUseAuthStore, mockGetImages, mockGetEnvironments } = vi.hoisted( vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore })); -vi.mock("../api", () => ({ - getSandboxCustomImages: mockGetImages, - getSandboxEnvironments: mockGetEnvironments, +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + listSandboxCustomImages: mockGetImages, + listSandboxEnvironments: mockGetEnvironments, + }), })); vi.mock("phosphor-react-native", () => ({ diff --git a/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx b/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx index 296c8ead4e..0da19fca21 100644 --- a/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx +++ b/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx @@ -3,8 +3,8 @@ import * as WebBrowser from "expo-web-browser"; import { Pressable, View } from "react-native"; import { useAuthStore } from "@/features/auth"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useThemeColors } from "@/lib/theme"; -import { startGithubUserIntegrationConnect } from "../api"; const log = logger.scope("github-connection-prompt"); @@ -44,7 +44,8 @@ export function GitHubConnectionPrompt({ // and, because we pass `connect_from: "posthog_mobile"`, redirects the // callback to `posthog://github/callback` so this in-app browser closes. try { - const { install_url } = await startGithubUserIntegrationConnect(); + const { install_url } = + await getPostHogApiClient().startGithubUserIntegrationConnect(); authorizeUrl = install_url; } catch (error) { log.error("Failed to start GitHub connection", { error }); diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts index 93c4cf1dc0..772d9a4f69 100644 --- a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts @@ -8,26 +8,36 @@ const { mockGetTaskAutomations, mockCreateTaskAutomation, mockUpdateTaskAutomation, + mockApiClient, } = vi.hoisted(() => ({ mockUseAuthStore: vi.fn(), mockGetTaskAutomations: vi.fn(), mockCreateTaskAutomation: vi.fn(), mockUpdateTaskAutomation: vi.fn(), + mockApiClient: { + listTaskAutomations: vi.fn(), + getTaskAutomation: vi.fn(), + createTaskAutomation: vi.fn(), + updateTaskAutomation: vi.fn(), + deleteTaskAutomation: vi.fn(), + runTaskAutomation: vi.fn(), + }, })); vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore, })); -vi.mock("../api", () => ({ - getTaskAutomations: mockGetTaskAutomations, - getTaskAutomation: vi.fn(), - createTaskAutomation: mockCreateTaskAutomation, - updateTaskAutomation: mockUpdateTaskAutomation, - deleteTaskAutomation: vi.fn(), - runTaskAutomation: vi.fn(), +vi.mock("../api", () => ({ runTaskInCloud: vi.fn() })); + +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => mockApiClient, })); +mockApiClient.listTaskAutomations = mockGetTaskAutomations; +mockApiClient.createTaskAutomation = mockCreateTaskAutomation; +mockApiClient.updateTaskAutomation = mockUpdateTaskAutomation; + import { automationKeys, getAutomationPollingInterval, diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.ts index a4667af875..709aec11b5 100644 --- a/apps/mobile/src/features/tasks/hooks/useAutomations.ts +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.ts @@ -6,14 +6,7 @@ import type { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; import { logger } from "@/lib/logger"; -import { - createTaskAutomation, - deleteTaskAutomation, - getTaskAutomation, - getTaskAutomations, - runTaskAutomation, - updateTaskAutomation, -} from "../api"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { taskKeys } from "./useTasks"; const log = logger.scope("automations-mutations"); @@ -59,7 +52,7 @@ export function useAutomations() { const query = useQuery({ queryKey: automationKeys.list(), - queryFn: getTaskAutomations, + queryFn: () => getPostHogApiClient().listTaskAutomations(), enabled: !!projectId && !!oauthAccessToken, refetchInterval: (query) => getAutomationPollingInterval( @@ -80,7 +73,7 @@ export function useAutomation(automationId: string) { return useQuery({ queryKey: automationKeys.detail(automationId), - queryFn: () => getTaskAutomation(automationId), + queryFn: () => getPostHogApiClient().getTaskAutomation(automationId), enabled: !!projectId && !!oauthAccessToken && !!automationId, refetchInterval: (query) => getAutomationPollingInterval( @@ -94,7 +87,7 @@ export function useCreateTaskAutomation() { return useMutation({ mutationFn: (options: CreateTaskAutomationOptions) => - createTaskAutomation(options), + getPostHogApiClient().createTaskAutomation(options), onSuccess: (automation) => { queryClient.setQueryData( automationKeys.detail(automation.id), @@ -118,7 +111,7 @@ export function useUpdateTaskAutomation() { }: { automationId: string; updates: UpdateTaskAutomationOptions; - }) => updateTaskAutomation(automationId, updates), + }) => getPostHogApiClient().updateTaskAutomation(automationId, updates), onSuccess: (automation, { automationId }) => { queryClient.setQueryData( automationKeys.detail(automationId), @@ -136,7 +129,8 @@ export function useDeleteTaskAutomation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (automationId: string) => deleteTaskAutomation(automationId), + mutationFn: (automationId: string) => + getPostHogApiClient().deleteTaskAutomation(automationId), onSuccess: (_, automationId) => { queryClient.removeQueries({ queryKey: automationKeys.detail(automationId), @@ -153,7 +147,8 @@ export function useRunTaskAutomation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (automationId: string) => runTaskAutomation(automationId), + mutationFn: (automationId: string) => + getPostHogApiClient().runTaskAutomation(automationId), onSuccess: (automation, automationId) => { queryClient.setQueryData( automationKeys.detail(automationId), diff --git a/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts b/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts index a634ecd5e6..a8bf916ce8 100644 --- a/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts +++ b/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; -import { getSandboxCustomImages, getSandboxEnvironments } from "../api"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; export const sandboxKeys = { customImages: () => ["sandbox-custom-images"] as const, @@ -26,7 +26,7 @@ export function useCustomImageName({ const imagesQuery = useQuery({ queryKey: sandboxKeys.customImages(), - queryFn: getSandboxCustomImages, + queryFn: () => getPostHogApiClient().listSandboxCustomImages(), enabled: canQuery && hasImageRef, staleTime: 60_000, retry: 0, @@ -34,7 +34,7 @@ export function useCustomImageName({ const environmentsQuery = useQuery({ queryKey: sandboxKeys.environments(), - queryFn: getSandboxEnvironments, + queryFn: () => getPostHogApiClient().listSandboxEnvironments(), enabled: canQuery && !!sandboxEnvironmentId, staleTime: 60_000, retry: 0, diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts index 070fb37c47..45040a2df6 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts @@ -14,9 +14,11 @@ vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore, })); -vi.mock("../api", () => ({ - getGithubRepositories: mockGetGithubRepositories, - getIntegrations: mockGetIntegrations, +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + getGithubRepositories: mockGetGithubRepositories, + getIntegrations: mockGetIntegrations, + }), })); import { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts index 49c1638780..bf2aab3c77 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts @@ -1,9 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; -import { getGithubRepositories, getIntegrations } from "../api"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; -import type { RepositoryOption } from "../types"; +import type { Integration, RepositoryOption } from "../types"; import { buildRepositoryOptions } from "../utils/repositorySelection"; /** Cheap content-equality check for repository option lists. Lets the cache @@ -59,8 +59,27 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { const integrationsQuery = useQuery({ queryKey: integrationKeys.github(), queryFn: async () => { - const data = await getIntegrations(); - return data.filter((i) => i.kind === "github"); + const data = await getPostHogApiClient().getIntegrations(); + return data.flatMap((integration): Integration[] => { + if ( + integration.kind !== "github" || + typeof integration.id !== "number" + ) { + return []; + } + + return [ + { + id: integration.id, + kind: integration.kind, + display_name: + typeof integration.display_name === "string" + ? integration.display_name + : undefined, + config: integration.config as Integration["config"], + }, + ]; + }); }, enabled: enabled && !!projectId && !!oauthAccessToken, }); @@ -78,7 +97,9 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { const results = await Promise.allSettled( githubIntegrations.map(async (integration) => ({ integrationId: integration.id, - repositories: await getGithubRepositories(integration.id), + repositories: await getPostHogApiClient().getGithubRepositories( + integration.id, + ), })), ); diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts index 6cd5c33ada..579ec4bce2 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts @@ -28,12 +28,17 @@ vi.mock("@/lib/logger", () => { }); vi.mock("../api", () => ({ - createTask: vi.fn(), - deleteTask: vi.fn(), - getTask: vi.fn(), - getTasks: vi.fn(), runTaskInCloud: vi.fn(), - updateTask: vi.fn(), +})); + +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + createTask: vi.fn(), + deleteTask: vi.fn(), + getTask: vi.fn(), + getTasks: vi.fn(), + updateTask: vi.fn(), + }), })); vi.mock("../stores/taskStore", () => ({ diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index 19b13fe570..9de10ad54e 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -2,14 +2,8 @@ import type { Task } from "@posthog/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore, useUserQuery } from "@/features/auth"; import { logger } from "@/lib/logger"; -import { - createTask, - deleteTask, - getTask, - getTasks, - runTaskInCloud, - updateTask, -} from "../api"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; +import { runTaskInCloud } from "../api"; import { filterAndSortTasks, useTaskStore } from "../stores/taskStore"; import type { CreateTaskOptions } from "../types"; @@ -70,7 +64,7 @@ export function useTasks(filters?: { const query = useQuery({ queryKey: taskKeys.list(queryFilters), - queryFn: () => getTasks(queryFilters), + queryFn: () => getPostHogApiClient().getTasks(queryFilters), enabled: !!projectId && !!oauthAccessToken && !!currentUser?.id, refetchInterval: (query) => getTaskPollingInterval(query.state.data as Task[] | undefined), @@ -103,7 +97,7 @@ export function useTask(taskId: string) { return useQuery({ queryKey: taskKeys.detail(taskId), - queryFn: () => getTask(taskId), + queryFn: () => getPostHogApiClient().getTask(taskId), enabled: !!projectId && !!oauthAccessToken && !!taskId, refetchInterval: (query) => getTaskPollingInterval(query.state.data as Task | undefined), @@ -118,7 +112,8 @@ export function useCreateTask() { }; const mutation = useMutation({ - mutationFn: (options: CreateTaskOptions) => createTask(options), + mutationFn: (options: CreateTaskOptions) => + getPostHogApiClient().createTask(options), onSuccess: () => { invalidateTasks(); }, @@ -140,7 +135,13 @@ export function useUpdateTask() { }: { taskId: string; updates: Partial; - }) => updateTask(taskId, updates), + }) => + getPostHogApiClient().updateTask( + taskId, + updates as Parameters< + ReturnType["updateTask"] + >[1], + ), onSuccess: (updatedTask, { taskId }) => { // Update the detail cache immediately queryClient.setQueryData(taskKeys.detail(taskId), updatedTask); @@ -156,7 +157,7 @@ export function useDeleteTask() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (taskId: string) => deleteTask(taskId), + mutationFn: (taskId: string) => getPostHogApiClient().deleteTask(taskId), onSuccess: (_, taskId) => { // Remove from detail cache queryClient.removeQueries({ queryKey: taskKeys.detail(taskId) }); diff --git a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts index 68ae4f9808..1ba6655cf2 100644 --- a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts @@ -1,8 +1,8 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; -import { getUserGithubIntegrations, getUserGithubRepositories } from "../api"; -import type { RepositoryOption, UserGithubIntegration } from "../types"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; +import type { RepositoryOption } from "../types"; /** * User-scoped sibling of {@link useIntegrations}. Reads the authenticated @@ -29,7 +29,10 @@ interface UseUserIntegrationsOptions { enabled?: boolean; } -function integrationLabel(integration: UserGithubIntegration): string { +function integrationLabel(integration: { + installation_id: string; + account?: { name?: string | null } | null; +}): string { return integration.account?.name ?? `GitHub ${integration.installation_id}`; } @@ -39,7 +42,7 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { const integrationsQuery = useQuery({ queryKey: userIntegrationKeys.github(), - queryFn: getUserGithubIntegrations, + queryFn: () => getPostHogApiClient().getGithubUserIntegrations(), enabled: enabled && !!oauthAccessToken, }); @@ -54,7 +57,7 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { const results = await Promise.allSettled( integrations.map(async (integration) => ({ installationId: integration.installation_id, - repositories: await getUserGithubRepositories( + repositories: await getPostHogApiClient().getGithubUserRepositories( integration.installation_id, ), })), diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx index 8ed33df816..43bfeca02b 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx @@ -8,8 +8,8 @@ const flagState = vi.hoisted(() => ({ enabled: true as boolean })); vi.mock("posthog-react-native", () => ({ useFeatureFlag: () => flagState.enabled, })); -vi.mock("@/features/tasks/api", () => ({ - warmTask: mockWarmTask, +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ warmTask: mockWarmTask }), })); vi.mock("@/lib/logger", () => { const mockLogger = { diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts index e69ce6555c..2947d8f718 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts @@ -1,8 +1,8 @@ import { TASKS_PREWARM_SANDBOX_FLAG } from "@posthog/shared"; import { useFeatureFlag } from "posthog-react-native"; import { useEffect, useRef } from "react"; -import { warmTask } from "@/features/tasks/api"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; const log = logger.scope("warm-task"); @@ -71,17 +71,19 @@ export function useWarmTask({ debounceRef.current = setTimeout(() => { debounceRef.current = null; lastWarmedKeyRef.current = key; - void warmTask({ - repository: repo, - github_integration: githubIntegration, - branch: warmBranch, - runtime_adapter: warmRuntimeAdapter, - model: warmModel, - reasoning_effort: warmReasoningEffort, - }).catch((error) => { - lastWarmedKeyRef.current = null; - log.warn("Failed to warm task", error); - }); + void getPostHogApiClient() + .warmTask({ + repository: repo, + github_integration: githubIntegration, + branch: warmBranch, + runtime_adapter: warmRuntimeAdapter, + model: warmModel, + reasoning_effort: warmReasoningEffort, + }) + .catch((error) => { + lastWarmedKeyRef.current = null; + log.warn("Failed to warm task", error); + }); }, WARM_DEBOUNCE_MS); return clearDebounce; diff --git a/apps/mobile/src/features/tasks/index.ts b/apps/mobile/src/features/tasks/index.ts index 07c05346e7..0a1cf308b0 100644 --- a/apps/mobile/src/features/tasks/index.ts +++ b/apps/mobile/src/features/tasks/index.ts @@ -1,7 +1,5 @@ // Tasks feature -// API -export * from "./api"; // Components export { TaskItem } from "./components/TaskItem"; export { TaskList } from "./components/TaskList"; diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts index 590312e815..6b4fa9152a 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts @@ -6,6 +6,8 @@ import type { } from "@posthog/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; +const { mockGetTask } = vi.hoisted(() => ({ mockGetTask: vi.fn() })); + vi.mock("expo-haptics", () => ({ impactAsync: vi.fn(), notificationAsync: vi.fn(), @@ -24,13 +26,16 @@ vi.mock("@/features/notifications/lib/notifications", () => ({ })); vi.mock("../api", () => ({ CloudCommandError: class CloudCommandError extends Error {}, - getTask: vi.fn(), runTaskInCloud: vi.fn(), sendCloudCommand: vi.fn(), })); +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ getTask: mockGetTask }), +})); + import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { getTask, runTaskInCloud } from "../api"; +import { runTaskInCloud } from "../api"; import { useMessageQueueStore } from "./messageQueueStore"; import { type TaskSession, useTaskSessionStore } from "./taskSessionStore"; import { useTaskStore } from "./taskStore"; @@ -216,7 +221,6 @@ describe("flushQueuedMessagesIfIdle", () => { }); describe("_resumeCloudRun", () => { - const mockGetTask = vi.mocked(getTask); const mockRunTaskInCloud = vi.mocked(runTaskInCloud); function previousTask(latestRun: Partial): Task { diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index beac7d35a9..3a9adcbdad 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -10,10 +10,10 @@ import { create } from "zustand"; import { presentLocalNotification } from "@/features/notifications/lib/notifications"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { CloudCommandError, cancelRun, - getTask, runTaskInCloud, sendCloudCommand, } from "../api"; @@ -1214,7 +1214,7 @@ export const useTaskSessionStore = create((set, get) => ({ previousRunId: string, prompt: string, ) => { - const freshTask = await getTask(taskId); + const freshTask = await getPostHogApiClient().getTask(taskId); const previousRun = freshTask.latest_run; const previousBranch = previousRun?.branch ?? null; diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 1511865147..18b4727a39 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -2692,16 +2692,13 @@ export class PostHogAPIClient { return normalizeTaskResponse(data, { teamId }); } - async updateTask( - taskId: string, - updates: Partial, - ): Promise { + async updateTask(taskId: string, updates: Partial): Promise { const teamId = await this.getTeamId(); const data = await this.api.patch( `/api/projects/{project_id}/tasks/{id}/`, { path: { project_id: teamId.toString(), id: taskId }, - body: updates, + body: updates as unknown as Partial, }, ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ece21aa003..f65dc4088b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -479,9 +479,6 @@ importers: '@posthog/api-client': specifier: workspace:* version: link:../../packages/api-client - '@posthog/core': - specifier: workspace:* - version: link:../../packages/core '@posthog/shared': specifier: workspace:* version: link:../../packages/shared From 6b2f9a20e61230ac2587cdf437faf371fe11bc28 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 21 Jul 2026 16:38:48 +0300 Subject: [PATCH 05/10] Share cloud task domain policies Generated-By: PostHog Code Task-Id: 40c57a59-b4e1-4760-8e56-ecd03e9c2f0f --- .../automations/automationSchedule.test.ts | 223 ++++++++++++++++++ .../src/automations/automationSchedule.ts | 216 +++++++++++++++++ .../src/automations/automationStatus.test.ts | 80 +++++++ .../core/src/automations/automationStatus.ts | 83 +++++++ packages/core/src/inbox/artefacts.test.ts | 159 ++++++++++++- packages/core/src/inbox/artefacts.ts | 86 +++++++ .../core/src/inbox/reportFiltering.test.ts | 10 + packages/core/src/inbox/reportFiltering.ts | 12 +- .../core/src/sessions/cloudSessionConfig.ts | 9 +- packages/core/src/sessions/executionModes.ts | 4 +- .../sessions/portableSessionEvents.test.ts | 71 ++++++ .../src/sessions/portableSessionEvents.ts | 98 ++++++++ .../core/src/sessions/sessionActivity.test.ts | 152 ++++++++++++ packages/core/src/sessions/sessionActivity.ts | 129 ++++++++++ packages/core/src/tasks/taskActivity.test.ts | 132 +++++++++++ packages/core/src/tasks/taskActivity.ts | 45 ++++ packages/core/src/tasks/taskArchive.test.ts | 44 ++++ packages/core/src/tasks/taskArchive.ts | 6 + .../src/tasks/taskStatusPresentation.test.ts | 69 ++++++ .../core/src/tasks/taskStatusPresentation.ts | 37 +++ 20 files changed, 1659 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/automations/automationSchedule.test.ts create mode 100644 packages/core/src/automations/automationSchedule.ts create mode 100644 packages/core/src/automations/automationStatus.test.ts create mode 100644 packages/core/src/automations/automationStatus.ts create mode 100644 packages/core/src/sessions/portableSessionEvents.test.ts create mode 100644 packages/core/src/sessions/portableSessionEvents.ts create mode 100644 packages/core/src/sessions/sessionActivity.test.ts create mode 100644 packages/core/src/sessions/sessionActivity.ts create mode 100644 packages/core/src/tasks/taskActivity.test.ts create mode 100644 packages/core/src/tasks/taskActivity.ts create mode 100644 packages/core/src/tasks/taskArchive.test.ts create mode 100644 packages/core/src/tasks/taskArchive.ts create mode 100644 packages/core/src/tasks/taskStatusPresentation.test.ts create mode 100644 packages/core/src/tasks/taskStatusPresentation.ts 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/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/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"; + } +} From dc82fa70807df634feff3338e65b1e88d79f301b Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 21 Jul 2026 16:38:50 +0300 Subject: [PATCH 06/10] refactor(mobile): adopt shared task policies Generated-By: PostHog Code Task-Id: 40c57a59-b4e1-4760-8e56-ecd03e9c2f0f --- apps/mobile/src/app/task/[id].tsx | 14 +- .../inbox/components/EditReviewersSheet.tsx | 5 +- .../inbox/components/ReviewerFilterSheet.tsx | 2 +- .../inbox/components/ReviewerOptionRow.tsx | 5 +- .../inbox/components/SuggestedReviewers.tsx | 10 +- apps/mobile/src/features/inbox/constants.ts | 14 - .../features/inbox/hooks/useInboxReports.ts | 22 +- .../features/inbox/stores/inboxFilterStore.ts | 8 +- apps/mobile/src/features/inbox/utils.test.ts | 241 +----------------- apps/mobile/src/features/inbox/utils.ts | 160 ------------ .../tasks/components/AutomationDetail.tsx | 2 +- .../tasks/components/AutomationForm.tsx | 14 +- .../tasks/components/AutomationItem.tsx | 2 +- .../components/AutomationStatusBadge.tsx | 18 +- .../tasks/components/ScheduleEditor.tsx | 4 +- .../tasks/components/SwipeableTaskItem.tsx | 6 +- .../features/tasks/components/TaskList.tsx | 3 +- .../tasks/components/TaskStatusIcon.test.ts | 91 ------- .../tasks/components/TaskStatusIcon.tsx | 4 +- .../tasks/components/taskStatusIconKind.ts | 42 --- .../src/features/tasks/composer/options.ts | 42 ++- .../src/features/tasks/hooks/useTasks.ts | 3 +- apps/mobile/src/features/tasks/index.ts | 1 - .../features/tasks/stores/taskStore.test.ts | 54 ---- .../src/features/tasks/stores/taskStore.ts | 47 ---- .../features/tasks/utils/archiveGuard.test.ts | 54 +--- .../src/features/tasks/utils/archiveGuard.ts | 6 - .../tasks/utils/automationSchedule.test.ts | 101 -------- .../tasks/utils/automationSchedule.ts | 213 ---------------- .../tasks/utils/automationStatus.test.ts | 42 --- .../features/tasks/utils/automationStatus.ts | 60 ----- .../features/tasks/utils/parseSessionLogs.ts | 87 +------ .../tasks/utils/sessionActivity.test.ts | 161 ------------ .../features/tasks/utils/sessionActivity.ts | 129 ---------- packages/core/src/tasks/taskActivity.ts | 20 +- packages/core/src/tasks/taskArchive.test.ts | 4 + packages/core/src/tasks/taskArchive.ts | 6 +- .../src/tasks/taskStatusPresentation.test.ts | 12 + .../core/src/tasks/taskStatusPresentation.ts | 12 +- 39 files changed, 133 insertions(+), 1588 deletions(-) delete mode 100644 apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts delete mode 100644 apps/mobile/src/features/tasks/components/taskStatusIconKind.ts delete mode 100644 apps/mobile/src/features/tasks/stores/taskStore.test.ts delete mode 100644 apps/mobile/src/features/tasks/utils/automationSchedule.test.ts delete mode 100644 apps/mobile/src/features/tasks/utils/automationSchedule.ts delete mode 100644 apps/mobile/src/features/tasks/utils/automationStatus.test.ts delete mode 100644 apps/mobile/src/features/tasks/utils/automationStatus.ts delete mode 100644 apps/mobile/src/features/tasks/utils/sessionActivity.test.ts delete mode 100644 apps/mobile/src/features/tasks/utils/sessionActivity.ts diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 3276288884..35d95ead55 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,4 +1,9 @@ import { Text } from "@components/text"; +import { + countUserMessages, + getSessionActivityPhase, +} from "@posthog/core/sessions/sessionActivity"; +import { isTaskRunning } from "@posthog/core/tasks/taskArchive"; import type { Task } from "@posthog/shared"; import { useQueryClient } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; @@ -52,14 +57,7 @@ import { } from "@/features/tasks/stores/pendingTaskPromptStore"; import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore"; import { useTaskStore } from "@/features/tasks/stores/taskStore"; -import { - confirmStopRun, - isTaskRunning, -} from "@/features/tasks/utils/archiveGuard"; -import { - countUserMessages, - getSessionActivityPhase, -} from "@/features/tasks/utils/sessionActivity"; +import { confirmStopRun } from "@/features/tasks/utils/archiveGuard"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { ANALYTICS_EVENTS, diff --git a/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx b/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx index 2aec45975d..2e30695576 100644 --- a/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx +++ b/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx @@ -1,4 +1,8 @@ import { Text } from "@components/text"; +import { + buildReviewerOptions, + reviewerMatchesAvailable, +} from "@posthog/core/inbox/artefacts"; import { MagnifyingGlass } from "phosphor-react-native"; import { useMemo, useState } from "react"; import { @@ -14,7 +18,6 @@ import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports"; import type { AvailableSuggestedReviewer, SuggestedReviewer } from "../types"; -import { buildReviewerOptions, reviewerMatchesAvailable } from "../utils"; import { ReviewerOptionRow } from "./ReviewerOptionRow"; interface EditReviewersSheetProps { diff --git a/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx b/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx index d07faaffa1..44ac730daf 100644 --- a/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx +++ b/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import { buildReviewerOptions } from "@posthog/core/inbox/artefacts"; import { useMemo } from "react"; import { ActivityIndicator, @@ -12,7 +13,6 @@ import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports"; import { useInboxFilterStore } from "../stores/inboxFilterStore"; -import { buildReviewerOptions } from "../utils"; import { ReviewerOptionRow } from "./ReviewerOptionRow"; interface ReviewerFilterSheetProps { diff --git a/apps/mobile/src/features/inbox/components/ReviewerOptionRow.tsx b/apps/mobile/src/features/inbox/components/ReviewerOptionRow.tsx index a6f9c5c265..0c21699177 100644 --- a/apps/mobile/src/features/inbox/components/ReviewerOptionRow.tsx +++ b/apps/mobile/src/features/inbox/components/ReviewerOptionRow.tsx @@ -1,8 +1,11 @@ import { Text } from "@components/text"; +import { + type ReviewerOption, + reviewerOptionLabel, +} from "@posthog/core/inbox/artefacts"; import { Check } from "phosphor-react-native"; import { Image, Pressable, View } from "react-native"; import { useThemeColors } from "@/lib/theme"; -import { type ReviewerOption, reviewerOptionLabel } from "../utils"; interface ReviewerOptionRowProps { reviewer: ReviewerOption; diff --git a/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx b/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx index 0536f1b364..d0eab2e25f 100644 --- a/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx +++ b/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx @@ -1,4 +1,9 @@ import { Text } from "@components/text"; +import { + orderSuggestedReviewers, + reviewerMatchesAvailable, + toSuggestedReviewerWriteContent, +} from "@posthog/core/inbox/artefacts"; import { Eye, Plus, X } from "phosphor-react-native"; import { useMemo, useState } from "react"; import { @@ -20,11 +25,6 @@ import type { SuggestedReviewer, SuggestedReviewersArtefact, } from "../types"; -import { - orderSuggestedReviewers, - reviewerMatchesAvailable, - toSuggestedReviewerWriteContent, -} from "../utils"; import { EditReviewersSheet } from "./EditReviewersSheet"; export type ReviewerActionExtra = Pick< diff --git a/apps/mobile/src/features/inbox/constants.ts b/apps/mobile/src/features/inbox/constants.ts index 201d15982f..f15aca7c5b 100644 --- a/apps/mobile/src/features/inbox/constants.ts +++ b/apps/mobile/src/features/inbox/constants.ts @@ -1,17 +1,3 @@ -/** Comma-separated statuses for the inbox pipeline (excludes terminal/deleted). */ -export const INBOX_PIPELINE_STATUS_FILTER = - "potential,candidate,in_progress,ready,pending_input"; - -/** - * Status filter for the Archive view — the two terminal, not-in-inbox states: - * `suppressed` (user archived it; restorable) and `resolved` (its - * implementation PR merged; terminal, reference only). - */ -export const INBOX_DISMISSED_STATUS_FILTER = "suppressed,resolved"; - -/** Polling interval for inbox queries (ms). */ -export const INBOX_REFETCH_INTERVAL_MS = 5_000; - /** * Reasons offered when the user dismisses a signal report. * Mirrors apps/code/src/shared/dismissalReasons.ts. diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts index 25f6e0eb40..ac37c738d1 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts @@ -1,3 +1,12 @@ +import { + buildArchiveListOrdering, + buildPriorityFilterParam, + buildSignalReportListOrdering, + buildStatusFilterParam, + buildSuggestedReviewerFilterParam, + INBOX_DISMISSED_STATUS_FILTER, + INBOX_REFETCH_INTERVAL_MS, +} from "@posthog/core/inbox/reportFiltering"; import { useInfiniteQuery, useMutation, @@ -19,10 +28,6 @@ import { restoreSignalReport, updateSignalReportArtefact, } from "../api"; -import { - INBOX_DISMISSED_STATUS_FILTER, - INBOX_REFETCH_INTERVAL_MS, -} from "../constants"; import { useInboxFilterStore } from "../stores/inboxFilterStore"; import type { AvailableSuggestedReviewersResponse, @@ -36,14 +41,7 @@ import type { SuggestedReviewer, SuggestedReviewerWriteEntry, } from "../types"; -import { - buildArchiveListOrdering, - buildPriorityFilterParam, - buildSignalReportListOrdering, - buildStatusFilterParam, - buildSuggestedReviewerFilterParam, - isRestorableReport, -} from "../utils"; +import { isRestorableReport } from "../utils"; export const inboxKeys = { all: ["inbox", "signal-reports"] as const, diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts index ea363b4e42..a0bc079ac4 100644 --- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts @@ -1,3 +1,4 @@ +import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -25,12 +26,7 @@ export type SourceProduct = | "signals_scout"; export const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ - "ready", - "pending_input", - "in_progress", - "failed", - "candidate", - "potential", + ...INBOX_PIPELINE_STATUSES, ]; interface InboxFilterState { diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts index d9012c3479..6f2bfbcf8c 100644 --- a/apps/mobile/src/features/inbox/utils.test.ts +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -1,103 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { - AvailableSuggestedReviewer, - SignalReport, - SignalReportOrderingField, - SignalReportStatus, - SuggestedReviewer, -} from "./types"; +import type { SignalReport, SignalReportStatus } from "./types"; import { - buildArchiveListOrdering, buildInboxViewedProperties, - buildPriorityFilterParam, - buildReviewerOptions, - buildSignalReportListOrdering, dismissalReasonLabel, formatSignalReportSummaryMarkdown, isRestorableReport, - orderSuggestedReviewers, - reviewerMatchesAvailable, - toSuggestedReviewerWriteContent, } from "./utils"; -function reviewer(login: string, uuid?: string): SuggestedReviewer { - return { - github_login: login, - github_name: login, - relevant_commits: [], - user: uuid - ? { - id: 1, - uuid, - email: `${login}@posthog.com`, - first_name: login, - last_name: "", - } - : null, - }; -} - -describe("orderSuggestedReviewers", () => { - it("moves the current user to the front", () => { - const reviewers = [ - reviewer("a", "uuid-a"), - reviewer("me", "uuid-me"), - reviewer("c", "uuid-c"), - ]; - const ordered = orderSuggestedReviewers(reviewers, "uuid-me"); - expect(ordered.map((r) => r.github_login)).toEqual(["me", "a", "c"]); - }); - - it.each([ - { - label: "already first", - reviewers: [reviewer("me", "uuid-me"), reviewer("a", "uuid-a")], - meUuid: "uuid-me" as string | null | undefined, - }, - { - label: "absent", - reviewers: [reviewer("a", "uuid-a"), reviewer("b", "uuid-b")], - meUuid: "uuid-me" as string | null | undefined, - }, - { - label: "null meUuid", - reviewers: [reviewer("a", "uuid-a"), reviewer("me", "uuid-me")], - meUuid: null as string | null | undefined, - }, - { - label: "undefined meUuid", - reviewers: [reviewer("a", "uuid-a"), reviewer("me", "uuid-me")], - meUuid: undefined as string | null | undefined, - }, - ])("is a no-op when $label", ({ reviewers, meUuid }) => { - expect(orderSuggestedReviewers(reviewers, meUuid)).toBe(reviewers); - }); -}); - -function makeReviewer( - partial: Partial = {}, -): SuggestedReviewer { - return { - github_login: "octocat", - github_name: "The Octocat", - relevant_commits: [], - user: null, - ...partial, - }; -} - -function makeAvailable( - partial: Partial = {}, -): AvailableSuggestedReviewer { - return { - uuid: "uuid-1", - name: "Ada Lovelace", - email: "ada@example.com", - github_login: "ada", - ...partial, - }; -} - const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ "ready", "pending_input", @@ -284,139 +193,6 @@ describe("buildInboxViewedProperties", () => { }); }); -describe("toSuggestedReviewerWriteContent", () => { - it.each([ - { - name: "prefers github_login so the server preserves commits/name", - reviewer: makeReviewer({ - github_login: "ada", - user: { id: 1, uuid: "u1", email: "", first_name: "", last_name: "" }, - }), - expected: [{ github_login: "ada" }], - }, - { - name: "falls back to user_uuid when there is no github_login", - reviewer: makeReviewer({ - github_login: "", - user: { id: 1, uuid: "u1", email: "", first_name: "", last_name: "" }, - }), - expected: [{ user_uuid: "u1" }], - }, - { - name: "drops entries with neither a login nor a resolved user", - reviewer: makeReviewer({ github_login: "", user: null }), - expected: [], - }, - ])("$name", ({ reviewer, expected }) => { - expect(toSuggestedReviewerWriteContent([reviewer])).toEqual(expected); - }); -}); - -describe("reviewerMatchesAvailable", () => { - it.each([ - { - name: "matches on user uuid", - reviewer: makeReviewer({ - github_login: "", - user: { - id: 1, - uuid: "uuid-1", - email: "", - first_name: "", - last_name: "", - }, - }), - expected: true, - }, - { - name: "matches on case-insensitive github login", - reviewer: makeReviewer({ github_login: "ADA", user: null }), - expected: true, - }, - { - name: "does not match different people", - reviewer: makeReviewer({ github_login: "octocat", user: null }), - expected: false, - }, - ])("$name", ({ reviewer, expected }) => { - expect(reviewerMatchesAvailable(reviewer, makeAvailable())).toBe(expected); - }); -}); - -describe("buildSignalReportListOrdering", () => { - it.each([ - { - field: "priority" as SignalReportOrderingField, - direction: "desc" as const, - expected: "status,-is_suggested_reviewer,-priority,-created_at", - }, - { - field: "priority" as SignalReportOrderingField, - direction: "asc" as const, - expected: "status,-is_suggested_reviewer,priority,-created_at", - }, - { - field: "signal_count" as SignalReportOrderingField, - direction: "desc" as const, - expected: "status,-is_suggested_reviewer,-signal_count", - }, - { - field: "total_weight" as SignalReportOrderingField, - direction: "asc" as const, - expected: "status,-is_suggested_reviewer,total_weight", - }, - { - field: "created_at" as SignalReportOrderingField, - direction: "desc" as const, - expected: "status,-is_suggested_reviewer,-created_at", - }, - { - field: "updated_at" as SignalReportOrderingField, - direction: "asc" as const, - expected: "status,-is_suggested_reviewer,updated_at", - }, - ])( - "orders $field $direction as $expected", - ({ field, direction, expected }) => { - expect(buildSignalReportListOrdering(field, direction)).toBe(expected); - }, - ); -}); - -describe("buildPriorityFilterParam", () => { - it.each([ - { - name: "returns undefined for an empty selection", - input: [], - expected: undefined, - }, - { - name: "joins selected priorities with commas", - input: ["P0", "P2"] as const, - expected: "P0,P2", - }, - { - name: "dedupes repeated priorities", - input: ["P1", "P1", "P3"] as const, - expected: "P1,P3", - }, - ])("$name", ({ input, expected }) => { - expect(buildPriorityFilterParam([...input])).toBe(expected); - }); -}); - -describe("buildArchiveListOrdering", () => { - it.each([ - { direction: "desc" as const, expected: "-updated_at" }, - { direction: "asc" as const, expected: "updated_at" }, - ])( - "sorts by field without a status prefix ($direction)", - ({ direction, expected }) => { - expect(buildArchiveListOrdering("updated_at", direction)).toBe(expected); - }, - ); -}); - describe("isRestorableReport", () => { it.each([ { status: "suppressed" as SignalReportStatus, expected: true }, @@ -437,18 +213,3 @@ describe("dismissalReasonLabel", () => { expect(dismissalReasonLabel(value)).toBe(expected); }); }); - -describe("buildReviewerOptions", () => { - it("dedupes by uuid and pins the current user first", () => { - const options = buildReviewerOptions( - [ - makeAvailable({ uuid: "b", name: "Bob" }), - makeAvailable({ uuid: "a", name: "Ada" }), - makeAvailable({ uuid: "a", name: "Ada (dupe)" }), - ], - "b", - ); - expect(options.map((o) => o.uuid)).toEqual(["b", "a"]); - expect(options[0].isMe).toBe(true); - }); -}); diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts index bac0a33aff..6fa4c895cc 100644 --- a/apps/mobile/src/features/inbox/utils.ts +++ b/apps/mobile/src/features/inbox/utils.ts @@ -2,13 +2,9 @@ import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import type { InboxViewedProperties } from "@/lib/analytics"; import { DISMISSAL_REASON_OPTIONS } from "./constants"; import type { - AvailableSuggestedReviewer, SignalReport, - SignalReportOrderingField, SignalReportPriority, SignalReportStatus, - SuggestedReviewer, - SuggestedReviewerWriteEntry, } from "./types"; const SIGNAL_SUMMARY_SECTION_HEADERS = [ @@ -86,77 +82,6 @@ export function inboxStatusLabel(status: SignalReportStatus): string { } } -/** - * Build comma-separated `ordering` param for the API: - * 1. Status rank (ready first) - * 2. Suggested reviewer (current user first) - * 3. User-selected field - * - * Priority is a coarse 5-bucket rank, so ties are broken by newest first. - */ -export function buildSignalReportListOrdering( - field: SignalReportOrderingField, - direction: "asc" | "desc", -): string { - const fieldKey = direction === "desc" ? `-${field}` : field; - const tiebreak = field === "priority" ? ",-created_at" : ""; - return `status,-is_suggested_reviewer,${fieldKey}${tiebreak}`; -} - -/** - * Ordering for the Archive view, which lists two terminal statuses - * (`suppressed` + `resolved`). Unlike the pipeline ordering, it must not prefix - * with `status`: that would group one terminal state ahead of the other before - * the time sort, burying recent items behind older ones from the sibling status. - */ -export function buildArchiveListOrdering( - field: SignalReportOrderingField, - direction: "asc" | "desc", -): string { - return direction === "desc" ? `-${field}` : field; -} - -/** - * Build a comma-separated status filter string for the API. - */ -export function buildStatusFilterParam(statuses: SignalReportStatus[]): string { - return statuses.join(","); -} - -/** - * Build a comma-separated suggested reviewer filter for the API. - */ -export function buildSuggestedReviewerFilterParam( - reviewerIds: string[], -): string | undefined { - const normalized = reviewerIds.map((id) => id.trim()).filter(Boolean); - if (normalized.length === 0) return undefined; - return Array.from(new Set(normalized)).join(","); -} - -export function buildPriorityFilterParam( - priorities: SignalReportPriority[], -): string | undefined { - if (priorities.length === 0) return undefined; - return Array.from(new Set(priorities)).join(","); -} - -export function filterReportsBySearch( - reports: SignalReport[], - query: string, -): SignalReport[] { - const trimmed = query.trim(); - if (!trimmed) return reports; - - const lower = trimmed.toLowerCase(); - return reports.filter( - (report) => - report.title?.toLowerCase().includes(lower) || - report.summary?.toLowerCase().includes(lower) || - report.id.toLowerCase().includes(lower), - ); -} - /** * Returns only reports that are actionable for the tinder-like card deck: * ready, immediately actionable, not already addressed. @@ -170,91 +95,6 @@ export function getActionableReports(reports: SignalReport[]): SignalReport[] { ); } -export function orderSuggestedReviewers( - reviewers: SuggestedReviewer[], - meUuid: string | null | undefined, -): SuggestedReviewer[] { - if (!meUuid) return reviewers; - const meIndex = reviewers.findIndex((r) => r.user?.uuid === meUuid); - if (meIndex <= 0) return reviewers; - return [reviewers[meIndex], ...reviewers.filter((_, i) => i !== meIndex)]; -} - -export interface ReviewerOption { - uuid: string; - name: string; - email: string; - github_login: string; - isMe: boolean; -} - -/** Deduplicate the available-reviewers list by uuid and sort "Me" first, then by name. */ -export function buildReviewerOptions( - reviewers: AvailableSuggestedReviewer[], - currentUserUuid: string | undefined, -): ReviewerOption[] { - const seen = new Set(); - const options: ReviewerOption[] = []; - - for (const r of reviewers) { - if (!r.uuid || seen.has(r.uuid)) continue; - seen.add(r.uuid); - options.push({ - uuid: r.uuid, - name: r.name?.trim() || "", - email: r.email?.trim() || "", - github_login: r.github_login?.trim() || "", - isMe: r.uuid === currentUserUuid, - }); - } - - options.sort((a, b) => { - if (a.isMe && !b.isMe) return -1; - if (!a.isMe && b.isMe) return 1; - return (a.name || a.email).localeCompare(b.name || b.email); - }); - - return options; -} - -export function reviewerOptionLabel(r: ReviewerOption): string { - const base = r.name || r.email || "Unknown user"; - return r.isMe ? `${base} (Me)` : base; -} - -/** A reviewer in the artefact matches an org member by user uuid or (case-insensitive) login. */ -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() - ); -} - -/** - * Build the full-replacement write payload from a read-shape list. Kept reviewers - * are sent by `github_login` so the server preserves their commits/name; an entry - * with only a resolved user falls back to `user_uuid`. Entries with neither are - * dropped. - */ -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); -} - interface InboxViewedFilterState { sourceProductFilter: string[]; statusFilter: SignalReportStatus[]; diff --git a/apps/mobile/src/features/tasks/components/AutomationDetail.tsx b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx index d5d761ee06..1e444ea3c3 100644 --- a/apps/mobile/src/features/tasks/components/AutomationDetail.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx @@ -1,7 +1,7 @@ import { Text } from "@components/text"; +import { formatAutomationScheduleSummary } from "@posthog/core/automations/automationSchedule"; import type { TaskAutomation, TaskRun } from "@posthog/shared"; import { ActivityIndicator, Pressable, View } from "react-native"; -import { formatAutomationScheduleSummary } from "../utils/automationSchedule"; import { getAutomationTemplatePresentation } from "../utils/automationTemplatePresentation"; import { AutomationStatusBadge } from "./AutomationStatusBadge"; diff --git a/apps/mobile/src/features/tasks/components/AutomationForm.tsx b/apps/mobile/src/features/tasks/components/AutomationForm.tsx index 2530066210..80975411f3 100644 --- a/apps/mobile/src/features/tasks/components/AutomationForm.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationForm.tsx @@ -1,4 +1,11 @@ import { Text } from "@components/text"; +import { + type AutomationScheduleDraft, + buildCronExpression, + createDefaultScheduleDraft, + deriveAutomationName, + parseCronExpression, +} from "@posthog/core/automations/automationSchedule"; import type { CreateTaskAutomationOptions } from "@posthog/shared"; import { CaretDown, GithubLogo } from "phosphor-react-native"; import { type MutableRefObject, useEffect, useMemo, useState } from "react"; @@ -14,13 +21,6 @@ import { useThemeColors } from "@/lib/theme"; import { RepositoryPickerInline } from "../composer/RepositoryPickerInline"; import { useIntegrations } from "../hooks/useIntegrations"; import type { RepositorySelection } from "../types"; -import { - type AutomationScheduleDraft, - buildCronExpression, - createDefaultScheduleDraft, - deriveAutomationName, - parseCronExpression, -} from "../utils/automationSchedule"; import { findRepositoryOption, isRepositorySelectionComplete, diff --git a/apps/mobile/src/features/tasks/components/AutomationItem.tsx b/apps/mobile/src/features/tasks/components/AutomationItem.tsx index d06c969492..34f598db90 100644 --- a/apps/mobile/src/features/tasks/components/AutomationItem.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationItem.tsx @@ -1,9 +1,9 @@ import { Text } from "@components/text"; +import { formatAutomationScheduleSummary } from "@posthog/core/automations/automationSchedule"; import type { TaskAutomation, TaskRun } from "@posthog/shared"; import { format, formatDistanceToNow } from "date-fns"; import { memo } from "react"; import { Pressable, View } from "react-native"; -import { formatAutomationScheduleSummary } from "../utils/automationSchedule"; import { getAutomationTemplatePresentation } from "../utils/automationTemplatePresentation"; import { AutomationStatusBadge } from "./AutomationStatusBadge"; diff --git a/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx index 600b36302e..931b0c736f 100644 --- a/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx @@ -1,7 +1,14 @@ import { Text } from "@components/text"; +import { getAutomationStatusPresentation } from "@posthog/core/automations/automationStatus"; import type { TaskRun } from "@posthog/shared"; import { View } from "react-native"; -import { getAutomationStatusPresentation } from "../utils/automationStatus"; + +const STATUS_TONE_CLASSES = { + neutral: "bg-gray-4 text-gray-11", + warning: "bg-status-warning/20 text-status-warning", + success: "bg-status-success/20 text-status-success", + error: "bg-status-error/20 text-status-error", +} as const; interface AutomationStatusBadgeProps { enabled: boolean; @@ -18,6 +25,9 @@ export function AutomationStatusBadge({ lastRunStatus, lastTaskRunStatus, }); + const runStatusClassName = runStatus + ? STATUS_TONE_CLASSES[runStatus.tone] + : null; return ( @@ -32,9 +42,9 @@ export function AutomationStatusBadge({ {enabled ? "Enabled" : "Paused"} - {runStatus ? ( - - + {runStatus && runStatusClassName ? ( + + {runStatus.label} diff --git a/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx b/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx index 102e607e87..726973a639 100644 --- a/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx +++ b/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx @@ -1,5 +1,4 @@ import { Text } from "@components/text"; -import { Pressable, TextInput, View } from "react-native"; import { type AutomationScheduleDraft, type AutomationScheduleMode, @@ -7,7 +6,8 @@ import { sanitizeHour, sanitizeMinute, WEEKDAY_OPTIONS, -} from "../utils/automationSchedule"; +} from "@posthog/core/automations/automationSchedule"; +import { Pressable, TextInput, View } from "react-native"; interface ScheduleEditorProps { value: AutomationScheduleDraft; diff --git a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx index fca9d75e79..64bd68eb9a 100644 --- a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx @@ -1,3 +1,4 @@ +import { isTaskRunning } from "@posthog/core/tasks/taskArchive"; import type { Task } from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { Archive, ArrowCounterClockwise } from "phosphor-react-native"; @@ -11,10 +12,7 @@ import { View, } from "react-native"; import { useThemeColors } from "@/lib/theme"; -import { - confirmArchiveRunningTask, - isTaskRunning, -} from "../utils/archiveGuard"; +import { confirmArchiveRunningTask } from "../utils/archiveGuard"; import { TaskItem } from "./TaskItem"; const SWIPE_THRESHOLD = 60; diff --git a/apps/mobile/src/features/tasks/components/TaskList.tsx b/apps/mobile/src/features/tasks/components/TaskList.tsx index 779b35535b..e2a5a7e11a 100644 --- a/apps/mobile/src/features/tasks/components/TaskList.tsx +++ b/apps/mobile/src/features/tasks/components/TaskList.tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import { taskActivityTimestamp } from "@posthog/core/tasks/taskActivity"; import type { Task } from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { Archive, GitBranch, Plus, Sparkle, X } from "phosphor-react-native"; @@ -14,7 +15,7 @@ import { useThemeColors } from "@/lib/theme"; import { useTasks } from "../hooks/useTasks"; import { useUserIntegrations } from "../hooks/useUserIntegrations"; import { useArchivedTasksStore } from "../stores/archivedTasksStore"; -import { taskActivityTimestamp, useTaskStore } from "../stores/taskStore"; +import { useTaskStore } from "../stores/taskStore"; import { GitHubConnectionPrompt } from "./GitHubConnectionPrompt"; import { GitHubLoadNotice } from "./GitHubLoadNotice"; import { SwipeableTaskItem } from "./SwipeableTaskItem"; diff --git a/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts b/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts deleted file mode 100644 index c2ae0c3b33..0000000000 --- a/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { Task } from "@posthog/shared"; -import { describe, expect, it } from "vitest"; -import { getTaskStatusIconKind } from "./taskStatusIconKind"; - -function makeTask(latestRun?: Partial>): Task { - return { - id: "task-1", - task_number: 1, - slug: "task-1", - title: "Test task", - description: "", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - origin_product: "code", - latest_run: latestRun - ? { - id: "run-1", - task: "task-1", - team: 1, - branch: null, - stage: null, - environment: "local", - 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("getTaskStatusIconKind", () => { - it("prioritizes PR over cloud status", () => { - const task = makeTask({ - environment: "cloud", - status: "in_progress", - output: { pr_url: "https://github.com/PostHog/code/pull/123" }, - }); - - expect(getTaskStatusIconKind(task)).toBe("pr"); - }); - - it("shows chat for cloud tasks without a PR, regardless of run status", () => { - expect( - getTaskStatusIconKind( - makeTask({ environment: "cloud", status: "queued" }), - ), - ).toBe("chat"); - - expect( - getTaskStatusIconKind( - makeTask({ environment: "cloud", status: "in_progress" }), - ), - ).toBe("chat"); - - expect( - getTaskStatusIconKind( - makeTask({ environment: "cloud", status: "completed" }), - ), - ).toBe("chat"); - - expect( - getTaskStatusIconKind( - makeTask({ environment: "cloud", status: "cancelled" }), - ), - ).toBe("chat"); - }); - - it("preserves local run-state icons", () => { - expect( - getTaskStatusIconKind( - makeTask({ environment: "local", status: "in_progress" }), - ), - ).toBe("running"); - - expect( - getTaskStatusIconKind( - makeTask({ environment: "local", status: "failed" }), - ), - ).toBe("failed"); - }); - - it("falls back to chat when a task has no run yet", () => { - expect(getTaskStatusIconKind(makeTask())).toBe("chat"); - }); -}); diff --git a/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx b/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx index 8736b203b0..7aa59b2d10 100644 --- a/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx +++ b/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx @@ -1,3 +1,4 @@ +import { getTaskStatusPresentationKind } from "@posthog/core/tasks/taskStatusPresentation"; import type { Task } from "@posthog/shared"; import { ChatCircle, @@ -10,7 +11,6 @@ import { import { memo, useEffect, useRef } from "react"; import { Animated, Easing } from "react-native"; import { useThemeColors } from "@/lib/theme"; -import { getTaskStatusIconKind } from "./taskStatusIconKind"; interface TaskStatusIconProps { task: Task; @@ -19,7 +19,7 @@ interface TaskStatusIconProps { function TaskStatusIconComponent({ task, size = 16 }: TaskStatusIconProps) { const colors = useThemeColors(); - const iconKind = getTaskStatusIconKind(task); + const iconKind = getTaskStatusPresentationKind(task); const rotation = useRef(new Animated.Value(0)).current; const isRunning = iconKind === "running"; diff --git a/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts b/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts deleted file mode 100644 index 96447d5859..0000000000 --- a/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Task } from "@posthog/shared"; - -export type TaskStatusIconKind = - | "pr" - | "completed" - | "failed" - | "running" - | "started" - | "chat"; - -export function getTaskStatusIconKind(task: Task): TaskStatusIconKind { - const prUrl = task.latest_run?.output?.pr_url as string | undefined; - const status = task.latest_run?.status; - const environment = task.latest_run?.environment; - - // Match desktop semantics, but let PR win when a cloud task also has one. - if (prUrl) { - return "pr"; - } - - if (environment === "cloud") { - return "chat"; - } - - if (status === "completed") { - return "completed"; - } - - if (status === "failed") { - return "failed"; - } - - if (status === "in_progress") { - return "running"; - } - - if (status === "queued") { - return "started"; - } - - return "chat"; -} diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts index b3056b41dc..f50c2c68d0 100644 --- a/apps/mobile/src/features/tasks/composer/options.ts +++ b/apps/mobile/src/features/tasks/composer/options.ts @@ -1,3 +1,7 @@ +import { + DEFAULT_CLAUDE_EXECUTION_MODE, + getAvailableModes, +} from "@posthog/core/sessions/executionModes"; import { DEFAULT_GATEWAY_MODEL, DEFAULT_REASONING_EFFORT, @@ -17,28 +21,19 @@ export const EXECUTION_MODES: { value: ExecutionMode; label: string; description: string; -}[] = [ - { - value: "plan", - label: "Plan Mode", - description: "Plan first, no tool execution", - }, - { - value: "default", - label: "Default", - description: "Standard behaviour, prompts for dangerous operations", - }, - { - value: "acceptEdits", - label: "Accept Edits", - description: "Auto-accept file edit operations", - }, - { - value: "auto", - label: "Auto", - description: "Model decides which prompts to approve or deny", - }, -]; +}[] = getAvailableModes() + .filter( + (mode): mode is typeof mode & { id: ExecutionMode } => + mode.id === "default" || + mode.id === "acceptEdits" || + mode.id === "plan" || + mode.id === "auto", + ) + .map((mode) => ({ + value: mode.id, + label: mode.name, + description: mode.description, + })); export interface ModelOption { value: string; @@ -74,7 +69,8 @@ export const MODELS: ModelOption[] = [ }, ]; -export const DEFAULT_EXECUTION_MODE: ExecutionMode = "plan"; +export const DEFAULT_EXECUTION_MODE: ExecutionMode = + DEFAULT_CLAUDE_EXECUTION_MODE; export const DEFAULT_MODEL = defaultEligibleModel(DEFAULT_GATEWAY_MODEL) ?? MODELS.find((model) => defaultEligibleModel(model.value))?.value ?? diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index 9de10ad54e..95b5d4725f 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -1,10 +1,11 @@ +import { filterAndSortTasks } from "@posthog/core/tasks/taskActivity"; import type { Task } from "@posthog/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore, useUserQuery } from "@/features/auth"; import { logger } from "@/lib/logger"; import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { runTaskInCloud } from "../api"; -import { filterAndSortTasks, useTaskStore } from "../stores/taskStore"; +import { useTaskStore } from "../stores/taskStore"; import type { CreateTaskOptions } from "../types"; const log = logger.scope("tasks-mutations"); diff --git a/apps/mobile/src/features/tasks/index.ts b/apps/mobile/src/features/tasks/index.ts index 0a1cf308b0..7da4db747e 100644 --- a/apps/mobile/src/features/tasks/index.ts +++ b/apps/mobile/src/features/tasks/index.ts @@ -27,7 +27,6 @@ export * from "./types"; // Utils export { - convertRawEntriesToEvents, convertStoredEntriesToEvents, parseSessionLogs, } from "./utils/parseSessionLogs"; diff --git a/apps/mobile/src/features/tasks/stores/taskStore.test.ts b/apps/mobile/src/features/tasks/stores/taskStore.test.ts deleted file mode 100644 index 3a39177ec6..0000000000 --- a/apps/mobile/src/features/tasks/stores/taskStore.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Task } from "@posthog/shared"; -import { describe, expect, it } from "vitest"; -import { filterAndSortTasks } from "./taskStore"; - -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-01T00:00:00Z", - origin_product: "tasks", - ...overrides, - }; -} - -describe("filterAndSortTasks", () => { - it.each([ - { name: "empty title and description", title: "", description: "" }, - { name: "whitespace-only fields", title: " ", description: "\n\t" }, - ])( - "hides warm-sandbox placeholder tasks ($name)", - ({ title, description }) => { - const placeholder = makeTask({ id: "warm", title, description }); - const real = makeTask({ id: "real" }); - - const result = filterAndSortTasks( - [placeholder, real], - "updated", - false, - "", - ); - - expect(result.map((t) => t.id)).toEqual(["real"]); - }, - ); - - it.each([ - { - name: "description only (title not landed yet)", - title: "", - description: "Fix login", - }, - { name: "title only", title: "Fix login", description: "" }, - ])("keeps a real task with $name", ({ title, description }) => { - const task = makeTask({ id: "real", title, description }); - - const result = filterAndSortTasks([task], "updated", false, ""); - - expect(result.map((t) => t.id)).toEqual(["real"]); - }); -}); diff --git a/apps/mobile/src/features/tasks/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts index ba7203e80f..456eae37a1 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.ts @@ -1,5 +1,3 @@ -import type { Task } from "@posthog/shared"; -import { isContentlessTask } from "@posthog/shared/domain-types"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -107,48 +105,3 @@ export const useTaskStore = create()( }, ), ); - -export function taskActivityTimestamp(task: Task, sortMode: SortMode): number { - if (sortMode === "created") { - return new Date(task.created_at).getTime(); - } - // "updated" — take the most recent of task.updated_at and latest_run.updated_at. - const runUpdated = task.latest_run?.updated_at; - const taskUpdated = task.updated_at ?? task.created_at; - return Math.max( - runUpdated ? new Date(runUpdated).getTime() : 0, - new Date(taskUpdated).getTime(), - ); -} - -export function filterAndSortTasks( - tasks: Task[], - sortMode: SortMode, - showInternal: boolean, - filter: string, -): Task[] { - let filtered = tasks; - - // Warm-sandbox prewarming creates empty placeholder tasks; never surface them. - filtered = filtered.filter((task) => !isContentlessTask(task)); - - // Visibility filter — mirrors desktop radio: External hides internal, Internal shows only internal. - filtered = filtered.filter((task) => - showInternal ? task.internal === true : task.internal !== true, - ); - - if (filter) { - const lowerFilter = filter.toLowerCase(); - filtered = filtered.filter( - (task) => - task.title.toLowerCase().includes(lowerFilter) || - task.slug.toLowerCase().includes(lowerFilter) || - task.description?.toLowerCase().includes(lowerFilter), - ); - } - - return [...filtered].sort( - (a, b) => - taskActivityTimestamp(b, sortMode) - taskActivityTimestamp(a, sortMode), - ); -} diff --git a/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts b/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts index b1a9b2ec3d..730e0d98d6 100644 --- a/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts +++ b/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts @@ -1,58 +1,6 @@ -import type { Task, TaskRunStatus } from "@posthog/shared"; import { Alert } from "react-native"; import { describe, expect, it, vi } from "vitest"; -import { confirmArchiveRunningTask, isTaskRunning } from "./archiveGuard"; - -function makeTask(status?: TaskRunStatus): Task { - return { - id: "task-1", - task_number: 1, - slug: "task-1", - title: "Test task", - description: "", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - origin_product: "code", - latest_run: status - ? { - id: "run-1", - task: "task-1", - team: 1, - branch: null, - stage: null, - environment: "cloud", - 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("treats a task with no run as not running", () => { - expect(isTaskRunning(makeTask())).toBe(false); - }); - - it.each(["not_started", "queued", "in_progress"] as const)( - "treats %s as running", - (status) => { - expect(isTaskRunning(makeTask(status))).toBe(true); - }, - ); - - it.each(["completed", "failed", "cancelled"] as const)( - "treats %s as not running", - (status) => { - expect(isTaskRunning(makeTask(status))).toBe(false); - }, - ); -}); +import { confirmArchiveRunningTask } from "./archiveGuard"; describe("confirmArchiveRunningTask", () => { it("archives only when the user confirms", () => { diff --git a/apps/mobile/src/features/tasks/utils/archiveGuard.ts b/apps/mobile/src/features/tasks/utils/archiveGuard.ts index d537075cf1..d5e8cd9687 100644 --- a/apps/mobile/src/features/tasks/utils/archiveGuard.ts +++ b/apps/mobile/src/features/tasks/utils/archiveGuard.ts @@ -1,11 +1,5 @@ -import { isTerminalStatus, type Task } from "@posthog/shared"; import { Alert } from "react-native"; -export function isTaskRunning(task: Task): boolean { - const status = task.latest_run?.status; - return status !== undefined && !isTerminalStatus(status); -} - export function confirmArchiveRunningTask( taskTitle: string, onConfirm: () => void, diff --git a/apps/mobile/src/features/tasks/utils/automationSchedule.test.ts b/apps/mobile/src/features/tasks/utils/automationSchedule.test.ts deleted file mode 100644 index af54fb0162..0000000000 --- a/apps/mobile/src/features/tasks/utils/automationSchedule.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildCronExpression, - createDefaultScheduleDraft, - deriveAutomationName, - formatScheduleSummary, - parseCronExpression, -} from "./automationSchedule"; - -describe("automationSchedule", () => { - it("builds cron expressions for common schedule presets", () => { - expect( - buildCronExpression({ - ...createDefaultScheduleDraft(), - mode: "hourly", - minute: "15", - }), - ).toBe("15 * * * *"); - - expect( - buildCronExpression({ - ...createDefaultScheduleDraft(), - mode: "daily", - hour: "09", - minute: "15", - }), - ).toBe("15 9 * * *"); - - expect( - buildCronExpression({ - ...createDefaultScheduleDraft(), - mode: "weekdays", - hour: "10", - minute: "00", - }), - ).toBe("0 10 * * 1-5"); - - expect( - buildCronExpression({ - ...createDefaultScheduleDraft(), - mode: "weekly", - hour: "11", - minute: "30", - weekday: "4", - }), - ).toBe("30 11 * * 4"); - }); - - it("parses common cron expressions back into schedule drafts", () => { - expect(parseCronExpression("15 * * * *")).toMatchObject({ - mode: "hourly", - minute: "15", - }); - - expect(parseCronExpression("0 9 * * *")).toMatchObject({ - mode: "daily", - hour: "09", - minute: "00", - }); - - expect(parseCronExpression("0 9 * * 1-5")).toMatchObject({ - mode: "weekdays", - hour: "09", - minute: "00", - }); - - expect(parseCronExpression("30 14 * * 2")).toMatchObject({ - mode: "weekly", - weekday: "2", - hour: "14", - minute: "30", - }); - }); - - it("keeps custom cron expressions in custom mode", () => { - expect(parseCronExpression("*/15 * * * *")).toMatchObject({ - mode: "custom", - rawCron: "*/15 * * * *", - }); - }); - - it("derives a readable automation name from the prompt", () => { - expect( - deriveAutomationName( - "\n Review every open PostHog PR for stale comments \n", - ), - ).toBe("Review every open PostHog PR for stale comments"); - }); - - it("formats schedule summaries with timezone context", () => { - expect(formatScheduleSummary("15 * * * *", "Europe/London")).toBe( - "Every hour at :15 · Europe/London", - ); - expect(formatScheduleSummary("0 9 * * 1-5", "Europe/London")).toBe( - "Weekdays at 09:00 · Europe/London", - ); - expect(formatScheduleSummary("*/15 * * * *", "UTC")).toBe( - "Custom schedule · UTC", - ); - }); -}); diff --git a/apps/mobile/src/features/tasks/utils/automationSchedule.ts b/apps/mobile/src/features/tasks/utils/automationSchedule.ts deleted file mode 100644 index 312616ba09..0000000000 --- a/apps/mobile/src/features/tasks/utils/automationSchedule.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type { TaskAutomation } from "@posthog/shared"; - -export type AutomationScheduleMode = - | "hourly" - | "daily" - | "weekdays" - | "weekly" - | "custom"; - -export interface AutomationScheduleDraft { - mode: AutomationScheduleMode; - hour: string; - minute: string; - weekday: string; - rawCron: string; -} - -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: Pick, -): string { - return formatScheduleSummary( - automation.cron_expression, - automation.timezone ?? null, - ); -} diff --git a/apps/mobile/src/features/tasks/utils/automationStatus.test.ts b/apps/mobile/src/features/tasks/utils/automationStatus.test.ts deleted file mode 100644 index 3a3305451b..0000000000 --- a/apps/mobile/src/features/tasks/utils/automationStatus.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getAutomationStatusPresentation } from "./automationStatus"; - -describe("automationStatus", () => { - it("shows queued when the linked task run has not started work yet", () => { - expect( - getAutomationStatusPresentation({ - lastRunStatus: "running", - lastTaskRunStatus: "queued", - }), - ).toMatchObject({ - label: "Queued", - }); - }); - - it("hides the running badge while the linked task run is actively in progress", () => { - expect( - getAutomationStatusPresentation({ - lastRunStatus: "running", - lastTaskRunStatus: "in_progress", - }), - ).toBeNull(); - }); - - it("hides the running badge when only the automation-level status is available", () => { - expect( - getAutomationStatusPresentation({ - lastRunStatus: "running", - }), - ).toBeNull(); - }); - - it("falls back to automation status when task-run detail is unavailable", () => { - expect( - getAutomationStatusPresentation({ - lastRunStatus: "success", - }), - ).toMatchObject({ - label: "Success", - }); - }); -}); diff --git a/apps/mobile/src/features/tasks/utils/automationStatus.ts b/apps/mobile/src/features/tasks/utils/automationStatus.ts deleted file mode 100644 index 5e8b7f553f..0000000000 --- a/apps/mobile/src/features/tasks/utils/automationStatus.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { TaskRun } from "@posthog/shared"; - -export interface AutomationStatusInput { - lastRunStatus: string | null; - lastTaskRunStatus?: TaskRun["status"] | null; -} - -export interface AutomationStatusPresentation { - label: string; - className: string; -} - -export function getAutomationStatusPresentation({ - lastRunStatus, - lastTaskRunStatus, -}: AutomationStatusInput): AutomationStatusPresentation | null { - switch (lastTaskRunStatus) { - case "not_started": - case "queued": - return { - label: "Queued", - className: "bg-status-warning/20 text-status-warning", - }; - case "in_progress": - return null; - case "completed": - return { - label: "Success", - className: "bg-status-success/20 text-status-success", - }; - case "failed": - case "cancelled": - return { - label: "Failed", - className: "bg-status-error/20 text-status-error", - }; - default: - break; - } - - switch (lastRunStatus) { - case "running": - return null; - case "success": - return { - label: "Success", - className: "bg-status-success/20 text-status-success", - }; - case "failed": - return { - label: "Failed", - className: "bg-status-error/20 text-status-error", - }; - default: - return { - label: "Never run", - className: "bg-gray-4 text-gray-11", - }; - } -} diff --git a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts index 6ff4044539..a6d512d59d 100644 --- a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts +++ b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts @@ -1,8 +1,5 @@ -import type { - MobileStoredLogEntry, - SessionEvent, - SessionNotification, -} from "../types"; +import { convertStoredEntriesToPortableSessionEvents } from "@posthog/core/sessions/portableSessionEvents"; +import type { MobileStoredLogEntry, SessionNotification } from "../types"; export interface ParsedSessionLogs { notifications: SessionNotification[]; @@ -53,81 +50,5 @@ export function parseSessionLogs(content: string): ParsedSessionLogs { return { notifications, rawEntries }; } -export function convertRawEntriesToEvents( - rawEntries: MobileStoredLogEntry[], - notifications: SessionNotification[], -): SessionEvent[] { - const events: SessionEvent[] = []; - let notificationIdx = 0; - - for (const entry of rawEntries) { - const ts = entry.timestamp - ? new Date(entry.timestamp).getTime() - : Date.now(); - - events.push({ - type: "acp_message", - direction: entry.direction ?? "agent", - ts, - message: entry.notification, - }); - - if ( - entry.type === "notification" && - entry.notification?.method === "session/update" && - notificationIdx < notifications.length - ) { - events.push({ - type: "session_update", - ts, - notification: notifications[notificationIdx], - }); - notificationIdx++; - } - } - - return events; -} - -function inferDirection(entry: MobileStoredLogEntry): "client" | "agent" { - if (entry.direction) return entry.direction; - const msg = entry.notification; - if (!msg) return "agent"; - const hasId = msg.id !== undefined; - const hasMethod = msg.method !== undefined; - const hasResult = msg.result !== undefined || msg.error !== undefined; - if (hasId && hasMethod) return "client"; - if (hasId && hasResult) return "agent"; - return "agent"; -} - -export function convertStoredEntriesToEvents( - entries: MobileStoredLogEntry[], -): SessionEvent[] { - const events: SessionEvent[] = []; - for (const entry of entries) { - const ts = entry.timestamp - ? new Date(entry.timestamp).getTime() - : Date.now(); - - events.push({ - type: "acp_message", - direction: inferDirection(entry), - ts, - message: entry.notification, - }); - - if ( - entry.type === "notification" && - entry.notification?.method === "session/update" && - entry.notification?.params - ) { - events.push({ - type: "session_update", - ts, - notification: entry.notification.params as SessionNotification, - }); - } - } - return events; -} +export const convertStoredEntriesToEvents = + convertStoredEntriesToPortableSessionEvents; diff --git a/apps/mobile/src/features/tasks/utils/sessionActivity.test.ts b/apps/mobile/src/features/tasks/utils/sessionActivity.test.ts deleted file mode 100644 index aef0f08b44..0000000000 --- a/apps/mobile/src/features/tasks/utils/sessionActivity.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { SessionEvent } from "../types"; -import { - countUserMessages, - getSessionActivityPhase, - isSessionAwaitingUserInput, -} from "./sessionActivity"; - -function buildUserMessage(text: string): SessionEvent { - return { - type: "session_update", - ts: 1, - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text }, - }, - }, - } satisfies SessionEvent; -} - -describe("countUserMessages", () => { - it("counts only user_message_chunk events", () => { - expect( - countUserMessages([ - buildUserMessage("hello"), - buildQuestionToolCall("pending"), - buildUserMessage("again"), - ]), - ).toBe(2); - }); - - it("returns 0 for no events", () => { - expect(countUserMessages()).toBe(0); - }); -}); - -function buildQuestionToolCall( - status: "pending" | "in_progress" | "completed", -) { - return { - type: "session_update", - ts: 1, - notification: { - update: { - sessionUpdate: "tool_call", - toolCallId: "question-1", - status, - rawInput: { - questions: [{ question: "Proceed?", options: [] }], - }, - _meta: { - claudeCode: { - toolName: "AskUserQuestion", - }, - }, - }, - }, - } satisfies SessionEvent; -} - -describe("getSessionActivityPhase", () => { - it("treats retrying as connecting", () => { - expect( - getSessionActivityPhase({ - retrying: true, - session: { isPromptPending: true, awaitingAgentOutput: false }, - }), - ).toBe("connecting"); - }); - - it("stays connecting until the agent emits visible output", () => { - expect( - getSessionActivityPhase({ - retrying: false, - session: { isPromptPending: true, awaitingAgentOutput: true }, - }), - ).toBe("connecting"); - }); - - it("shows working only after the agent is actively in a turn", () => { - expect( - getSessionActivityPhase({ - retrying: false, - session: { isPromptPending: true, awaitingAgentOutput: false }, - }), - ).toBe("working"); - }); - - it("returns idle once the agent is no longer working", () => { - expect( - getSessionActivityPhase({ - retrying: false, - session: { isPromptPending: false, awaitingAgentOutput: false }, - }), - ).toBe("idle"); - - expect( - getSessionActivityPhase({ - retrying: false, - session: { - isPromptPending: true, - awaitingAgentOutput: false, - terminalStatus: "completed", - }, - }), - ).toBe("idle"); - }); - - it("returns idle while the agent is paused on a question tool", () => { - expect( - getSessionActivityPhase({ - retrying: false, - session: { - isPromptPending: true, - awaitingAgentOutput: false, - events: [buildQuestionToolCall("pending")], - }, - }), - ).toBe("idle"); - }); -}); - -describe("isSessionAwaitingUserInput", () => { - it("detects unresolved question tools", () => { - expect(isSessionAwaitingUserInput([buildQuestionToolCall("pending")])).toBe( - true, - ); - }); - - it("clears the waiting state once the user responds", () => { - const events: SessionEvent[] = [ - buildQuestionToolCall("pending"), - { - type: "session_update", - ts: 2, - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: "Yes" }, - }, - }, - }, - ]; - - expect(isSessionAwaitingUserInput(events)).toBe(false); - }); - - it("honors explicit awaiting-user-input backend markers", () => { - const events: SessionEvent[] = [ - { - type: "acp_message", - direction: "agent", - ts: 1, - message: { method: "_posthog/awaiting_user_input" }, - }, - ]; - - expect(isSessionAwaitingUserInput(events)).toBe(true); - }); -}); diff --git a/apps/mobile/src/features/tasks/utils/sessionActivity.ts b/apps/mobile/src/features/tasks/utils/sessionActivity.ts deleted file mode 100644 index f980e66a52..0000000000 --- a/apps/mobile/src/features/tasks/utils/sessionActivity.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { SessionEvent, SessionNotification } from "../types"; - -export type SessionActivityPhase = "idle" | "connecting" | "working"; - -interface SessionActivityState { - isPromptPending?: boolean; - awaitingAgentOutput?: boolean; - terminalStatus?: "failed" | "completed"; - events?: SessionEvent[]; -} - -function isQuestionNotification(notification: SessionNotification): 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?: "pending" | "in_progress" | "completed" | "failed" | null, -): boolean { - return status === null || status === "pending" || status === "in_progress"; -} - -export function isSessionAwaitingUserInput( - events: SessionEvent[] = [], -): boolean { - let awaitingUserInput = false; - const questionStatuses = new Map< - string, - "pending" | "in_progress" | "completed" | "failed" | null | 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") && - isQuestionNotification(event.notification) - ) { - questionStatuses.set( - update?.toolCallId ?? `question-${event.ts}`, - update?.status, - ); - awaitingUserInput = [...questionStatuses.values()].some((status) => - isPendingQuestionStatus(status), - ); - } - - continue; - } - - const method = - event.message && typeof event.message === "object" - ? (event.message as { method?: string }).method - : undefined; - - if (method === "_posthog/awaiting_user_input") { - awaitingUserInput = true; - continue; - } - - if ( - method === "_posthog/turn_complete" || - method === "_posthog/task_complete" || - method === "_posthog/error" - ) { - awaitingUserInput = false; - questionStatuses.clear(); - } - } - - return awaitingUserInput; -} - -export function countUserMessages(events: SessionEvent[] = []): number { - return events.filter( - (e) => - e.type === "session_update" && - e.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.ts b/packages/core/src/tasks/taskActivity.ts index 6a52168e63..50d639e95c 100644 --- a/packages/core/src/tasks/taskActivity.ts +++ b/packages/core/src/tasks/taskActivity.ts @@ -1,9 +1,19 @@ -import { isContentlessTask, type Task } from "@posthog/shared/domain-types"; +import { isContentlessTask } from "@posthog/shared/domain-types"; export type TaskActivitySortMode = "created" | "updated"; +export interface TaskActivityInput { + title: string; + slug: string; + description?: string | null; + internal?: boolean; + created_at: string; + updated_at?: string | null; + latest_run?: { updated_at: string }; +} + export function taskActivityTimestamp( - task: Pick, + task: Pick, sortMode: TaskActivitySortMode, ): number { if (sortMode === "created") { @@ -17,12 +27,12 @@ export function taskActivityTimestamp( ); } -export function filterAndSortTasks( - tasks: readonly Task[], +export function filterAndSortTasks( + tasks: readonly TaskType[], sortMode: TaskActivitySortMode, showInternal: boolean, filter: string, -): Task[] { +): TaskType[] { const normalizedFilter = filter.toLowerCase(); return tasks diff --git a/packages/core/src/tasks/taskArchive.test.ts b/packages/core/src/tasks/taskArchive.test.ts index ab22bbd459..08da2d2eaf 100644 --- a/packages/core/src/tasks/taskArchive.test.ts +++ b/packages/core/src/tasks/taskArchive.test.ts @@ -35,6 +35,10 @@ describe("isTaskRunning", () => { }, ); + it("returns true for the legacy started status", () => { + expect(isTaskRunning({ latest_run: { status: "started" } })).toBe(true); + }); + it.each(["completed", "failed", "cancelled"] as const)( "returns false for %s", (status) => { diff --git a/packages/core/src/tasks/taskArchive.ts b/packages/core/src/tasks/taskArchive.ts index db52bb6310..d5365592cf 100644 --- a/packages/core/src/tasks/taskArchive.ts +++ b/packages/core/src/tasks/taskArchive.ts @@ -1,6 +1,8 @@ -import { isTerminalStatus, type Task } from "@posthog/shared/domain-types"; +import { isTerminalStatus } from "@posthog/shared/domain-types"; -export function isTaskRunning(task: Pick): boolean { +export function isTaskRunning(task: { + latest_run?: { status: string | null }; +}): 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 index a7895cf59e..93ff4f59a5 100644 --- a/packages/core/src/tasks/taskStatusPresentation.test.ts +++ b/packages/core/src/tasks/taskStatusPresentation.test.ts @@ -66,4 +66,16 @@ describe("getTaskStatusPresentationKind", () => { it("falls back to chat when a task has no run", () => { expect(getTaskStatusPresentationKind(makeTask())).toBe("chat"); }); + + it("preserves the legacy started presentation", () => { + expect( + getTaskStatusPresentationKind({ + latest_run: { + environment: "local", + status: "started", + output: null, + }, + }), + ).toBe("started"); + }); }); diff --git a/packages/core/src/tasks/taskStatusPresentation.ts b/packages/core/src/tasks/taskStatusPresentation.ts index 968a17bb76..92eebea72e 100644 --- a/packages/core/src/tasks/taskStatusPresentation.ts +++ b/packages/core/src/tasks/taskStatusPresentation.ts @@ -1,5 +1,4 @@ import { readPrUrls } from "@posthog/shared"; -import type { Task } from "@posthog/shared/domain-types"; export type TaskStatusPresentationKind = | "pr" @@ -9,9 +8,13 @@ export type TaskStatusPresentationKind = | "started" | "chat"; -export function getTaskStatusPresentationKind( - task: Pick, -): TaskStatusPresentationKind { +export function getTaskStatusPresentationKind(task: { + latest_run?: { + environment?: "local" | "cloud"; + status: string; + output: Record | null; + }; +}): TaskStatusPresentationKind { const latestRun = task.latest_run; if (readPrUrls(latestRun?.output)[0]) { @@ -30,6 +33,7 @@ export function getTaskStatusPresentationKind( case "in_progress": return "running"; case "queued": + case "started": return "started"; default: return "chat"; From e56b51b0b15d5f69bc2ce8f4a5315d970eb484f4 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 21 Jul 2026 16:39:41 +0300 Subject: [PATCH 07/10] fix(core): accept portable mobile session events Generated-By: PostHog Code Task-Id: 40c57a59-b4e1-4760-8e56-ecd03e9c2f0f --- .../src/features/tasks/composer/options.ts | 2 +- packages/core/src/sessions/sessionActivity.ts | 24 +++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts index f50c2c68d0..486843c8c7 100644 --- a/apps/mobile/src/features/tasks/composer/options.ts +++ b/apps/mobile/src/features/tasks/composer/options.ts @@ -70,7 +70,7 @@ export const MODELS: ModelOption[] = [ ]; export const DEFAULT_EXECUTION_MODE: ExecutionMode = - DEFAULT_CLAUDE_EXECUTION_MODE; + DEFAULT_CLAUDE_EXECUTION_MODE as ExecutionMode; export const DEFAULT_MODEL = defaultEligibleModel(DEFAULT_GATEWAY_MODEL) ?? MODELS.find((model) => defaultEligibleModel(model.value))?.value ?? diff --git a/packages/core/src/sessions/sessionActivity.ts b/packages/core/src/sessions/sessionActivity.ts index 6e2804ff47..69b5667ff1 100644 --- a/packages/core/src/sessions/sessionActivity.ts +++ b/packages/core/src/sessions/sessionActivity.ts @@ -1,17 +1,25 @@ import { isNotification, POSTHOG_NOTIFICATIONS } from "./acpNotifications"; import type { - PortableSessionEvent, PortableSessionNotification, PortableSessionToolCallStatus, + PortableSessionUpdateEvent, } from "./portableSessionEvents"; export type SessionActivityPhase = "idle" | "connecting" | "working"; +export type SessionActivityEvent = + | PortableSessionUpdateEvent + | { + type: "acp_message"; + ts: number; + message: unknown; + }; + export interface SessionActivityState { isPromptPending?: boolean; awaitingAgentOutput?: boolean; terminalStatus?: "failed" | "completed"; - events?: readonly PortableSessionEvent[]; + events?: readonly SessionActivityEvent[]; } function isQuestionNotification( @@ -44,7 +52,7 @@ function isPendingQuestionStatus( } export function isSessionAwaitingUserInput( - events: readonly PortableSessionEvent[] = [], + events: readonly SessionActivityEvent[] = [], ): boolean { let awaitingUserInput = false; const questionStatuses = new Map< @@ -87,7 +95,13 @@ export function isSessionAwaitingUserInput( continue; } - const method = "method" in event.message ? event.message.method : undefined; + const method = + typeof event.message === "object" && + event.message !== null && + "method" in event.message && + typeof event.message.method === "string" + ? event.message.method + : undefined; if (method === "_posthog/awaiting_user_input") { awaitingUserInput = true; continue; @@ -107,7 +121,7 @@ export function isSessionAwaitingUserInput( } export function countUserMessages( - events: readonly PortableSessionEvent[] = [], + events: readonly SessionActivityEvent[] = [], ): number { return events.filter( (event) => From 2437cf512992b9751f782bb0674539c012173047 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 21 Jul 2026 20:27:33 +0300 Subject: [PATCH 08/10] Share cross-platform visual semantics Generated-By: PostHog Code Task-Id: 40c57a59-b4e1-4760-8e56-ecd03e9c2f0f --- apps/mobile/package.json | 1 + .../tasks/components/PrStatusBadge.test.tsx | 2 +- .../tasks/components/PrStatusBadge.tsx | 41 +++---- apps/mobile/src/lib/theme.ts | 103 ++---------------- packages/shared/package.json | 4 + packages/shared/src/theme.ts | 93 ++++++++++++++++ packages/shared/tsup.config.ts | 1 + .../features/code-editor/theme/editorTheme.ts | 37 ++++--- .../src/features/terminal/TerminalManager.ts | 21 ++-- packages/ui/src/primitives/confetti.ts | 11 +- pnpm-lock.yaml | 3 + 11 files changed, 165 insertions(+), 152 deletions(-) create mode 100644 packages/shared/src/theme.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 915c2bdad7..09ebacf3d0 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -28,6 +28,7 @@ "@modelcontextprotocol/ext-apps": "^1.2.2", "@modelcontextprotocol/sdk": "^1.29.0", "@posthog/api-client": "workspace:*", + "@posthog/core": "workspace:*", "@posthog/shared": "workspace:*", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/netinfo": "^12.0.1", diff --git a/apps/mobile/src/features/tasks/components/PrStatusBadge.test.tsx b/apps/mobile/src/features/tasks/components/PrStatusBadge.test.tsx index a330030fde..357bb1f5d9 100644 --- a/apps/mobile/src/features/tasks/components/PrStatusBadge.test.tsx +++ b/apps/mobile/src/features/tasks/components/PrStatusBadge.test.tsx @@ -14,7 +14,7 @@ vi.mock("phosphor-react-native", () => ({ vi.mock("@/lib/theme", () => ({ useThemeColors: () => ({ gray: { 11: "#444444" }, - status: { success: "#00aa00", error: "#cc0000" }, + status: { success: "#00aa00", error: "#cc0000", merged: "#8800aa" }, }), toRgba: (hex: string, alpha: number) => `${hex}/${alpha}`, })); diff --git a/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx b/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx index 646b491866..e901cebaf5 100644 --- a/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx +++ b/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx @@ -1,3 +1,4 @@ +import { getPrVisualConfig } from "@posthog/core/git-interaction/prStatus"; import { GitMerge, GitPullRequest } from "phosphor-react-native"; import { Pressable } from "react-native"; import { openExternalUrl } from "@/lib/openExternalUrl"; @@ -13,11 +14,6 @@ interface PrStatusBadgeProps { size?: "sm" | "md"; } -// Mirrors the desktop "merged" PR color (Radix purple-9 family). Theme tokens -// don't include a purple, and merged-PR purple is recognisable enough that a -// fixed value works in both light and dark. -const MERGED_COLOR = "#8e4ec6"; - export function PrStatusBadge({ prUrl, hideWhenUnresolved = false, @@ -32,24 +28,23 @@ export function PrStatusBadge({ openExternalUrl(prUrl); }; - let color: string = themeColors.gray[11]; - let Icon: typeof GitPullRequest = GitPullRequest; - let label = "Open PR"; - - if (status?.merged) { - color = MERGED_COLOR; - Icon = GitMerge; - label = "Open merged PR"; - } else if (status?.state === "closed") { - color = themeColors.status.error; - label = "Open closed PR"; - } else if (status?.draft) { - color = themeColors.gray[11]; - label = "Open draft PR"; - } else if (status?.state === "open") { - color = themeColors.status.success; - label = "Open PR"; - } + const visual = getPrVisualConfig( + status?.state ?? "open", + status?.merged ?? false, + status?.draft ?? false, + ); + const colorByTone = { + gray: themeColors.gray[11], + green: themeColors.status.success, + red: themeColors.status.error, + purple: themeColors.status.merged, + } satisfies Record; + const color = colorByTone[visual.color]; + const Icon = visual.icon === "merged" ? GitMerge : GitPullRequest; + const label = + visual.label === "Open" + ? "Open PR" + : `Open ${visual.label.toLowerCase()} PR`; const box = size === "sm" ? "h-7 w-7" : "h-9 w-9"; const iconSize = size === "sm" ? 16 : 20; diff --git a/apps/mobile/src/lib/theme.ts b/apps/mobile/src/lib/theme.ts index 0fa4d0e819..813b82c3fe 100644 --- a/apps/mobile/src/lib/theme.ts +++ b/apps/mobile/src/lib/theme.ts @@ -1,95 +1,6 @@ +import { APP_COLOR_PALETTE, type AppThemeColors } from "@posthog/shared/theme"; import { useColorScheme, vars } from "nativewind"; -/** - * Single source of truth for all theme colors. - * Defined as hex for readability, converted to RGB for NativeWind vars(). - */ -// Color palette mirrored from the desktop app (apps/code globals.css). -// Light: slate gray + orange accent. Dark: slate gray + yellow accent. -const colors = { - light: { - gray: { - 1: "#f2f3ee", - 2: "#eceee8", - 3: "#e4e5de", - 4: "#d8dbd1", - 5: "#cbd0c3", - 6: "#bcc1b4", - 7: "#a9af9f", - 8: "#93998a", - 9: "#6b7165", - 10: "#5a6054", - 11: "#3a4036", - 12: "#0d0d0d", - }, - accent: { - 1: "#fff5f0", - 2: "#ffe8dc", - 3: "#ffd0b8", - 4: "#ffb38a", - 5: "#ff8f56", - 6: "#f57030", - 7: "#e05a18", - 8: "#c94800", - 9: "#f54d00", - 10: "#e64600", - 11: "#a33300", - 12: "#4d1800", - contrast: "#ffffff", - }, - status: { - success: "#16a34a", - error: "#dc2626", - warning: "#d97706", - info: "#2563eb", - }, - background: "#f2f3ee", - // "Card" surface — used for raised UI like buttons, composer card, pills. - // Pure white in light mode for max contrast against the cream background; - // gray-3 in dark mode so cards lift slightly off the bg. - card: "#ffffff", - }, - dark: { - gray: { - 1: "#131316", - 2: "#18181f", - 3: "#1e1e28", - 4: "#24243e", - 5: "#2a2a37", - 6: "#2e2e3d", - 7: "#40405a", - 8: "#616180", - 9: "#7c7c9e", - 10: "#8d8daa", - 11: "#9898b6", - 12: "#e6e6e6", - }, - accent: { - 1: "#14120a", - 2: "#1a1608", - 3: "#261e07", - 4: "#362900", - 5: "#443300", - 6: "#524007", - 7: "#6b561a", - 8: "#8c7230", - 9: "#f8be2a", - 10: "#ebb520", - 11: "#fcc84e", - 12: "#fde8b8", - contrast: "#1a1200", - }, - status: { - success: "#4ade80", - error: "#f87171", - warning: "#fbbf24", - info: "#60a5fa", - }, - background: "#131316", - card: "#1e1e28", - }, -} as const; - // Convert hex to RGB space-separated format for NativeWind vars() function hexToRgb(hex: string): string { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); @@ -108,7 +19,7 @@ function hexToRgba(hex: string, alpha: number): string { } // Generate NativeWind vars() from color definitions -function createThemeVars(theme: (typeof colors)["light" | "dark"]) { +function createThemeVars(theme: AppThemeColors) { return vars({ "--gray-1": hexToRgb(theme.gray[1]), "--gray-2": hexToRgb(theme.gray[2]), @@ -145,11 +56,11 @@ function createThemeVars(theme: (typeof colors)["light" | "dark"]) { } // NativeWind vars() for runtime theming (used in root View style) -export const lightTheme = createThemeVars(colors.light); -export const darkTheme = createThemeVars(colors.dark); +export const lightTheme = createThemeVars(APP_COLOR_PALETTE.light); +export const darkTheme = createThemeVars(APP_COLOR_PALETTE.dark); // Types -export type ThemeColors = (typeof colors)["light" | "dark"]; +export type ThemeColors = AppThemeColors; /** * Hook to get raw hex color values for native components. @@ -162,7 +73,9 @@ export type ThemeColors = (typeof colors)["light" | "dark"]; */ export function useThemeColors(): ThemeColors { const { colorScheme } = useColorScheme(); - return colorScheme === "dark" ? colors.dark : colors.light; + return colorScheme === "dark" + ? APP_COLOR_PALETTE.dark + : APP_COLOR_PALETTE.light; } /** diff --git a/packages/shared/package.json b/packages/shared/package.json index 7a1ee01192..2e4955ac84 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -43,6 +43,10 @@ "./posthog-property-headers": { "types": "./dist/posthog-property-headers.d.ts", "import": "./dist/posthog-property-headers.js" + }, + "./theme": { + "types": "./dist/theme.d.ts", + "import": "./dist/theme.js" } }, "scripts": { diff --git a/packages/shared/src/theme.ts b/packages/shared/src/theme.ts new file mode 100644 index 0000000000..605c770ec4 --- /dev/null +++ b/packages/shared/src/theme.ts @@ -0,0 +1,93 @@ +export const APP_COLOR_PALETTE = { + light: { + gray: { + 1: "#f2f3ee", + 2: "#eceee8", + 3: "#e4e5de", + 4: "#d8dbd1", + 5: "#cbd0c3", + 6: "#bcc1b4", + 7: "#a9af9f", + 8: "#93998a", + 9: "#6b7165", + 10: "#5a6054", + 11: "#3a4036", + 12: "#0d0d0d", + }, + accent: { + 1: "#fff5f0", + 2: "#ffe8dc", + 3: "#ffd0b8", + 4: "#ffb38a", + 5: "#ff8f56", + 6: "#f57030", + 7: "#e05a18", + 8: "#c94800", + 9: "#f54d00", + 10: "#e64600", + 11: "#a33300", + 12: "#4d1800", + contrast: "#ffffff", + }, + status: { + success: "#16a34a", + error: "#dc2626", + warning: "#d97706", + info: "#2563eb", + merged: "#8e4ec6", + }, + background: "#f2f3ee", + card: "#ffffff", + }, + dark: { + gray: { + 1: "#131316", + 2: "#18181f", + 3: "#1e1e28", + 4: "#24243e", + 5: "#2a2a37", + 6: "#2e2e3d", + 7: "#40405a", + 8: "#616180", + 9: "#7c7c9e", + 10: "#8d8daa", + 11: "#9898b6", + 12: "#e6e6e6", + }, + accent: { + 1: "#14120a", + 2: "#1a1608", + 3: "#261e07", + 4: "#362900", + 5: "#443300", + 6: "#524007", + 7: "#6b561a", + 8: "#8c7230", + 9: "#f8be2a", + 10: "#ebb520", + 11: "#fcc84e", + 12: "#fde8b8", + contrast: "#1a1200", + }, + status: { + success: "#4ade80", + error: "#f87171", + warning: "#fbbf24", + info: "#60a5fa", + merged: "#8e4ec6", + }, + background: "#131316", + card: "#1e1e28", + }, +} as const; + +export type AppThemeColors = + (typeof APP_COLOR_PALETTE)[keyof typeof APP_COLOR_PALETTE]; + +export const POSTHOG_BRAND_COLORS = [ + APP_COLOR_PALETTE.light.accent[9], + APP_COLOR_PALETTE.dark.accent[9], + "#1d4aff", + "#000000", + "#ffffff", +] as const; diff --git a/packages/shared/tsup.config.ts b/packages/shared/tsup.config.ts index 4d3231a785..6636f10e6d 100644 --- a/packages/shared/tsup.config.ts +++ b/packages/shared/tsup.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ "src/domain-types.ts", "src/mcp-sandbox-proxy.ts", "src/posthog-property-headers.ts", + "src/theme.ts", "src/types.ts", ], format: ["esm"], diff --git a/packages/ui/src/features/code-editor/theme/editorTheme.ts b/packages/ui/src/features/code-editor/theme/editorTheme.ts index 99dfab5388..a222b9cfaa 100644 --- a/packages/ui/src/features/code-editor/theme/editorTheme.ts +++ b/packages/ui/src/features/code-editor/theme/editorTheme.ts @@ -2,6 +2,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import type { Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { APP_COLOR_PALETTE } from "@posthog/shared/theme"; function withAlpha(hex: string, alpha: number): string { const a = Math.round(alpha * 255) @@ -15,40 +16,42 @@ const dark = { chalky: "#e5c07b", coral: "#e06c75", cyan: "#56b6c2", - invalid: "#e6e6e6", - ivory: "#e6e6e6", - stone: "#9898b6", + invalid: APP_COLOR_PALETTE.dark.gray[12], + ivory: APP_COLOR_PALETTE.dark.gray[12], + stone: APP_COLOR_PALETTE.dark.gray[11], malibu: "#61afef", sage: "#98c379", whiskey: "#d19a66", violet: "#c678dd", - background: "#131316", - highlightBackground: "#1e1e28", - tooltipBackground: "#24243e", - selection: "#2e2e3d", - cursor: "#f8be2a", + background: APP_COLOR_PALETTE.dark.background, + highlightBackground: APP_COLOR_PALETTE.dark.gray[3], + tooltipBackground: APP_COLOR_PALETTE.dark.gray[4], + selection: APP_COLOR_PALETTE.dark.gray[6], + cursor: APP_COLOR_PALETTE.dark.accent[9], }; +type EditorColors = { [K in keyof typeof dark]: string }; + // PostHog-inspired light theme colors const light = { chalky: "#c18401", coral: "#c45649", cyan: "#0184bc", - invalid: "#0d0d0d", + invalid: APP_COLOR_PALETTE.light.gray[12], ivory: "#1a1d17", - stone: "#6b7165", + stone: APP_COLOR_PALETTE.light.gray[9], malibu: "#4078f2", sage: "#50a14f", whiskey: "#986801", violet: "#a626a4", - background: "#f2f3ee", - highlightBackground: "#e4e5de", - tooltipBackground: "#eceee8", - selection: "#d8dbd1", - cursor: "#f54d00", + background: APP_COLOR_PALETTE.light.background, + highlightBackground: APP_COLOR_PALETTE.light.gray[3], + tooltipBackground: APP_COLOR_PALETTE.light.gray[2], + selection: APP_COLOR_PALETTE.light.gray[4], + cursor: APP_COLOR_PALETTE.light.accent[9], }; -function createEditorTheme(colors: typeof dark, isDark: boolean) { +function createEditorTheme(colors: EditorColors, isDark: boolean) { return EditorView.theme( { "&": { @@ -184,7 +187,7 @@ function createEditorTheme(colors: typeof dark, isDark: boolean) { ); } -function createHighlightStyle(colors: typeof dark) { +function createHighlightStyle(colors: EditorColors) { return HighlightStyle.define([ { tag: t.keyword, color: colors.violet }, { diff --git a/packages/ui/src/features/terminal/TerminalManager.ts b/packages/ui/src/features/terminal/TerminalManager.ts index e8ce975bb6..e12b1a59d5 100644 --- a/packages/ui/src/features/terminal/TerminalManager.ts +++ b/packages/ui/src/features/terminal/TerminalManager.ts @@ -1,6 +1,7 @@ import { DEFAULT_TERMINAL_FONT_FAMILY } from "@posthog/core/terminal/resolveTerminalFontFamily"; import { resolveService } from "@posthog/di/container"; import { getErrorMessage } from "@posthog/shared"; +import { APP_COLOR_PALETTE } from "@posthog/shared/theme"; import { SHELL_CLIENT, type ShellClient, @@ -89,22 +90,22 @@ type Listener = (payload: EventPayloadMap[T]) => void; function getTerminalTheme(isDarkMode: boolean) { return isDarkMode ? { - background: "#131316", - foreground: "#e6e6e6", - cursor: "#f8be2a", - cursorAccent: "#131316", + background: APP_COLOR_PALETTE.dark.background, + foreground: APP_COLOR_PALETTE.dark.gray[12], + cursor: APP_COLOR_PALETTE.dark.accent[9], + cursorAccent: APP_COLOR_PALETTE.dark.background, selectionBackground: "rgba(248, 190, 42, 0.25)", selectionInactiveBackground: "rgba(248, 190, 42, 0.12)", - selectionForeground: "#e6e6e6", + selectionForeground: APP_COLOR_PALETTE.dark.gray[12], } : { - background: "#f2f3ee", - foreground: "#3a4036", - cursor: "#f54d00", - cursorAccent: "#f2f3ee", + background: APP_COLOR_PALETTE.light.background, + foreground: APP_COLOR_PALETTE.light.gray[11], + cursor: APP_COLOR_PALETTE.light.accent[9], + cursorAccent: APP_COLOR_PALETTE.light.background, selectionBackground: "#fbd0b8", selectionInactiveBackground: "#f3e2d6", - selectionForeground: "#3a4036", + selectionForeground: APP_COLOR_PALETTE.light.gray[11], }; } diff --git a/packages/ui/src/primitives/confetti.ts b/packages/ui/src/primitives/confetti.ts index 46c03bfab9..eacfb3563b 100644 --- a/packages/ui/src/primitives/confetti.ts +++ b/packages/ui/src/primitives/confetti.ts @@ -1,7 +1,6 @@ +import { POSTHOG_BRAND_COLORS } from "@posthog/shared/theme"; import confetti from "canvas-confetti"; -const POSTHOG_COLORS = ["#f54d00", "#f8be2a", "#1d4aff", "#000000", "#ffffff"]; - function reducedMotion(): boolean { return ( window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false @@ -14,7 +13,7 @@ export function celebrate(options?: confetti.Options): void { particleCount: 80, spread: 70, origin: { y: 0.7 }, - colors: POSTHOG_COLORS, + colors: [...POSTHOG_BRAND_COLORS], ...options, }); } @@ -28,14 +27,14 @@ export function shipIt(): void { angle: 60, spread: 55, origin: { x: 0, y: 0.8 }, - colors: POSTHOG_COLORS, + colors: [...POSTHOG_BRAND_COLORS], }); confetti({ particleCount: 6, angle: 120, spread: 55, origin: { x: 1, y: 0.8 }, - colors: POSTHOG_COLORS, + colors: [...POSTHOG_BRAND_COLORS], }); if (Date.now() < end) requestAnimationFrame(fire); }; @@ -55,7 +54,7 @@ export function fireFrom( spread: 60, startVelocity: 35, origin: { x, y }, - colors: POSTHOG_COLORS, + colors: [...POSTHOG_BRAND_COLORS], ...options, }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f65dc4088b..ece21aa003 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -479,6 +479,9 @@ importers: '@posthog/api-client': specifier: workspace:* version: link:../../packages/api-client + '@posthog/core': + specifier: workspace:* + version: link:../../packages/core '@posthog/shared': specifier: workspace:* version: link:../../packages/shared From 43cee90b407cea27334ff558db3ab4286bbc6a79 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 21 Jul 2026 20:29:16 +0300 Subject: [PATCH 09/10] Format inherited mobile task facade Generated-By: PostHog Code Task-Id: 40c57a59-b4e1-4760-8e56-ecd03e9c2f0f --- apps/mobile/src/features/tasks/api.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index 2408f688c6..56180264de 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -1,9 +1,4 @@ -import type { - Adapter, - StoredLogEntry, - Task, - TaskRun, -} from "@posthog/shared"; +import type { Adapter, StoredLogEntry, Task, TaskRun } from "@posthog/shared"; import { fetch } from "expo/fetch"; import { authedFetch, From d97abf6b478f6e39ea14364ec8152a33f8c60e1f Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 21 Jul 2026 20:47:42 +0300 Subject: [PATCH 10/10] Align task status presentation across hosts Generated-By: PostHog Code Task-Id: 40c57a59-b4e1-4760-8e56-ecd03e9c2f0f --- .../src/tasks/taskStatusPresentation.test.ts | 23 +++++++++++- .../core/src/tasks/taskStatusPresentation.ts | 35 ++++++++++++------- .../components/HomeWorkstreamDetailPanel.tsx | 11 +++--- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/packages/core/src/tasks/taskStatusPresentation.test.ts b/packages/core/src/tasks/taskStatusPresentation.test.ts index 93ff4f59a5..7b2b6ff5e5 100644 --- a/packages/core/src/tasks/taskStatusPresentation.test.ts +++ b/packages/core/src/tasks/taskStatusPresentation.test.ts @@ -1,6 +1,9 @@ import type { Task, TaskRun } from "@posthog/shared/domain-types"; import { describe, expect, it } from "vitest"; -import { getTaskStatusPresentationKind } from "./taskStatusPresentation"; +import { + getTaskRunStatusPresentationKind, + getTaskStatusPresentationKind, +} from "./taskStatusPresentation"; function makeTask(latestRun?: Partial): Pick { return { @@ -79,3 +82,21 @@ describe("getTaskStatusPresentationKind", () => { ).toBe("started"); }); }); + +describe("getTaskRunStatusPresentationKind", () => { + it.each([ + ["completed", false, "completed"], + ["failed", false, "failed"], + ["in_progress", false, "running"], + ["queued", false, "started"], + ["not_started", false, "chat"], + ["completed", true, "running"], + ] as const)( + "maps %s with generating=%s to %s", + (status, isGenerating, expected) => { + expect(getTaskRunStatusPresentationKind(status, isGenerating)).toBe( + expected, + ); + }, + ); +}); diff --git a/packages/core/src/tasks/taskStatusPresentation.ts b/packages/core/src/tasks/taskStatusPresentation.ts index 92eebea72e..5ff6cdde54 100644 --- a/packages/core/src/tasks/taskStatusPresentation.ts +++ b/packages/core/src/tasks/taskStatusPresentation.ts @@ -8,6 +8,27 @@ export type TaskStatusPresentationKind = | "started" | "chat"; +export function getTaskRunStatusPresentationKind( + status: string | null | undefined, + isGenerating = false, +): Exclude { + if (isGenerating) return "running"; + + switch (status) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "in_progress": + return "running"; + case "queued": + case "started": + return "started"; + default: + return "chat"; + } +} + export function getTaskStatusPresentationKind(task: { latest_run?: { environment?: "local" | "cloud"; @@ -25,17 +46,5 @@ export function getTaskStatusPresentationKind(task: { return "chat"; } - switch (latestRun?.status) { - case "completed": - return "completed"; - case "failed": - return "failed"; - case "in_progress": - return "running"; - case "queued": - case "started": - return "started"; - default: - return "chat"; - } + return getTaskRunStatusPresentationKind(latestRun?.status); } diff --git a/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx b/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx index 71743b3109..bbc43c5abd 100644 --- a/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx +++ b/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx @@ -18,9 +18,9 @@ import type { HomeWorkstream, HomeWorkstreamTask, } from "@posthog/core/home/schemas"; +import { getTaskRunStatusPresentationKind } from "@posthog/core/tasks/taskStatusPresentation"; import { Badge, Button } from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; -import type { TaskRunStatus } from "@posthog/shared/domain-types"; import { type BoundAction, useBoundActions, @@ -339,15 +339,16 @@ function TaskStatusIcon({ status, isGenerating, }: { - status: TaskRunStatus | undefined; + status: HomeWorkstreamTask["status"] | undefined; isGenerating: boolean; }) { - if (isGenerating || status === "in_progress" || status === "queued") { + const kind = getTaskRunStatusPresentationKind(status, isGenerating); + if (kind === "running") { return ( ); } - if (status === "completed") { + if (kind === "completed") { return ( ); } - if (status === "failed") { + if (kind === "failed") { return ( );