-
Notifications
You must be signed in to change notification settings - Fork 61
Expand file tree
/
Copy pathuseInboxCloudTaskRunner.ts
More file actions
314 lines (295 loc) · 11.1 KB
/
Copy pathuseInboxCloudTaskRunner.ts
File metadata and controls
314 lines (295 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort";
import {
REPORT_MODEL_RESOLVER,
type ReportModelResolver,
} from "@posthog/core/inbox/identifiers";
import {
isUsageLimitResult,
TASK_SERVICE,
type TaskCreationInput,
type TaskService,
} from "@posthog/core/task-detail/taskService";
import { useService } from "@posthog/di/react";
import {
type Adapter,
ANALYTICS_EVENTS,
defaultEligibleModel,
getCloudUrlFromRegion,
} from "@posthog/shared";
import type { Task } from "@posthog/shared/domain-types";
import { useAuthStateValue } from "@posthog/ui/features/auth/store";
import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast";
import { resolveDefaultModel } from "@posthog/ui/features/inbox/hooks/resolveDefaultModel";
import { useUserRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations";
import { toastError } from "@posthog/ui/features/notifications/errorDetails";
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations";
import { useConnectivity } from "@posthog/ui/hooks/useConnectivity";
import { toast } from "@posthog/ui/primitives/toast";
import { openTask } from "@posthog/ui/router/useOpenTask";
import { track } from "@posthog/ui/shell/analytics";
import { logger } from "@posthog/ui/shell/logger";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
/** Variant-specific copy used in the toasts/errors emitted by the runner. */
export interface InboxCloudTaskCopy {
/** Toast shown while the task is being created. */
loadingTitle: string;
/** Toast title used for any failure (repo / integration / sign-in / mutation). */
errorTitle: string;
/** Error description when no repository is selected. */
missingRepository: string;
/** Error description when no GitHub integration is connected for the repo. */
missingIntegration: string;
/** Error description when the user is signed out. */
signedOut: string;
/** Error description when no model can be resolved. */
missingModel: string;
/**
* Title for the success toast shown when `redirectOnSuccess` is false and the
* runner stays in place instead of navigating to the task. Defaults to
* "Task started".
*/
successTitle?: string;
}
/** Context the variant uses to assemble the TaskCreationInput. */
export interface InboxCloudTaskInputContext {
reportId?: string;
reportTitle?: string | null;
/**
* Resolved repository, or null when the runner ran repo-less (only possible
* when the caller sets `allowMissingRepository`). Variants that require a repo
* never observe null here, since the runner bails before `buildInput`.
*/
cloudRepository: string | null;
/** Null alongside a null `cloudRepository` (repo-less run). */
githubUserIntegrationId: string | null;
adapter: Adapter;
model: string;
reasoningLevel?: string;
}
export interface UseInboxCloudTaskRunnerOptions {
/** Backing signal report, when the task is report-scoped (Create PR, Discuss). */
reportId?: string;
reportTitle?: string | null;
cloudRepository: string | null;
/**
* When true, a missing repository is not an error: the task is created
* repo-less (no clone, no GitHub identity). The backend provisions a bare
* sandbox in that case. Use for flows that only need the cloud sandbox plus
* PostHog MCP — e.g. scout chats — never for flows that author a PR. Defaults
* to false, preserving the repo + integration gate for Create-PR / Discuss.
*/
allowMissingRepository?: boolean;
copy: InboxCloudTaskCopy;
/** Logger scope used for failure traces. */
loggerScope: string;
/** Build the TaskCreationInput from the resolved context (prompt, branch, etc.). */
buildInput: (ctx: InboxCloudTaskInputContext) => TaskCreationInput;
/** Telemetry extras merged into the TASK_CREATED event when the run succeeds. */
analyticsExtras?: Record<string, unknown>;
/** Called with the created task record, before any navigation happens. */
onTaskCreated?: (task: Task) => void;
/**
* When false, the runner does not navigate to the created task. The task is
* still added to the sidebar via `invalidateTasks`, and a success toast with a
* "View task" action lets the user open it on demand. Defaults to true.
*/
redirectOnSuccess?: boolean;
}
export interface UseInboxCloudTaskRunnerReturn {
/** Kick off the cloud-task flow. Resolves after the task is created (or failed). */
run: () => Promise<void>;
/** True while a task is being created. */
isRunning: boolean;
}
/**
* Shared driver for one-click "create an auto-mode cloud task" flows
* (Create PR, Discuss, scout fleet overview). Variants supply copy, telemetry,
* and a `buildInput` callback that assembles the per-variant prompt / branch /
* metadata; report context is optional for flows not scoped to a report.
*/
export function useInboxCloudTaskRunner({
reportId,
reportTitle,
cloudRepository,
allowMissingRepository = false,
copy,
loggerScope,
buildInput,
analyticsExtras,
onTaskCreated,
redirectOnSuccess = true,
}: UseInboxCloudTaskRunnerOptions): UseInboxCloudTaskRunnerReturn {
const [isRunning, setIsRunning] = useState(false);
const { getUserIntegrationIdForRepo } = useUserRepositoryIntegration();
const { invalidateTasks } = useCreateTask();
const taskService = useService<TaskService>(TASK_SERVICE);
const modelResolver = useService<ReportModelResolver>(REPORT_MODEL_RESOLVER);
const cloudRegion = useAuthStateValue((state) => state.cloudRegion);
const queryClient = useQueryClient();
const { isOnline } = useConnectivity();
const run = useCallback(async () => {
if (isRunning) return;
const log = logger.scope(loggerScope);
if (!isOnline) {
showOfflineToast();
return;
}
if (!cloudRepository && !allowMissingRepository) {
toast.error(copy.errorTitle, { description: copy.missingRepository });
return;
}
// A repo-less run has no GitHub identity; only resolve/require the user
// integration when a repository is actually in play.
const githubUserIntegrationId = cloudRepository
? getUserIntegrationIdForRepo(cloudRepository)
: null;
if (cloudRepository && !githubUserIntegrationId) {
toast.error(copy.errorTitle, { description: copy.missingIntegration });
return;
}
if (!cloudRegion) {
toast.error(copy.errorTitle, { description: copy.signedOut });
return;
}
setIsRunning(true);
const toastId = toast.loading(copy.loadingTitle, reportTitle ?? undefined);
const settings = useSettingsStore.getState();
const adapter = settings.lastUsedAdapter ?? "claude";
const apiHost = getCloudUrlFromRegion(cloudRegion);
// Pass the persisted model as a *preference*, not a hard selection: the
// resolver keeps it only if the gateway still offers it, otherwise it falls
// back to the server default. A stale id (e.g. one later de-listed for the
// org) would otherwise be sent here and fail the run with a gateway 403.
const preferredModel = defaultEligibleModel(settings.lastUsedModel);
const resolvedModel = await resolveDefaultModel(
queryClient,
apiHost,
adapter,
modelResolver,
preferredModel,
);
// The resolver returns undefined on a transient failure; fall back to the
// persisted id so a gateway outage degrades gracefully rather than blocking.
const model = resolvedModel ?? preferredModel;
if (!model) {
toast.dismiss(toastId);
toast.error(copy.errorTitle, { description: copy.missingModel });
setIsRunning(false);
return;
}
// The persisted effort belongs to `lastUsedModel`; if the resolver swapped in
// a fallback default, that tier may be unsupported for the new model and the
// cloud runtime rejects the pair (see agent `bin.ts`). Carry the effort only
// when the model is unchanged AND the tier is actually supported for it —
// an effort-less model (e.g. a Cloudflare `@cf/*` model) carrying a stale
// tier would otherwise hard-fail the run at startup. Otherwise let the
// runtime pick its default.
const reasoningLevel =
model === settings.lastUsedModel &&
settings.lastUsedReasoningEffort &&
isSupportedReasoningEffort(
adapter,
model,
settings.lastUsedReasoningEffort,
)
? settings.lastUsedReasoningEffort
: undefined;
const input = buildInput({
reportId,
reportTitle,
cloudRepository,
githubUserIntegrationId: githubUserIntegrationId
? String(githubUserIntegrationId)
: null,
adapter,
model,
reasoningLevel,
});
try {
let createdTask: Parameters<typeof openTask>[0] | null = null;
const result = await taskService.createTask(input, (output) => {
createdTask = output.task;
invalidateTasks(output.task);
onTaskCreated?.(output.task);
if (redirectOnSuccess) {
void openTask(output.task);
}
});
if (result.success) {
toast.dismiss(toastId);
if (!redirectOnSuccess) {
const task = createdTask;
toast.success(copy.successTitle ?? "Task started", {
description: reportTitle ?? undefined,
action: task
? {
label: "View task",
onClick: () => {
void openTask(task);
},
}
: undefined,
});
}
track(ANALYTICS_EVENTS.TASK_CREATED, {
auto_run: true,
created_from: "command-menu",
...(cloudRepository ? { repository_provider: "github" } : {}),
workspace_mode: "cloud",
...(reportId
? {
cloud_run_source: "signal_report",
cloud_pr_authorship_mode: "user",
signal_report_id: reportId,
}
: { cloud_run_source: "manual" }),
adapter,
...analyticsExtras,
});
} else {
toast.dismiss(toastId);
// Usage-limit blocks already show the upgrade modal; don't double-toast.
if (!isUsageLimitResult(result)) {
toastError(copy.errorTitle, result.error);
log.error("Cloud-task creation failed", {
failedStep: result.failedStep,
error: result.error,
reportId,
reportTitle,
});
}
}
} catch (error) {
toast.dismiss(toastId);
toastError(copy.errorTitle, error);
log.error("Unexpected error during cloud-task creation", {
error,
reportId,
});
} finally {
setIsRunning(false);
}
}, [
isRunning,
isOnline,
loggerScope,
cloudRepository,
allowMissingRepository,
cloudRegion,
reportId,
reportTitle,
getUserIntegrationIdForRepo,
invalidateTasks,
queryClient,
buildInput,
copy,
analyticsExtras,
onTaskCreated,
modelResolver,
taskService,
redirectOnSuccess,
]);
return { run, isRunning };
}