/** * Shared client interface for Weft. Both {@link LocalClient} and * {@link HttpClient} implement this contract so switching between * library mode and server mode is a constructor change, not an API change. * * @module client/interface */ import type { ClientOperationName, ClientOperationTypes, ClientOperations } from '../cli/generated/operation-client.generated.ts'; import type { StoredStreamChunk } from '../core/context.ts'; import type { StartOrSignalOutcome as EngineStartOrSignalOutcome } from '../core/engine/handles.ts'; import type { TypedEventTarget, WeftEventMap } from '../core/events.ts'; import type { AttributeFilterKey, BulkCancelResult, BulkDeleteResult, BulkRetryFailedResult, BulkSignalResult, BulkTagResult, CoordinatedUpdateResult, ForkOptions, ListFilter, PaginatedResult, PendingAsyncActivityListOptions, PendingAsyncActivityPage, PurgeResult, QueryDefinition, RetentionOverview, ReviewListEntry, ReviewListFilter, ScheduleUpdateOptions as ScheduleEdit, ScheduleFilter, ScheduleOptions, ScheduleSpec, ScheduleSummary, SearchAttributeValue, SignalDefinition, SignalDeliveryOptions, StartOptions, StartOrSignalOptions, StartOrSignalSignal, SubmitReviewOptions, TypedListFilter, UpdateDefinition, WorkflowEvent, WorkflowInput, WorkflowOutput, WorkflowRegistry, WorkflowReplay, WorkflowState, WorkflowSummary, WorkflowTimelineEntry } from '../core/types.ts'; import type { WeftClientStorage } from './client-storage.ts'; import type { WorkflowEventTail } from './event-tail.ts'; import type { KnownWorkflowName, UnknownNameWhenRegistryEmpty } from './workflow-name-typing.ts'; /** * Start options accepted by remote-capable clients. These options intentionally * exclude inline-only engine features such as `defer` and per-run `services` * because they cannot be serialized over the HTTP transport. * * @example * ```ts * import type { ClientStartOptions } from '@lostgradient/weft/client'; * * const options: ClientStartOptions = { * id: 'welcome-ada', * tags: ['onboarding'], * }; * void options; * ``` */ export type ClientStartOptions = Omit & { readonly defer?: never; readonly services?: never; }; /** * Remote-capable start-or-signal options, including terminal restart policy. * @example * ```ts * import type { ClientStartOrSignalOptions } from '@lostgradient/weft/client'; * const options: ClientStartOrSignalOptions = { * id: 'github:installations:42:sync', * onTerminalConflict: 'start-new', * }; * void options; * ``` */ export type ClientStartOrSignalOptions = ClientStartOptions & Pick; /** * Which atomic path a {@link WeftClient.startOrSignal} call took, returned * alongside the {@link ClientHandle}. `'started'` when the call created the * run; `'signalled'` when it delivered a signal to a run that already existed, * including losing a concurrent same-key create race and converging onto the * winner. Each call receives its OWN handle regardless of the outcome. * * @example * ```ts * import type { StartOrSignalOutcome } from '@lostgradient/weft/client'; * * const outcome: StartOrSignalOutcome = 'started'; * void outcome; * ``` */ export type StartOrSignalOutcome = EngineStartOrSignalOutcome; /** * A reference to a workflow that provides convenience methods. * * Extends {@link TypedEventTarget} so callers can observe workflow lifecycle * events with the same `addEventListener` / `removeEventListener` API in both * library mode (events flow through `EventTarget` directly) and server mode * (events are bridged over WebSocket). * * @example * ```ts * import { workflow, Engine, MemoryStorage, LocalClient, type WorkflowCompletedEvent } from '@lostgradient/weft'; * import type { ClientHandle } from '@lostgradient/weft/client'; * * await using engine = new Engine({ storage: new MemoryStorage() }); * engine.register(workflow({ name: 'ping' }).execute(async function* () { return 'pong'; })); * * const client = new LocalClient(engine); * const handle: ClientHandle = await client.start('ping', null); * handle.addEventListener('workflow:completed', (e) => { * console.log('completed', (e as WorkflowCompletedEvent).result); * }); * const result = await handle.result(); * console.log(result); // 'pong' * ``` * * The `TResult` parameter carries the workflow's output type. It defaults to * `unknown`, so untyped (string-name) call sites are unaffected. When a * project augments {@link WorkflowRegistry} (typically via `weft codegen`), * the typed `start` overload returns `ClientHandle>` and * `result()` is narrowed to that workflow's output. * * The narrowing is a compile-time projection of the registered output schema, * exactly as `engine.start` returns a typed `WorkflowHandle` over a * runtime handle whose `result()` is structurally `Promise`. The * concrete handles (`LocalHandle`, `HttpHandle`) stay non-generic; the static * type reflects the schema the project opted into via codegen, not an extra * runtime guarantee. */ export interface ClientHandle extends TypedEventTarget, Disposable { /** The workflow's unique identifier. */ readonly id: string; /** * For a {@link WeftClient.startOrSignal} handle, which atomic path the call * took (see {@link StartOrSignalOutcome}); `undefined` on handles from any * other call (`start`, `getHandle`, `resume`, …). */ readonly outcome: StartOrSignalOutcome | undefined; /** Resolves when the workflow completes (or rejects on failure). */ result(): Promise; /** Cancel this workflow. */ cancel(): Promise; /** * Suspend this workflow without terminating it: it moves to the non-terminal * `suspended` status, keeps its checkpoint, and is later resumable via * {@link ClientHandle.resume}. Unlike {@link ClientHandle.cancel}, it does not * run cancel handlers and does not settle `result()`. Inline execution mode * only (worker-mode servers fault with `Unprocessable`). */ suspend(): Promise; /** * Resume this workflow from its persisted checkpoint after a * {@link ClientHandle.suspend} (or after a process restart left it running). * `result()` resolves when the resumed run completes. */ resume(): Promise; /** Send a named signal with an optional payload. */ signal(name: SignalDefinition): Promise; signal(name: SignalDefinition, payload: TInput, options?: SignalDeliveryOptions): Promise; signal(name: string, payload?: unknown, options?: SignalDeliveryOptions): Promise; /** Submit a synchronous update and return the handler's result. */ update(name: UpdateDefinition, payload?: void, options?: { timeout?: number; }): Promise; update(name: UpdateDefinition, payload: TInput, options?: { timeout?: number; }): Promise; update(name: string, payload?: unknown, options?: { timeout?: number; }): Promise; /** Query a named read-only accessor on the running workflow. */ query(name: QueryDefinition): Promise; query(name: QueryDefinition, input: TInput): Promise; query(name: string, input?: unknown): Promise; /** Get search attributes for this workflow. */ getAttributes(): Promise | null>; /** Set search attributes on this workflow (merge semantics). */ setAttributes(attributes: Record): Promise; /** Add free-form tags to this workflow. */ addTags(...tags: string[]): Promise; /** Remove free-form tags from this workflow. */ removeTags(...tags: string[]): Promise; /** * Open a live, push-based tail of this workflow's events. Async-iterate the * returned {@link WorkflowEventTail} to consume events as they happen. In * server mode this rides the WebSocket watch channel (no polling); in library * mode it bridges the engine's event stream directly. */ tail(): WorkflowEventTail; /** * Resolves once this handle's live event subscription is connected, opening * it if necessary. Await this after attaching `addEventListener` listeners * and before triggering work whose events you intend to observe, so nothing * is missed in the window before the underlying transport connects. In * library mode it resolves immediately — engine events are already live. */ whenConnected(): Promise; } /** * A reference to a recurring schedule that provides convenience methods. * * Mirrors the core {@link ScheduleHandle} surface without leaking the engine * implementation type into the transport-neutral client contract. * * @example * ```ts * import { workflow, Engine, MemoryStorage, LocalClient } from '@lostgradient/weft'; * import type { ClientScheduleHandle } from '@lostgradient/weft/client'; * * await using engine = new Engine({ storage: new MemoryStorage() }); * engine.register(workflow({ name: 'report' }).execute(async function* () { return 'sent'; })); * * const client = new LocalClient(engine); * const handle: ClientScheduleHandle = await client.schedule('report', {}, '0 9 * * 1'); * await handle.pause(); * console.log(handle.id); * ``` */ export interface ClientScheduleHandle extends Disposable { /** The schedule's unique identifier. */ readonly id: string; /** Pause this schedule. */ pause(): Promise; /** Resume this schedule. */ resume(): Promise; /** Cancel this schedule. */ cancel(): Promise; /** Update the schedule cadence and optional mutable schedule settings. */ update(newSpec: string | ScheduleSpec, options?: ScheduleEdit): Promise; /** Read the latest persisted summary for this schedule. */ describe(): Promise; } /** Result of a coordinated update request. */ export type UpdateResult = { updateId: string; result?: unknown; error?: string; } | null; /** * Out-of-band ("async") activity completion surface, shared by every client. * * An activity that called `ActivityContext.completeAsync()` parks its workflow * until an external system resolves it by durable task token. `listPending()` * queries the durable record after a missed live event or process restart. * Library mode calls the engine directly; server mode uses the matching REST * operations. The token is a deterministic identifier, not a secret. * * @example * ```ts * import type { WeftClientActivity } from '@lostgradient/weft/client'; * declare const activity: WeftClientActivity; * void activity.listPending('order-1'); * ``` */ export interface WeftClientActivity { /** List a bounded page of durable activities awaiting completion for one workflow. */ listPending(workflowId: string, options?: PendingAsyncActivityListOptions): Promise; /** Complete a deferred activity by token, resuming its workflow with `result` (optional; omitted/`undefined` resumes with `undefined`). */ complete(token: string, result?: unknown): Promise; /** Fail a deferred activity by token; the error is thrown into its workflow. */ completeExceptionally(token: string, error: unknown): Promise; } /** * Operations shared by both in-process and HTTP clients. * * @example * ```ts * import { workflow, Engine, MemoryStorage, LocalClient, type WeftClient } from '@lostgradient/weft'; * * await using engine = new Engine({ storage: new MemoryStorage() }); * engine.register(workflow({ name: 'my-workflow' }).execute(async function* () { return 42; })); * const client: WeftClient = new LocalClient(engine); * const handle = await client.start('my-workflow', { input: 42 }); * const result = await handle.result(); * console.log(result); // 42 * ``` */ export interface WeftClient { /** * Start a new workflow and return a handle to it. * * When the {@link WorkflowRegistry} is augmented (e.g. via `weft codegen`), * the workflow name narrows `input` to that workflow's input type and the * returned handle's `result()` to its output type. Without augmentation the * permissive string-name overload applies, so the client stays usable with * plain string names and no hard dependency on codegen. * Pass `options.idempotencyKey` for at-most-once starts: a repeated key returns * a handle to the existing run rather than starting a second. Conflicts (a * duplicate `id`, or a key whose run was purged) are transport-dependent: * `LocalClient` throws the typed error (`WorkflowAlreadyExistsError` / * `IdempotencyKeyPurgedError`), while `HttpClient` throws `HttpClientError` * with `status === 409` and `faultCode === 'Conflict'`. */ start(type: TName, input: WorkflowInput, options?: ClientStartOptions): Promise>>; start(type: UnknownNameWhenRegistryEmpty, input: unknown, options?: ClientStartOptions): Promise; /** * Atomically start a workflow or signal it if it already exists * (signal-with-start). An absent target is created and delivered the signal in * one batch; a non-terminal target (running, pending, or suspended) is * signalled; a terminal target is rejected as a conflict unless * `options.onTerminalConflict: 'start-new'` is supplied with an explicit * workflow id and deterministic `signal.signalId`. * * The rejection shape is transport-dependent: `LocalClient` throws the typed * `StartOrSignalConflictError` (and `IdempotencyKeyPurgedError` for a spent * key), while `HttpClient` throws `HttpClientError` with `status === 409` and * `faultCode === 'Conflict'`. Branch on `faultCode`/`status` for code that runs * over either transport. * * Pass `options.idempotencyKey` to dedup independent callers such as retried * webhooks: concurrent same-key callers converge on one workflow and one * delivered signal, with the signal id derived from the key. Convergence needs a * shared workflow identity — `options.idempotencyKey` (id-free) or * `options.id` + `signal.signalId`. A bare `signal.signalId` with neither is an * atomic start-with-one-signal that does NOT converge concurrent callers (each * gets its own run). Supply exactly one of `signal.signalId` or * `options.idempotencyKey`; `options.id` and `options.idempotencyKey` are * mutually exclusive. `options.onTerminalConflict: 'start-new'` is also * mutually exclusive with `options.idempotencyKey`. */ startOrSignal(type: TName, input: WorkflowInput, signal: StartOrSignalSignal, options?: ClientStartOrSignalOptions): Promise>>; startOrSignal(type: UnknownNameWhenRegistryEmpty, input: unknown, signal: StartOrSignalSignal, options?: ClientStartOrSignalOptions): Promise; /** * Register a recurring schedule (cron string or interval spec) and return a * handle to it. * * Like {@link WeftClient.start}, the workflow name narrows `input` to the * registered workflow's input type when the {@link WorkflowRegistry} is * augmented; otherwise the string-name overload applies. */ schedule(type: TName, input: WorkflowInput, spec: string | ScheduleSpec, options?: ScheduleOptions): Promise; schedule(type: UnknownNameWhenRegistryEmpty, input: unknown, spec: string | ScheduleSpec, options?: ScheduleOptions): Promise; /** Get the full persisted state of a workflow, or `null` if not found. */ get(id: string): Promise; /** * Re-attach a {@link ClientHandle} to an existing workflow by id, or `null` * when none exists — handle ergonomics for a run you did not start yourself. * `result()` on an already-terminal run resolves (or rejects) from persisted * state, so a fire-and-forget run can be observed later without hand-rolling * terminal-status polling. Supplying the workflow name as a type argument * (`getHandle<'my-workflow'>(id)`) narrows `result()` to that workflow's output * when the {@link WorkflowRegistry} is augmented; the name is a type hint only. */ getHandle(id: string): Promise; getHandle(id: string): Promise> | null>; /** Get the current summary of a recurring schedule, or `null` if not found. */ getSchedule(id: string): Promise; /** List workflows with optional filtering and pagination. */ list(filter?: TypedListFilter): Promise>; /** List recurring schedules with optional filtering and pagination. */ listSchedules(filter?: ScheduleFilter): Promise>; /** Cancel a running workflow. */ cancel(id: string): Promise; /** * Suspend a running workflow without terminating it. It moves to the * non-terminal `suspended` status, keeps its checkpoint, and is later * resumable via {@link WeftClient.resume}. Inline execution mode only. */ suspend(id: string): Promise; /** Pause a recurring schedule. */ pauseSchedule(id: string): Promise; /** Resume a recurring schedule. */ resumeSchedule(id: string): Promise; /** Cancel a recurring schedule. */ cancelSchedule(id: string): Promise; /** Update a recurring schedule's cadence and optional mutable schedule settings. */ updateSchedule(id: string, newSpec: string | ScheduleSpec, options?: ScheduleEdit): Promise; /** Send a named signal to a workflow. */ signal(id: string, name: SignalDefinition): Promise; signal(id: string, name: SignalDefinition, payload: TInput, options?: SignalDeliveryOptions): Promise; signal(id: string, name: string, payload?: unknown, options?: SignalDeliveryOptions): Promise; /** Query a named read-only accessor on a running workflow. */ query(id: string, name: QueryDefinition): Promise; query(id: string, name: QueryDefinition, input: TInput): Promise; query(id: string, name: string, input?: unknown): Promise; /** Submit a synchronous update to a running workflow. */ update(id: string, name: UpdateDefinition, payload?: void, options?: { timeout?: number; }): Promise; update(id: string, name: UpdateDefinition, payload: TInput, options?: { timeout?: number; }): Promise; update(id: string, name: string, payload?: unknown, options?: { timeout?: number; }): Promise; /** Out-of-band ("async") activity completion by task token. See {@link WeftClientActivity}. */ readonly activity: WeftClientActivity; /** * Re-drive a workflow from its persisted checkpoint. Accepts a workflow that * was explicitly suspended (`suspend(id)`) or one left `'running'` by a prior * process; throws for a status that cannot be resumed (terminal or pending). */ resume(id: string): Promise; /** Recover all interrupted workflows. */ recoverAll(): Promise; /** Force-timeout a workflow. */ timeout(id: string): Promise; /** Get search attributes for a workflow. */ getAttributes(id: string): Promise | null>; /** Set search attributes on a workflow. */ setAttributes(id: string, attributes: Record): Promise; /** Add free-form tags to a workflow. */ addTags(id: string, ...tags: string[]): Promise; /** Remove free-form tags from a workflow. */ removeTags(id: string, ...tags: string[]): Promise; /** Get the event history for a workflow. */ getEvents(id: string): Promise; /** * Open a live, push-based tail of a workflow's events. Async-iterate the * returned {@link WorkflowEventTail} to consume events as they happen. In * server mode this rides the configured per-workflow WebSocket or SSE event * stream (replacing the old 2-second poll); in library mode it bridges the * engine's event stream directly. Both transports deliver the same * {@link WorkflowEvent} records and terminate cleanly on completion or close. */ tail(id: string): WorkflowEventTail; /** * Get the structured execution timeline for a workflow. * Returns `[]` when the workflow is missing or has no retained timeline entries. */ getTimeline(id: string): Promise; /** Reconstruct workflow state at a historical checkpoint step. */ replayTo(id: string, step: number): Promise; /** List human review requests, optionally filtering by status or workflow metadata. */ listReviews(filter?: ReviewListFilter): Promise; /** Submit a decision for a pending review. */ submitReview(reviewId: string, options: SubmitReviewOptions): Promise; /** Read stream chunks back from storage for a completed stream operation. */ getStreamChunks(workflowId: string, key: string, options?: { after?: number; }): Promise; /** Fork a workflow from its latest or a historical checkpoint. */ fork(id: string, options?: ForkOptions): Promise; /** Get the configured workflow retention policies and next sweep time. */ getRetentionOverview(): Promise; /** Purge matching terminal workflows. */ purge(filter?: ListFilter): Promise; /** Cancel all running or pending workflows that match a filter. */ cancelAll(filter: ListFilter): Promise; /** Retry all failed workflows that match a filter. */ retryFailedAll(filter: ListFilter): Promise; /** Signal all running or pending workflows that match a filter. */ signalAll(filter: ListFilter, name: string, payload?: unknown): Promise; /** Delete all matching terminal workflows. */ deleteAll(filter: ListFilter): Promise; /** Add tags to all workflows that match a filter. */ tagAll(filter: ListFilter, tags: string[]): Promise; /** Remove tags from all workflows that match a filter. */ untagAll(filter: ListFilter, tags: string[]): Promise; /** Submit a coordinated update and wait for the result. */ submitCoordinatedUpdate(id: string, name: string, payload?: unknown, options?: { timeout?: number; idempotencyKey?: string; }): Promise; /** Retrieve the result of a previously submitted coordinated update. */ getUpdateResult(updateId: string): Promise; /** * Typed low-level accessor for the full operation catalog. * * Every unary JSON-RPC operation and ordinary schema-shaped REST-only * operation is reachable as `client.operations['weft.']`, including * server operations the ergonomic surface does not curate (workers, task * queues, task diagnostics, system lease/metrics/registry, checkpoints). Binary * and streaming raw-storage routes use {@link storage} instead. New compatible * operations appear here when the snapshot regenerates. * * @example * ```ts * import { workflow, Engine, MemoryStorage, LocalClient } from '@lostgradient/weft'; * * await using engine = new Engine({ storage: new MemoryStorage() }); * engine.register(workflow({ name: 'noop' }).execute(async function* () {})); * const client = new LocalClient(engine); * const metrics = await client.operations['weft.system.metrics']({}); * void metrics; * ``` */ readonly operations: ClientOperations; /** Raw storage administration with byte values and streaming scans. */ readonly storage: WeftClientStorage; /** * Invoke a single catalog operation by name, with its input and output typed * from the generated catalog. Equivalent to `client.operations[name](input)` * but ergonomic when the operation name is known dynamically. */ call(name: Name, input: ClientOperationTypes[Name]['input']): Promise; }