import { Clock, Duration, Effect, Schedule, Schema, Stream } from "effect" import { AIError, TimeoutError } from "./schema/errors.js" export const Status = Schema.Literals(["queued", "running", "completed", "failed", "cancelled", "expired"]) export type Status = Schema.Schema.Type /** Provider-neutral view of one generation observation. */ export interface Snapshot { readonly id: string readonly status: Status /** Normalized 0..1 when the provider reports progress. */ readonly progress?: number readonly position?: number } /** * Route-owned generation operations for one generation. The media route decodes its serializable token once (from the * submission response or a `resume` input) and closes over it, so `Generation` never sees the token's shape. */ export interface Route { readonly status: Effect.Effect readonly result: Effect.Effect readonly cancel?: Effect.Effect } export interface Poll { readonly interval?: Duration.Input readonly timeout?: Duration.Input } export interface AwaitOptions { readonly poll?: Poll } const DEFAULT_POLL_INTERVAL = Duration.seconds(5) const DEFAULT_POLL_TIMEOUT = Duration.minutes(10) export const QueuedEvent = Schema.Struct({ type: Schema.tag("generation-queued"), id: Schema.String, position: Schema.optional(Schema.Number), }).annotate({ identifier: "Generation.Event.Queued" }) export const ProgressEvent = Schema.Struct({ type: Schema.tag("generation-progress"), id: Schema.String, progress: Schema.optional(Schema.Number), }).annotate({ identifier: "Generation.Event.Progress" }) export type Observation = Schema.Schema.Type | Schema.Schema.Type export type Event = Observation | { readonly type: "generation-finished"; readonly id: string; readonly status: Status } const TERMINAL: ReadonlySet = new Set(["completed", "failed", "cancelled", "expired"]) export const isTerminal = (status: Status) => TERMINAL.has(status) export class Generation { readonly id: string readonly status: Status readonly progress?: number readonly position?: number constructor( readonly route: Route, /** Route-owned serializable JSON; pass it to the modality's `resume` from another process. */ readonly token: unknown, snapshot: Snapshot, ) { this.id = snapshot.id this.status = snapshot.status this.progress = snapshot.progress this.position = snapshot.position } get snapshot(): Snapshot { return { id: this.id, status: this.status, progress: this.progress, position: this.position, } } get terminal() { return isTerminal(this.status) } refresh(): Effect.Effect, AIError> { return this.route.status.pipe(Effect.map((snapshot) => new Generation(this.route, this.token, snapshot))) } /** Fetch the result without polling; non-completed terminal generations fail with the provider's terminal body. */ result(): Effect.Effect { return this.route.result } /** Poll until the generation reaches a terminal status, then fetch the result. Fails with a `Timeout` reason on deadline. */ await(options?: AwaitOptions): Effect.Effect { const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT) const settled = this.terminal ? Effect.succeed(this) : this.poll(options?.poll) return settled.pipe( // Non-completed terminal states also go through `result` so the route can surface its provider failure body. Effect.flatMap((generation) => generation.result()), Effect.timeoutOrElse({ duration: timeout, orElse: () => timeoutError(this.id, timeout) }), ) } cancel(): Effect.Effect { return this.route.cancel ?? Effect.void } /** * Status observations as a stream, ending after the first terminal observation. Each poll and each sleep between polls * is bounded by the time remaining until `poll.timeout`, so a hung status request or a long interval fails the stream at * the deadline instead of stalling it. (`Stream.interruptWhen` would express this directly but deadlocks under * `TestClock` when the source completes while the timer sleeps.) */ events(options?: AwaitOptions): Stream.Stream { if (this.terminal) return Stream.make(this.event()) const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT) return Stream.unwrap( Clock.currentTimeMillis.pipe( Effect.map((start) => { const deadline = start + Duration.toMillis(timeout) const refresh = within(this.refresh(), this.id, timeout, deadline) const schedule = this.schedule(options?.poll).pipe( Schedule.modifyDelay((meta) => Effect.succeed(Duration.min(meta.duration, Duration.millis(Math.max(0, deadline - meta.now)))), ), ) return Stream.fromEffectSchedule(refresh, schedule).pipe( Stream.takeUntil((generation) => generation.terminal), Stream.map((generation) => generation.event()), ) }), ), ) } private event(): Event { if (this.terminal) return { type: "generation-finished", id: this.id, status: this.status } if (this.status === "queued") return { type: "generation-queued", id: this.id, position: this.position } return { type: "generation-progress", id: this.id, progress: this.progress } } private poll(poll: Poll | undefined) { return this.refresh().pipe( Effect.repeat({ schedule: this.schedule(poll), until: (generation) => generation.terminal }), ) } private schedule(poll: Poll | undefined) { return Schedule.spaced(poll?.interval ?? DEFAULT_POLL_INTERVAL) } } /** `events` followed by the expanded result, with the result fetch bounded by the same `poll.timeout` deadline. */ export const resultEvents = ( generation: Generation, expand: (response: Response) => ReadonlyArray, options?: AwaitOptions, ): Stream.Stream => { const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT) return Stream.unwrap( Clock.currentTimeMillis.pipe( Effect.map((start) => generation.events(options).pipe( Stream.filter((event): event is Observation => event.type !== "generation-finished"), Stream.concat( Stream.fromIterableEffect( within(generation.result(), generation.id, timeout, start + Duration.toMillis(timeout)).pipe( Effect.map(expand), ), ), ), ), ), ), ) } /** * Run `effect` within the time left until `deadline`. Fails before starting once the deadline has passed: a fast * request could otherwise win the zero-budget race and schedule another zero-delay poll. */ const within = (effect: Effect.Effect, id: string, timeout: Duration.Duration, deadline: number) => Clock.currentTimeMillis.pipe( Effect.flatMap((now) => now >= deadline ? Effect.fail(timeoutError(id, timeout)) : effect.pipe( Effect.timeoutOrElse({ duration: Duration.millis(deadline - now), orElse: () => Effect.fail(timeoutError(id, timeout)), }), ), ), ) const timeoutError = (id: string, timeout: Duration.Duration) => new AIError({ reason: new TimeoutError({ message: `Generation ${id} did not finish within ${Duration.format(timeout)}`, timeoutMs: Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, Math.ceil(Duration.toMillis(timeout)))), }), })