Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/agent/src/adapters/claude/session/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,9 @@ function buildEnvironment(gateway?: GatewayEnv): Record<string, string> {
}),
...(gateway?.openaiBaseUrl && { OPENAI_BASE_URL: gateway.openaiBaseUrl }),
...(gateway?.openaiApiKey && { OPENAI_API_KEY: gateway.openaiApiKey }),
ELECTRON_RUN_AS_NODE: "1",
...((process.versions.electron || process.env.ELECTRON_RUN_AS_NODE) && {
ELECTRON_RUN_AS_NODE: "1",
}),
Comment thread
tatoalo marked this conversation as resolved.
CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL: "true",
// Offload all MCP tools by default
ENABLE_TOOL_SEARCH: "auto:0",
Expand Down
161 changes: 161 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import type {
PriorityJudgmentArtefact,
RepoSelectionArtefact,
SafetyJudgmentArtefact,
SandboxCustomImage,
SandboxEnvironment,
SandboxEnvironmentInput,
Signal,
Expand Down Expand Up @@ -119,6 +120,13 @@ export class SeatPaymentFailedError extends Error {
}
}

export class SandboxCustomImagesDisabledError extends Error {
constructor(message?: string) {
super(message ?? "Custom sandbox images are not enabled");
this.name = "SandboxCustomImagesDisabledError";
}
}

export type UsageLimitType = "burst" | "sustained" | null;

// Stable message so callers recognize this after a saga reduces the error to a string.
Expand Down Expand Up @@ -477,6 +485,7 @@ interface CloudRunOptions {
model?: string;
reasoningLevel?: string;
sandboxEnvironmentId?: string;
customImageId?: string;
prAuthorshipMode?: PrAuthorshipMode;
runSource?: CloudRunSource;
signalReportId?: string;
Expand Down Expand Up @@ -548,6 +557,9 @@ function buildCloudRunRequestBody(
if (options?.sandboxEnvironmentId) {
body.sandbox_environment_id = options.sandboxEnvironmentId;
}
if (options?.customImageId) {
body.custom_image_id = options.customImageId;
}
if (options?.prAuthorshipMode) {
body.pr_authorship_mode = options.prAuthorshipMode;
}
Expand Down Expand Up @@ -4404,6 +4416,155 @@ export class PostHogAPIClient {
}
}

async listSandboxCustomImages(): Promise<SandboxCustomImage[]> {
const teamId = await this.getTeamId();
const url = new URL(
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/`,
);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: `/api/projects/${teamId}/sandbox_custom_images/`,
});
if (!response.ok) {
if (response.status === 403) {
const errorData = (await response.json().catch(() => ({}))) as {
detail?: string;
};
throw new SandboxCustomImagesDisabledError(errorData.detail);
}
throw new Error(
`Failed to fetch sandbox custom images: ${response.statusText}`,
);
}
const data = (await response.json()) as {
results?: SandboxCustomImage[];
};
return data.results ?? [];
}

async createSandboxCustomImage(input: {
name: string;
description?: string;
repository?: string | null;
private?: boolean;
}): Promise<SandboxCustomImage> {
const teamId = await this.getTeamId();
const url = new URL(
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/`,
);
const response = await this.api.fetcher.fetch({
method: "post",
url,
path: `/api/projects/${teamId}/sandbox_custom_images/`,
overrides: {
body: JSON.stringify(input),
},
});
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as {
detail?: string;
};
throw new Error(
errorData.detail ??
`Failed to create sandbox custom image: ${response.statusText}`,
);
}
return (await response.json()) as SandboxCustomImage;
}

async getSandboxCustomImage(id: string): Promise<SandboxCustomImage> {
const teamId = await this.getTeamId();
const url = new URL(
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/`,
);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/`,
});
if (!response.ok) {
throw new Error(
`Failed to fetch sandbox custom image: ${response.statusText}`,
);
}
return (await response.json()) as SandboxCustomImage;
}

async ensureSandboxCustomImageBuilderTask(
id: string,
): Promise<SandboxCustomImage> {
const teamId = await this.getTeamId();
const url = new URL(
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/builder_task/`,
);
const response = await this.api.fetcher.fetch({
method: "post",
url,
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/builder_task/`,
overrides: {
body: JSON.stringify({}),
},
});
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as {
detail?: string;
};
throw new Error(
errorData.detail ??
`Failed to open image builder session: ${response.statusText}`,
);
}
return (await response.json()) as SandboxCustomImage;
}

async buildSandboxCustomImage(
id: string,
specYaml?: string | null,
): Promise<SandboxCustomImage> {
const teamId = await this.getTeamId();
const url = new URL(
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/build/`,
);
const response = await this.api.fetcher.fetch({
method: "post",
url,
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/build/`,
overrides: {
body: JSON.stringify(
specYaml === undefined ? {} : { spec_yaml: specYaml },
),
},
});
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as {
detail?: string;
};
throw new Error(
errorData.detail ??
`Failed to build sandbox custom image: ${response.statusText}`,
);
}
return (await response.json()) as SandboxCustomImage;
}

async deleteSandboxCustomImage(id: string): Promise<void> {
const teamId = await this.getTeamId();
const url = new URL(
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/`,
);
const response = await this.api.fetcher.fetch({
method: "delete",
url,
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/`,
});
if (!response.ok) {
throw new Error(
`Failed to delete sandbox custom image: ${response.statusText}`,
);
}
}

/** Find an exported asset by session recording ID. */
async findExportBySessionRecordingId(
projectId: number,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/settings/sandboxEnvironmentForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface SandboxEnvironmentFormState {
include_default_domains: boolean;
environment_variables_text: string;
private: boolean;
custom_image_id: string | null;
}

export function isValidDomain(domain: string): boolean {
Expand Down Expand Up @@ -71,6 +72,7 @@ export function emptyForm(): SandboxEnvironmentFormState {
include_default_domains: true,
environment_variables_text: "",
private: true,
custom_image_id: null,
};
}

Expand All @@ -84,6 +86,7 @@ export function formFromEnv(
include_default_domains: env.include_default_domains,
environment_variables_text: "",
private: env.private,
custom_image_id: env.custom_image_id ?? null,
};
}

Expand All @@ -100,6 +103,7 @@ export function buildSandboxEnvironmentInput(
include_default_domains: isCustom ? form.include_default_domains : false,
private: form.private,
repositories: [],
custom_image_id: form.custom_image_id,
...(form.environment_variables_text.trim()
? { environment_variables: envVars }
: {}),
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/sidebar/groupTasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,37 @@ describe("groupByRepository", () => {
expect(groups[0]?.name).toBe("Other");
});

it("routes image-builder tasks to a pinned 'Custom images' group above 'Other'", () => {
const tasks: TestTask[] = [
{ id: "t1", repository: null, originProduct: "image_builder" },
{
id: "t2",
repository: {
fullPath: "posthog/code",
name: "code",
organization: "PostHog",
},
originProduct: "image_builder",
},
task("t3"),
task("t4", {
fullPath: "posthog/posthog",
name: "posthog",
organization: "PostHog",
}),
];

const groups = groupByRepository(tasks, []);

expect(groups.map((g) => g.id)).toEqual([
"posthog/posthog",
"custom-images",
"other",
]);
expect(groups[1]?.name).toBe("Custom images");
expect(groups[1]?.tasks.map((t) => t.id)).toEqual(["t1", "t2"]);
});

it("keeps the bare name for a group without an organization when others collide", () => {
const tasks: TestTask[] = [
task("t1", {
Expand Down
36 changes: 23 additions & 13 deletions packages/core/src/sidebar/groupTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ export interface TaskRepositoryInfo {

export interface GroupableTask {
repository: TaskRepositoryInfo | null;
originProduct?: string;
}

export const CUSTOM_IMAGES_GROUP_ID = "custom-images";

export interface TaskGroup<T extends GroupableTask> {
id: string;
name: string;
Expand Down Expand Up @@ -69,8 +72,13 @@ export function groupByRepository<T extends GroupableTask>(

for (const task of tasks) {
const repository = task.repository;
const groupId = repository?.fullPath ?? "other";
const groupName = repository?.name ?? "Other";
const isImageBuilder = task.originProduct === "image_builder";
const groupId = isImageBuilder
? CUSTOM_IMAGES_GROUP_ID
: (repository?.fullPath ?? "other");
const groupName = isImageBuilder
? "Custom images"
: (repository?.name ?? "Other");

let group = groupMap.get(groupId);
if (!group) {
Expand Down Expand Up @@ -106,25 +114,27 @@ export function groupByRepository<T extends GroupableTask>(
}
}

// The "other" group (tasks without a resolvable repository) always sorts to
// the bottom, regardless of the alphabetical or persisted folder order.
const pinOtherLast = (a: TaskGroup<T>, b: TaskGroup<T>): number | null => {
const aOther = a.id === "other";
const bOther = b.id === "other";
if (aOther && bOther) return 0;
if (aOther) return 1;
if (bOther) return -1;
return null;
// Custom-images and "other" always sort last, in that order.
const pinnedRank = (group: TaskGroup<T>): number => {
if (group.id === CUSTOM_IMAGES_GROUP_ID) return 1;
if (group.id === "other") return 2;
return 0;
};
const pinSpecialLast = (a: TaskGroup<T>, b: TaskGroup<T>): number | null => {
const aRank = pinnedRank(a);
const bRank = pinnedRank(b);
if (aRank === 0 && bRank === 0) return null;
return aRank - bRank;
};

if (folderOrder.length === 0) {
return groups.sort(
(a, b) => pinOtherLast(a, b) ?? a.name.localeCompare(b.name),
(a, b) => pinSpecialLast(a, b) ?? a.name.localeCompare(b.name),
);
}

return groups.sort((a, b) => {
const pinned = pinOtherLast(a, b);
const pinned = pinSpecialLast(a, b);
if (pinned !== null) return pinned;
const aIndex = folderOrder.indexOf(a.id);
const bIndex = folderOrder.indexOf(b.id);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/task-detail/taskCreationApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface CreateTaskRunClientOptions {
model?: string;
reasoningLevel?: string;
sandboxEnvironmentId?: string;
customImageId?: string;
prAuthorshipMode?: PrAuthorshipMode;
runSource?: CloudRunSource;
signalReportId?: string;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/task-detail/taskCreationSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ export class TaskCreationSaga extends Saga<
model: input.model,
reasoningLevel: input.reasoningLevel,
sandboxEnvironmentId: input.sandboxEnvironmentId,
customImageId: input.customImageId,
prAuthorshipMode,
runSource: input.cloudRunSource ?? "manual",
signalReportId: input.signalReportId,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/task-detail/taskInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface PrepareTaskInputOptions {
reasoningLevel?: string;
environmentId?: string | null;
sandboxEnvironmentId?: string;
customImageId?: string;
signalReportId?: string;
additionalDirectories?: string[];
channelContext?: string;
Expand Down Expand Up @@ -52,6 +53,7 @@ export function prepareTaskInput(
reasoningLevel: options.reasoningLevel,
environmentId: options.environmentId ?? undefined,
sandboxEnvironmentId: options.sandboxEnvironmentId,
customImageId: options.customImageId,
cloudPrAuthorshipMode:
options.signalReportId && isCloud ? "user" : undefined,
cloudRunSource:
Expand Down
Loading
Loading