`makeTrackExecutionUsage` lived in its own `services/autumn-tracker.ts` only to dodge the `api/layers.ts` → `@tanstack/react-start` import cycle that breaks vitest-pool-workers. It baked fire-and-forget semantics + error logging into the function shape — callers got back a `(id) => void` and couldn't compose or test the billing call. Moves the tracker onto the service itself as `AutumnService.trackExecution` (Effect shape, error channel `never`), matching the existing `use` method pattern. Callers now `Effect.runFork(autumn.trackExecution(id))` at the single boundary in `execution-stack.ts`, which is honest about the fire-and-forget choice happening at that layer — not in the service. - `services/autumn.ts`: adds `trackExecution` to `IAutumnService`. Errors are caught and logged; the returned Effect never fails. Wrapped in an `autumn.trackExecution` span so traces pick it up the same way the existing `use` calls do. - `services/execution-stack.ts`: inlines the `Effect.runFork` at the sole caller; drops the `autumn-tracker` import. - `api/autumn.ts`: drops the re-export that pointed at the old file. - Deletes `services/autumn-tracker.ts`.
Unified · Split
| @@ -8,12 +8,6 @@ import { AutumnService } from "../services/autumn"; · 7 unmodified lines | |||
| 8 | import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response"; | 8 | import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response"; |
| 9 | import { SharedServices } from "./layers"; | 9 | import { SharedServices } from "./layers"; |
| 10 | 10 | ||
| 11 | // Re-export so existing callers don't have to change their import path. | ||
| 12 | // The actual implementation moved to `services/autumn-tracker.ts` to break | ||
| 13 | // a DO → api/* → tanstack-start import cycle that crashed module load | ||
| 14 | // under vitest-pool-workers. | ||
| 15 | export { makeTrackExecutionUsage } from "../services/autumn-tracker"; | ||
| 16 | |||
| 17 | const handleAutumnRequestEffect = Effect.gen(function* () { | 11 | const handleAutumnRequestEffect = Effect.gen(function* () { |
| 18 | const request = yield* HttpServerRequest.HttpServerRequest; | 12 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 19 | const webRequest = yield* Effect.mapError( | 13 | const webRequest = yield* Effect.mapError( |
| @@ -1,38 +0,0 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Fire-and-forget execution usage tracker against Autumn. | ||
| 3 | // --------------------------------------------------------------------------- | ||
| 4 | // | ||
| 5 | // Lives in `services/` rather than `api/` because callers on the Durable | ||
| 6 | // Object path (`mcp-session.ts` → `execution-stack.ts`) transitively pull | ||
| 7 | // it in, and anything under `api/` eventually re-exports from | ||
| 8 | // `api/layers.ts`, which imports `auth/handlers.ts` → `@tanstack/react-start` | ||
| 9 | // — that pulls the `#tanstack-start-plugin-adapters` subpath specifier | ||
| 10 | // into Vite's resolver and breaks vitest-pool-workers module load. | ||
| 11 | // | ||
| 12 | // This file has exactly one responsibility, no HTTP concerns, and imports | ||
| 13 | // nothing from `api/`. | ||
| 14 | // --------------------------------------------------------------------------- | ||
| 15 | |||
| 16 | import { Effect } from "effect"; | ||
| 17 | |||
| 18 | import type { AutumnService } from "./autumn"; | ||
| 19 | |||
| 20 | export const makeTrackExecutionUsage = (autumn: AutumnService["Type"]) => { | ||
| 21 | return (organizationId: string): void => { | ||
| 22 | autumn | ||
| 23 | .use((client) => | ||
| 24 | client.track({ | ||
| 25 | customerId: organizationId, | ||
| 26 | featureId: "executions", | ||
| 27 | value: 1, | ||
| 28 | }), | ||
| 29 | ) | ||
| 30 | .pipe( | ||
| 31 | Effect.catchAll((err) => { | ||
| 32 | console.error("[billing] track failed:", err); | ||
| 33 | return Effect.void; | ||
| 34 | }), | ||
| 35 | Effect.runFork, | ||
| 36 | ); | ||
| 37 | }; | ||
| 38 | }; | ||
| @@ -21,6 +21,13 @@ export class AutumnError extends Data.TaggedError("AutumnError")<{ · 20 unmodified lines | |||
| 21 | 21 | ||
| 22 | export type IAutumnService = Readonly<{ | 22 | export type IAutumnService = Readonly<{ |
| 23 | use: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnError, never>; | 23 | use: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnError, never>; |
| 24 | /** | ||
| 25 | * Fire-and-forget-safe execution usage tracker. Errors are caught and | ||
| 26 | * logged; the returned Effect never fails. Callers typically | ||
| 27 | * `Effect.runFork` it at the boundary so the billing call can't stall a | ||
| 28 | * user-facing request. | ||
| 29 | */ | ||
| 30 | trackExecution: (organizationId: string) => Effect.Effect<void, never, never>; | ||
| 24 | }>; | 31 | }>; |
| 25 | 32 | ||
| 26 | // --------------------------------------------------------------------------- | 33 | // --------------------------------------------------------------------------- |
| @@ -31,9 +38,13 @@ const make = Effect.sync(() => { · 4 unmodified lines | |||
| 31 | const secretKey = server.AUTUMN_SECRET_KEY; | 38 | const secretKey = server.AUTUMN_SECRET_KEY; |
| 32 | 39 | ||
| 33 | if (!secretKey) { | 40 | if (!secretKey) { |
| 41 | const notConfigured = Effect.die( | ||
| 42 | new Error("Autumn not configured — AUTUMN_SECRET_KEY is empty"), | ||
| 43 | ); | ||
| 34 | return { | 44 | return { |
| 35 | use: () => Effect.die(new Error("Autumn not configured — AUTUMN_SECRET_KEY is empty")), | 45 | use: () => notConfigured, |
| 36 | } as IAutumnService; | 46 | trackExecution: () => Effect.void, |
| 47 | } satisfies IAutumnService; | ||
| 37 | } | 48 | } |
| 38 | 49 | ||
| 39 | const client = new Autumn({ secretKey }); | 50 | const client = new Autumn({ secretKey }); |
| @@ -44,7 +55,19 @@ const make = Effect.sync(() => { · 4 unmodified lines | |||
| 44 | catch: (cause) => new AutumnError({ cause }), | 55 | catch: (cause) => new AutumnError({ cause }), |
| 45 | }).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`)); | 56 | }).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`)); |
| 46 | 57 | ||
| 47 | return { use } satisfies IAutumnService; | 58 | const trackExecution = (organizationId: string) => |
| 59 | use((c) => | ||
| 60 | c.track({ customerId: organizationId, featureId: "executions", value: 1 }), | ||
| 61 | ).pipe( | ||
| 62 | Effect.asVoid, | ||
| 63 | Effect.tapError((err) => | ||
| 64 | Effect.sync(() => console.error("[billing] track failed:", err)), | ||
| 65 | ), | ||
| 66 | Effect.orElse(() => Effect.void), | ||
| 67 | Effect.withSpan("autumn.trackExecution"), | ||
| 68 | ); | ||
| 69 | |||
| 70 | return { use, trackExecution } satisfies IAutumnService; | ||
| 48 | }); | 71 | }); |
| 49 | 72 | ||
| 50 | export class AutumnService extends Context.Tag("@executor/cloud/AutumnService")< | 73 | export class AutumnService extends Context.Tag("@executor/cloud/AutumnService")< |
| @@ -12,7 +12,6 @@ import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker"; · 11 unmodified lines | |||
| 12 | 12 | ||
| 13 | import { withExecutionUsageTracking } from "../api/execution-usage"; | 13 | import { withExecutionUsageTracking } from "../api/execution-usage"; |
| 14 | import { AutumnService } from "./autumn"; | 14 | import { AutumnService } from "./autumn"; |
| 15 | import { makeTrackExecutionUsage } from "./autumn-tracker"; | ||
| 16 | import { createScopedExecutor } from "./executor"; | 15 | import { createScopedExecutor } from "./executor"; |
| 17 | 16 | ||
| 18 | export const makeExecutionStack = (organizationId: string, organizationName: string) => | 17 | export const makeExecutionStack = (organizationId: string, organizationName: string) => |
| @@ -23,7 +22,7 @@ export const makeExecutionStack = (organizationId: string, organizationName: str · 4 unmodified lines | |||
| 23 | const engine = withExecutionUsageTracking( | 22 | const engine = withExecutionUsageTracking( |
| 24 | organizationId, | 23 | organizationId, |
| 25 | createExecutionEngine({ executor, codeExecutor }), | 24 | createExecutionEngine({ executor, codeExecutor }), |
| 26 | makeTrackExecutionUsage(autumn), | 25 | (orgId) => Effect.runFork(autumn.trackExecution(orgId)), |
| 27 | ); | 26 | ); |
| 28 | return { executor, engine }; | 27 | return { executor, engine }; |
| 29 | }); | 28 | }); |