Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit e1af136

Browse files
refactor(core): extract portable cloud task engine
Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22
1 parent 5bbe73f commit e1af136

10 files changed

Lines changed: 183 additions & 75 deletions

File tree

apps/web/src/web-container.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ import {
2727
} from "@posthog/core/auth/identifiers";
2828
import { canvasCoreModule } from "@posthog/core/canvas/canvas.module";
2929
import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module";
30+
import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task";
3031
import { cloudTaskModule } from "@posthog/core/cloud-task/cloud-task.module";
31-
import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task-engine";
3232
import {
3333
CLOUD_TASK_AUTH,
3434
CLOUD_TASK_SERVICE,

packages/core/src/cloud-task/cloud-task-engine.ts

Lines changed: 60 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,24 @@
1+
import type { RootLogger, ScopedLogger } from "@posthog/di/logger";
2+
import type { IAnalytics } from "@posthog/platform/analytics";
13
import {
2-
ROOT_LOGGER,
3-
type RootLogger,
4-
type ScopedLogger,
5-
} from "@posthog/di/logger";
6-
import {
7-
ANALYTICS_SERVICE,
8-
type IAnalytics,
9-
} from "@posthog/platform/analytics";
10-
import type { StoredLogEntry } from "@posthog/shared";
11-
import {
4+
type CloudTaskPermissionRequestUpdate,
5+
isTerminalStatus,
126
mcpToolKey,
137
posthogToolMeta,
8+
type StoredLogEntry,
149
serializeError,
10+
type TaskRunStatus,
1511
TypedEventEmitter,
1612
} from "@posthog/shared";
1713
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
18-
import { inject, injectable, optional, preDestroy } from "inversify";
19-
import type { CloudTaskPermissionRequestUpdate } from "./cloud-task-types";
20-
import {
21-
CLOUD_TASK_AUTH,
22-
type ICloudTaskAuth,
23-
MCP_RELAY_EXECUTOR,
24-
type McpRelayExecutor,
25-
} from "./identifiers";
14+
import type { ICloudTaskAuth, McpRelayExecutor } from "./identifiers";
2615
import {
2716
CloudTaskEvent,
2817
type CloudTaskEvents,
29-
isTerminalStatus,
3018
type SendCommandInput,
3119
type SendCommandOutput,
3220
type StopInput,
3321
type StopOutput,
34-
type TaskRunStatus,
3522
type WatchInput,
3623
} from "./schemas";
3724
import { type SseEvent, SseEventParser } from "./sse-parser";
@@ -435,23 +422,45 @@ function sandboxAlivePayload(watcher: { lastSandboxAlive: boolean | null }): {
435422
: { sandboxAlive: watcher.lastSandboxAlive };
436423
}
437424

438-
@injectable()
439-
export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
425+
export interface CloudTaskEngineDependencies {
426+
auth: ICloudTaskAuth;
427+
analytics: IAnalytics;
428+
logger: RootLogger;
429+
mcpRelayExecutor?: McpRelayExecutor | null;
430+
streamFetch?: CloudTaskFetch;
431+
}
432+
433+
export type CloudTaskFetch = (
434+
input: string | URL | Request,
435+
init?: RequestInit,
436+
) => Promise<Response>;
437+
438+
export function createCloudTaskEngine(
439+
dependencies: CloudTaskEngineDependencies,
440+
): CloudTaskEngine {
441+
return new CloudTaskEngine(dependencies);
442+
}
443+
444+
export class CloudTaskEngine extends TypedEventEmitter<CloudTaskEvents> {
440445
private watchers = new Map<string, WatcherState>();
441446
private readonly log: ScopedLogger;
442-
443-
constructor(
444-
@inject(CLOUD_TASK_AUTH)
445-
private readonly auth: ICloudTaskAuth,
446-
@inject(ANALYTICS_SERVICE)
447-
private readonly analytics: IAnalytics,
448-
@inject(ROOT_LOGGER)
449-
logger: RootLogger,
450-
@inject(MCP_RELAY_EXECUTOR)
451-
@optional()
452-
private readonly mcpRelayExecutor: McpRelayExecutor | null = null,
453-
) {
447+
private readonly auth: ICloudTaskAuth;
448+
private readonly analytics: IAnalytics;
449+
private readonly mcpRelayExecutor: McpRelayExecutor | null;
450+
private readonly streamFetch: CloudTaskFetch;
451+
452+
constructor({
453+
auth,
454+
analytics,
455+
logger,
456+
mcpRelayExecutor = null,
457+
streamFetch = globalThis.fetch.bind(globalThis),
458+
}: CloudTaskEngineDependencies) {
454459
super();
460+
this.auth = auth;
461+
this.analytics = analytics;
462+
this.mcpRelayExecutor = mcpRelayExecutor;
463+
this.streamFetch = streamFetch;
455464
this.log = logger.scope("cloud-task");
456465
}
457466

@@ -770,6 +779,22 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
770779
void this.bootstrapWatcher(key);
771780
}
772781

782+
reconnectIfDisconnected(taskId: string, runId: string): void {
783+
const key = watcherKey(taskId, runId);
784+
const watcher = this.watchers.get(key);
785+
if (
786+
!watcher ||
787+
watcher.sseAbortController ||
788+
watcher.reconnectTimeoutId ||
789+
watcher.isBootstrapping ||
790+
isTerminalStatus(watcher.lastStatus)
791+
) {
792+
return;
793+
}
794+
795+
void this.connectSse(key);
796+
}
797+
773798
// Resets a watcher to its pre-bootstrap state so bootstrapWatcher can rebuild it from server truth.
774799
private resetWatcherForRebootstrap(watcher: WatcherState): void {
775800
watcher.reconnectAttempts = 0;
@@ -959,7 +984,6 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
959984
}
960985
}
961986

962-
@preDestroy()
963987
unwatchAll(): void {
964988
for (const key of [...this.watchers.keys()]) {
965989
this.stopWatcher(key);
@@ -1306,7 +1330,7 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
13061330
try {
13071331
// The proxy authenticates with the run-scoped Bearer token; the Django leg uses the session.
13081332
const response = usingProxy
1309-
? await fetch(url.toString(), {
1333+
? await this.streamFetch(url.toString(), {
13101334
method: "GET",
13111335
headers,
13121336
signal: controller.signal,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { CloudTaskService } from "./cloud-task";
3+
import { CloudTaskEngine } from "./cloud-task-engine";
4+
5+
describe("CloudTaskService", () => {
6+
it("preserves the injectable service API as a thin engine wrapper", () => {
7+
const scopedLog = {
8+
debug: vi.fn(),
9+
info: vi.fn(),
10+
warn: vi.fn(),
11+
error: vi.fn(),
12+
};
13+
const service = new CloudTaskService(
14+
{
15+
authenticatedFetch: vi.fn(),
16+
getCloudContext: vi.fn(),
17+
},
18+
{ track: vi.fn() } as never,
19+
{ ...scopedLog, scope: vi.fn(() => scopedLog) },
20+
);
21+
22+
expect(service).toBeInstanceOf(CloudTaskEngine);
23+
expect(service.watch).toBeTypeOf("function");
24+
expect(service.retry).toBeTypeOf("function");
25+
expect(service.unwatchAll).toBeTypeOf("function");
26+
});
27+
});

packages/core/src/cloud-task/cloud-task.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ContainerModule } from "inversify";
2-
import { CloudTaskService } from "./cloud-task-engine";
2+
import { CloudTaskService } from "./cloud-task";
33
import { CLOUD_TASK_SERVICE } from "./identifiers";
44

55
export const cloudTaskModule = new ContainerModule(({ bind }) => {

packages/core/src/cloud-task/cloud-task.test.ts

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ const mockNetFetch = vi.hoisted(() => vi.fn());
55
const mockStreamFetch = vi.hoisted(() => vi.fn());
66
const mockStreamTokenFetch = vi.hoisted(() => vi.fn());
77

8-
// The service now uses global fetch for BOTH authenticated API calls (JSON)
9-
// and SSE streaming. The two used to be distinct (net.fetch vs global fetch).
108
// Route by URL: /stream_token/ → token mock (read-leg resolution), the stream leg
119
// (Django /stream/ or proxy /v1/runs/:run/stream) → stream mock, everything else → API mock.
1210
// The token mock has a Django-path default so existing fixtures (which never set it) are untouched.
1311
const fetchRouter = vi.hoisted(() =>
14-
vi.fn((input: string | Request, init?: RequestInit) => {
15-
const url = typeof input === "string" ? input : input.url;
12+
vi.fn((input: string | URL | Request, init?: RequestInit) => {
13+
const url =
14+
typeof input === "string"
15+
? input
16+
: input instanceof URL
17+
? input.toString()
18+
: input.url;
1619
const impl = url.includes("/stream_token/")
1720
? mockStreamTokenFetch
1821
: /\/stream(\/|\?|$)/.test(url)
@@ -22,7 +25,10 @@ const fetchRouter = vi.hoisted(() =>
2225
}),
2326
);
2427

25-
import { CloudTaskService } from "./cloud-task-engine";
28+
import {
29+
type CloudTaskEngine,
30+
createCloudTaskEngine,
31+
} from "./cloud-task-engine";
2632

2733
const mockAuthService = {
2834
authenticatedFetch: vi.fn(),
@@ -86,8 +92,8 @@ async function waitFor(
8692
}
8793
}
8894

89-
describe("CloudTaskService", () => {
90-
let service: CloudTaskService;
95+
describe("CloudTaskEngine", () => {
96+
let service: CloudTaskEngine;
9197

9298
beforeEach(() => {
9399
const scopedLog = {
@@ -98,11 +104,12 @@ describe("CloudTaskService", () => {
98104
};
99105
const loggerMock = { ...scopedLog, scope: vi.fn(() => scopedLog) };
100106
const analyticsMock = { track: vi.fn() };
101-
service = new CloudTaskService(
102-
mockAuthService as never,
103-
analyticsMock as never,
104-
loggerMock,
105-
);
107+
service = createCloudTaskEngine({
108+
auth: mockAuthService as never,
109+
analytics: analyticsMock as never,
110+
logger: loggerMock,
111+
streamFetch: fetchRouter,
112+
});
106113
mockNetFetch.mockReset();
107114
mockStreamFetch.mockReset();
108115
mockStreamTokenFetch.mockReset();
@@ -3077,8 +3084,8 @@ describe("CloudTaskService", () => {
30773084
});
30783085
});
30793086

3080-
describe("CloudTaskService MCP relay", () => {
3081-
let relayService: CloudTaskService;
3087+
describe("CloudTaskEngine MCP relay", () => {
3088+
let relayService: CloudTaskEngine;
30823089
let mcpRelayExecutor: {
30833090
execute: ReturnType<typeof vi.fn>;
30843091
closeRun: ReturnType<typeof vi.fn>;
@@ -3099,12 +3106,12 @@ describe("CloudTaskService MCP relay", () => {
30993106
})),
31003107
closeRun: vi.fn(async () => {}),
31013108
};
3102-
relayService = new CloudTaskService(
3103-
mockAuthService as never,
3104-
analyticsMock as never,
3105-
loggerMock,
3106-
mcpRelayExecutor as never,
3107-
);
3109+
relayService = createCloudTaskEngine({
3110+
auth: mockAuthService as never,
3111+
analytics: analyticsMock as never,
3112+
logger: loggerMock,
3113+
mcpRelayExecutor: mcpRelayExecutor as never,
3114+
});
31083115

31093116
mockNetFetch.mockReset();
31103117
mockStreamFetch.mockReset();
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
2+
import {
3+
ANALYTICS_SERVICE,
4+
type IAnalytics,
5+
} from "@posthog/platform/analytics";
6+
import { inject, injectable, optional, preDestroy } from "inversify";
7+
import { CloudTaskEngine } from "./cloud-task-engine";
8+
import {
9+
CLOUD_TASK_AUTH,
10+
type ICloudTaskAuth,
11+
MCP_RELAY_EXECUTOR,
12+
type McpRelayExecutor,
13+
} from "./identifiers";
14+
15+
@injectable()
16+
export class CloudTaskService extends CloudTaskEngine {
17+
constructor(
18+
@inject(CLOUD_TASK_AUTH)
19+
auth: ICloudTaskAuth,
20+
@inject(ANALYTICS_SERVICE)
21+
analytics: IAnalytics,
22+
@inject(ROOT_LOGGER)
23+
logger: RootLogger,
24+
@inject(MCP_RELAY_EXECUTOR)
25+
@optional()
26+
mcpRelayExecutor: McpRelayExecutor | null = null,
27+
) {
28+
super({ auth, analytics, logger, mcpRelayExecutor });
29+
}
30+
31+
@preDestroy()
32+
override unwatchAll(): void {
33+
super.unwatchAll();
34+
}
35+
}

packages/core/src/cloud-task/schemas.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,12 @@
1-
import type { TaskRunStatus } from "@posthog/shared";
1+
import type { CloudTaskUpdatePayload } from "@posthog/shared";
22
import { z } from "zod";
3-
import type { CloudTaskUpdatePayload } from "./cloud-task-types";
43

5-
export type { CloudTaskUpdatePayload, TaskRunStatus };
6-
7-
export const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const;
8-
9-
export function isTerminalStatus(
10-
status: TaskRunStatus | string | null | undefined,
11-
): boolean {
12-
return (
13-
status !== null &&
14-
status !== undefined &&
15-
TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number])
16-
);
17-
}
4+
export {
5+
type CloudTaskUpdatePayload,
6+
isTerminalStatus,
7+
type TaskRunStatus,
8+
TERMINAL_STATUSES,
9+
} from "@posthog/shared";
1810

1911
// --- Events ---
2012

packages/core/src/handoff/handoff.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
TypedEventEmitter,
66
} from "@posthog/shared";
77
import { inject, injectable } from "inversify";
8-
import type { CloudTaskService } from "../cloud-task/cloud-task-engine";
8+
import type { CloudTaskService } from "../cloud-task/cloud-task";
99
import { CLOUD_TASK_SERVICE } from "../cloud-task/identifiers";
1010
import { HandoffSaga, type HandoffSagaDeps } from "./handoff-saga";
1111
import {

packages/core/vitest.config.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { defineConfig } from "vitest/config";
2+
import { trunkTestOptions } from "../../vitest.config.base";
3+
4+
export default defineConfig({
5+
oxc: false,
6+
esbuild: {
7+
tsconfigRaw: {
8+
compilerOptions: {
9+
experimentalDecorators: true,
10+
target: "ES2022",
11+
useDefineForClassFields: false,
12+
verbatimModuleSyntax: true,
13+
},
14+
},
15+
},
16+
test: {
17+
globals: true,
18+
...trunkTestOptions,
19+
environment: "node",
20+
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
21+
exclude: ["**/node_modules/**", "**/dist/**"],
22+
},
23+
});

packages/host-router/src/routers/cloud-task.router.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task-engine";
1+
import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task";
22
import { CLOUD_TASK_SERVICE } from "@posthog/core/cloud-task/identifiers";
33
import {
44
CloudTaskEvent,

0 commit comments

Comments
 (0)