/** * The main API client for interacting with the Deepline platform. * * `DeeplineClient` is the low-level workhorse — it maps 1:1 to the REST API * and handles authentication, retries, and response parsing. For a higher-level * interface with named play handles and tool shortcuts, use {@link Deepline.connect} * or {@link DeeplineContext} instead. * * ## Quick start * * ```typescript * import { DeeplineClient } from 'deepline'; * * const client = new DeeplineClient(); * * // List available tools * const tools = await client.listTools(); * * // Execute a tool * const result = await client.executeTool('test_company_search', { domain: 'stripe.com' }); * * // Run a play end-to-end * const playResult = await client.runPlay(bundledCode, null, 'my-play', { * onProgress: (status) => console.log(status.progress?.logs), * }); * ``` * * ## Configuration * * All options are optional — the client resolves API keys and URLs automatically * from environment variables and CLI config files. See {@link resolveConfig} for * the full resolution order. * * @module */ import { resolveConfig } from './config.js'; import { DeeplineError } from './errors.js'; import { HttpClient } from './http.js'; import { PRODUCT_NOTIFICATION_SLACK_OAUTH_SCOPES } from '../../shared_libs/product-notifications/contract.js'; import { STREAM_HEALTHY_CONNECTION_MS, isTransientPlayStreamError, streamReconnectDelayMs, } from './stream-reconnect.js'; import { observeRunEvents, RunObserveTransportUnavailableError, } from './runs/observe-transport.js'; import type { DeeplineClientOptions, ResolvedConfig, PlayRevisionSummary, PlayRunListItem, PlayDetail, PlayCheckResult, PlayRunResult, PlayRunStart, PlayStatus, PlayRunPackage, PlayLiveEvent, PlayListItem, ProductNotificationSettings, PlayDescription, StopPlayRunResult, StopAllPlayRunsResult, ClearPlayHistoryRequest, ClearPlayHistoryResult, PublishPlayVersionRequest, PublishPlayVersionResult, StartPlayRunRequest, DeletePlayResult, RestorePlayResult, SharePageStatus, PublishSharePageRequest, UpdateSharePageRequest, ToolDefinition, ProviderDefinition, ToolSearchOptions, ToolSearchResult, ToolMetadata, CustomerDbQueryResult, DeeplineAgentModelDescription, InferenceQuote, } from './types.js'; import type { MonitorDefinition } from './monitors.js'; import type { PlayStagedFileRef } from './plays/local-file-discovery.js'; import type { PlayCompilerManifest } from '../../shared_libs/plays/compiler-manifest.js'; import type { EnrichCompiledConfig } from './cli/enrich-play-compiler.js'; import { RUNTIME_ENVIRONMENT_TOKEN_HEADER } from '../../shared_libs/play-runtime/coordinator-headers.js'; import { resolveTheirstackClientTimeoutMs } from '../../shared_libs/integrations/theirstack-execution-policy.js'; import { BETTERCONTACT_LAUNCHER_CLIENT_TIMEOUT_MS, usesExtendedBetterContactLauncherBudget, } from '../../shared_libs/integrations/bettercontact-execution-policy.js'; import { normalizePlayRuntimeEnvironment, normalizePlayRuntimeNamespace, normalizePlayRuntimeSelection, type PlayRuntimeSelection, } from '../../shared_libs/play-runtime/runtime-environment.js'; import { TOOL_EXECUTION_ERROR_SCHEMA_HEADER, TOOL_EXECUTION_ERROR_SCHEMA_VERSION, } from '../../shared_libs/tool-execution-error.js'; import { decodePlayRunPublicStatus } from '../../shared_libs/play-runtime/run-lifecycle-policy.js'; import { legacyRawFromToolResponseRawV2, providerMetaFromToolResponseRawV2, RAW_V2_TOOL_RESPONSE_CONTRACT, } from '../../shared_libs/play-runtime/tool-response-contract.js'; export type SlackNotificationTarget = | { channel: string; memberId?: never } | { channel?: never; memberId: string }; export type CreateNotificationInput = { name: string; provider: 'slack'; eventTypes: string[]; } & SlackNotificationTarget; export type UpdateNotificationInput = | { enabled: boolean } | ({ name: string; eventTypes: string[] } & SlackNotificationTarget); const TERMINAL_PLAY_STATUSES = new Set(['completed', 'failed', 'cancelled']); const INCLUDE_TOOL_METADATA_HEADER = 'x-deepline-include-tool-metadata'; const EXECUTE_RESPONSE_CONTRACT_HEADER = 'x-deepline-execute-response-contract'; const EXECUTE_RESPONSE_INTENT_HEADER = 'x-deepline-execute-response-intent'; const RAW_V2_EXECUTE_RESPONSE_CONTRACT = RAW_V2_TOOL_RESPONSE_CONTRACT; const COMPILE_MANIFEST_RETRY_DELAYS_MS = [250, 1_000]; const REGISTER_PLAY_ARTIFACTS_COMPILE_CONCURRENCY = 3; const REGISTER_PLAY_ARTIFACTS_MAX_BATCH_COUNT = 3; const REGISTER_PLAY_ARTIFACTS_MAX_BATCH_BYTES = 2_500_000; const DEEPLINEAGENT_EXECUTE_TIMEOUT_MS = 15 * 60 * 1000; export type IngestionStorageRepairResult = { status?: 'repaired'; connection_grants: { runtime_role: string; customer_db_role: string; }; repaired: unknown[]; count: number; repair?: { location: { changed: boolean }; storage_contract: { applied: boolean; current: boolean }; runtime_migration: { current: boolean }; runtime_capabilities: { before: { reachable: boolean; status: string; storage_contract_current: boolean; runtime_migration_current: boolean; runtime_capabilities_ready: boolean; capability_failures: string[]; }; after: { reachable: boolean; status: string; storage_contract_current: boolean; runtime_migration_current: boolean; runtime_capabilities_ready: boolean; capability_failures: string[]; }; runtime_role: { currentRole: string | null; dlMetaSchemaUsage: boolean; uuidFunctionExecute: boolean; enrichmentsSchemaUsage: boolean; enrichmentsWriteReady: boolean; storageSchemaUsage: boolean; storageSchemaCreate: boolean; }; customer_db_role: { currentRole: string | null; dlMetaSchemaUsage: boolean; uuidFunctionExecute: boolean; enrichmentsSchemaUsage: boolean; enrichmentsWriteReady: boolean; storageSchemaUsage: boolean; storageSchemaCreate: boolean; }; }; internal_relations: { reconciled: boolean }; materialized_tables: { repaired_count: number }; }; }; function normalizePlayRunIntegrationMode( value: unknown, ): 'live' | 'eval_stub' | 'fixture' | undefined { if (value === 'live' || value === 'eval_stub' || value === 'fixture') { return value; } return undefined; } function resolvePlayRunIntegrationMode( request: StartPlayRunRequest, ): 'live' | 'eval_stub' | 'fixture' | undefined { return normalizePlayRunIntegrationMode( request.integrationMode ?? process.env.DEEPLINE_EVAL_INTEGRATION_MODE, ); } function resolvePlayRunRuntimeSelection( request: StartPlayRunRequest, ): PlayRuntimeSelection | undefined { if (request.runtime !== undefined) { const runtime = normalizePlayRuntimeSelection(request.runtime); if (!runtime) { throw new DeeplineError( 'runtime must be { environment: "preview", namespace, backend?: "daytona" | "modal" } with namespace matching ^[a-z][a-z0-9-]{0,30}$.', undefined, 'INVALID_RUNTIME_SELECTION', ); } return runtime; } // These variables select where the app executes a run, not which app the // SDK calls. DEEPLINE_API_BASE_URL still chooses the app host. Environment // opts into remote preview routing, namespace names the isolated lane, and // the token only authorizes that selection. Omitting all three leaves the // app free to use its native runtime (local for a local app, prod for prod). const configured = process.env.DEEPLINE_RUNTIME_ENVIRONMENT?.trim(); const configuredNamespace = process.env.DEEPLINE_RUNTIME_NAMESPACE?.trim(); const configuredToken = process.env.DEEPLINE_RUNTIME_ENVIRONMENT_TOKEN?.trim(); if (!configured) { if (configuredNamespace || configuredToken) { // Name the variables that are actually set. This branch is almost always // hit by a stale value auto-loaded from a .env/.env.local rather than by // a deliberate export, so the message has to say where to look and how // to run the command anyway. const present: string[] = []; if (configuredNamespace) present.push('DEEPLINE_RUNTIME_NAMESPACE'); if (configuredToken) present.push('DEEPLINE_RUNTIME_ENVIRONMENT_TOKEN'); const isPlural = present.length > 1; throw new DeeplineError( [ `Incomplete play runtime selection: ${present.join(' and ')} ${isPlural ? 'are' : 'is'} set but DEEPLINE_RUNTIME_ENVIRONMENT is not.`, 'Preview routing requires DEEPLINE_RUNTIME_ENVIRONMENT=preview together with DEEPLINE_RUNTIME_NAMESPACE; the token alone authorizes nothing.', `If you did not export ${isPlural ? 'these' : 'this'} yourself, ${isPlural ? 'they were' : 'it was'} most likely auto-loaded from a .env or .env.local in the current directory (bun loads those automatically, and in a git worktree .env.local is usually a symlink to the main checkout).`, `To run against the app-native runtime, clear ${isPlural ? 'them' : 'it'} for this command: ${present.map((name) => `${name}=`).join(' ')} `, ].join('\n'), undefined, 'INVALID_RUNTIME_ENVIRONMENT', ); } return undefined; } const environment = normalizePlayRuntimeEnvironment(configured); if (!environment) { throw new DeeplineError( `DEEPLINE_RUNTIME_ENVIRONMENT supports only explicit preview selection. Received "${configured}". Omit it to use the app-native runtime.`, undefined, 'INVALID_RUNTIME_ENVIRONMENT', ); } const namespace = normalizePlayRuntimeNamespace(configuredNamespace); if (!namespace) { throw new DeeplineError( 'DEEPLINE_RUNTIME_NAMESPACE is required for preview and must match ^[a-z][a-z0-9-]{0,30}$.', undefined, 'INVALID_RUNTIME_NAMESPACE', ); } const configuredBackend = process.env.DEEPLINE_PLAY_RUNNER_BACKEND?.trim(); if (!configuredBackend) { return { environment, namespace }; } if (configuredBackend !== 'daytona' && configuredBackend !== 'modal') { throw new DeeplineError( `DEEPLINE_PLAY_RUNNER_BACKEND must be daytona or modal for preview runtime selection. Received "${configuredBackend}".`, undefined, 'INVALID_RUNTIME_BACKEND', ); } return { environment, namespace, backend: configuredBackend }; } function runtimeSelectionHeaders( runtime: PlayRuntimeSelection | undefined, ): Record | undefined { if (!runtime) return undefined; // Keep the credential out of the typed runtime payload. The app consumes it // at the HTTP boundary and never forwards it to Fly, Absurd, or Daytona. const token = process.env.DEEPLINE_RUNTIME_ENVIRONMENT_TOKEN?.trim(); if (!token) { throw new DeeplineError( 'DEEPLINE_RUNTIME_ENVIRONMENT_TOKEN is required for explicit preview runtime selection.', undefined, 'RUNTIME_ENVIRONMENT_TOKEN_REQUIRED', ); } return { [RUNTIME_ENVIRONMENT_TOKEN_HEADER]: token }; } function normalizeTestPolicyOverrides( value: unknown, source: string, ): Record { if (value && typeof value === 'object' && !Array.isArray(value)) { return value as Record; } throw new DeeplineError( `${source} must be a JSON object.`, undefined, 'INVALID_TEST_POLICY_OVERRIDES', ); } function parseEnvTestPolicyOverrides(): Record | undefined { const raw = typeof process !== 'undefined' ? process.env?.DEEPLINE_TEST_POLICY_OVERRIDES : undefined; const trimmed = raw?.trim(); if (!trimmed) return undefined; let parsed: unknown; try { parsed = JSON.parse(trimmed); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ''; throw new DeeplineError( `DEEPLINE_TEST_POLICY_OVERRIDES must be valid JSON.${detail}`, undefined, 'INVALID_TEST_POLICY_OVERRIDES', ); } return normalizeTestPolicyOverrides(parsed, 'DEEPLINE_TEST_POLICY_OVERRIDES'); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function isTransientCompileManifestError(error: unknown): boolean { if (error instanceof DeeplineError && typeof error.statusCode === 'number') { return ( error.statusCode === 408 || error.statusCode === 425 || error.statusCode === 499 || (error.statusCode >= 500 && error.statusCode < 600) ); } const message = error instanceof Error ? error.message : String(error); return /fetch failed|connection (?:closed|reset|terminated)|socket hang up|econnreset|etimedout|eai_again|abort/i.test( message, ); } function requireCompileManifestResponse( response: { compilerManifest?: PlayCompilerManifest } | null | undefined, playName: string, ): PlayCompilerManifest { const compilerManifest = response?.compilerManifest; if ( !compilerManifest || typeof compilerManifest !== 'object' || Array.isArray(compilerManifest) ) { throw new DeeplineError( `Compile manifest response did not include compilerManifest for ${playName}.`, 502, 'API_RESPONSE_INVALID', { response: response ?? null }, ); } return compilerManifest; } async function mapWithConcurrency( items: T[], concurrency: number, mapper: (item: T, index: number) => Promise, ): Promise { const results = new Array(items.length); let nextIndex = 0; const workerCount = Math.min(Math.max(1, concurrency), items.length); await Promise.all( Array.from({ length: workerCount }, async () => { for (;;) { const index = nextIndex; nextIndex += 1; if (index >= items.length) { return; } results[index] = await mapper(items[index]!, index); } }), ); return results; } function jsonUtf8Bytes(value: unknown): number { return new TextEncoder().encode(JSON.stringify(value)).length; } function chunkRegisterPlayArtifacts(artifacts: T[]): T[][] { const chunks: T[][] = []; let current: T[] = []; for (const artifact of artifacts) { const candidate = [...current, artifact]; const candidateTooLarge = candidate.length > REGISTER_PLAY_ARTIFACTS_MAX_BATCH_COUNT || jsonUtf8Bytes({ artifacts: candidate }) > REGISTER_PLAY_ARTIFACTS_MAX_BATCH_BYTES; if (current.length > 0 && candidateTooLarge) { chunks.push(current); current = [artifact]; } else { current = candidate; } } if (current.length > 0) { chunks.push(current); } return chunks; } type ExecuteToolRawOptions = { includeToolMetadata?: boolean; responseIntent?: 'dataset' | 'raw' | 'row_artifact'; metadata?: Record; timeout?: number; maxRetries?: number; }; /** * The server launches the Apify run, polls it for `payload.timeoutMs`, then * reads its dataset before replying. Keep the client request alive through * that final hand-off so a pollable run id is never lost to the SDK default * timeout. */ const APIFY_SYNC_DEFAULT_TIMEOUT_MS = 300_000; const APIFY_SYNC_RESPONSE_GRACE_MS = 90_000; function resolveToolExecuteTimeoutMs( toolId: string, input: Record, ): number | undefined { const normalized = toolId.trim().toLowerCase(); if (normalized === 'apify_run_actor_sync') { const requestedTimeoutMs = input.timeoutMs ?? APIFY_SYNC_DEFAULT_TIMEOUT_MS; if ( typeof requestedTimeoutMs === 'number' && Number.isFinite(requestedTimeoutMs) && requestedTimeoutMs > 0 ) { return Math.floor(requestedTimeoutMs) + APIFY_SYNC_RESPONSE_GRACE_MS; } } // Provider-specific slow-request policies live in shared modules so the SDK // and server classify the same payloads. If more providers need this, replace // these individual checks with a shared policy registry, not a wider default. const theirstackTimeoutMs = resolveTheirstackClientTimeoutMs( normalized, input, ); if (theirstackTimeoutMs !== null) return theirstackTimeoutMs; if (usesExtendedBetterContactLauncherBudget(normalized)) { return BETTERCONTACT_LAUNCHER_CLIENT_TIMEOUT_MS; } return normalized === 'deeplineagent' || normalized === 'deeplineagent_deeplineagent' || normalized === 'ai_inference' || normalized === 'deeplineagent_ai_inference' || normalized === 'aiinference' ? DEEPLINEAGENT_EXECUTE_TIMEOUT_MS : undefined; } /** * Standard provider/tool execution envelope returned by low-level SDK calls. * * `toolResponse.rawV2` contains the complete scrubbed provider response; * `toolResponse.raw` is derived locally as the legacy provider-result projection. `extractedValues` and * `extractedLists` contain Deepline-normalized getters when the tool exposes * them. Billing fields are Deepline-facing and must not expose provider spend. */ export type ToolExecution> = { status: string; job_id?: string; meta?: Record; toolResponse: { raw: TData; rawV2?: unknown; view?: 'data' | 'rawV2'; meta?: TMeta; responseMeta?: TMeta; }; extractedLists?: Record; extractedValues?: Record; billing?: Record; [key: string]: unknown; }; /** Filters for `client.runs.list(...)`. */ export type RunsListOptions = { play?: string; status?: string; limit?: number; /** Zero-based page offset. Requires `play` because status-only inventory is not paginated. */ offset?: number; }; /** Options for `client.runs.get(...)`. */ export type RunsGetOptions = { /** Return the raw status payload instead of the compact package. */ full?: boolean; /** Attach a bounded end-of-stream window for a failed run. */ failedLogs?: boolean; /** Requested failed-log lines to attach (default 20, hard-capped at 20). */ failedLogLimit?: number; }; const RUNS_FAILED_LOG_LIMIT = 20; /** Streaming options for `client.runs.tail(...)`. */ export type RunsTailOptions = { signal?: AbortSignal; /** Observe each canonical live event while `tail` waits for terminal state. */ onEvent?: (event: PlayLiveEvent) => void; /** * Called before each stream reconnect. Server stream windows are finite, so * long runs reconnect with backoff until a terminal status is observed. */ onReconnect?: (info: { attempt: number; delayMs: number; reason: string; }) => void; /** * Display-only transport notices: subscription-transport reconnects, * staleness warnings, and the one-time fallback notice when the server * cannot serve the Convex subscription transport (ADR-0008). */ onNotice?: (message: string) => void; }; /** Log fetch options for `client.runs.logs(...)`. */ export type RunsLogsOptions = { /** Return the LAST `limit` stored log lines (default 200). */ limit?: number; /** Fetch every stored log line, paginating to the full totalCount. */ all?: boolean; /** Select the bounded failure view; truncated runs degrade explicitly. */ failed?: boolean; }; /** Persisted log response for one play run. */ export type RunsLogsResult = { runId: string; totalCount: number; returnedCount: number; firstSequence: number | null; lastSequence: number | null; truncated: boolean; hasMore: boolean; entries: string[]; /** Selected public log view. */ view?: 'tail' | 'failed' | 'all'; /** Whether entries are terminal context or the last pre-truncation lines. */ association?: 'terminal_failure_window' | 'retained_before_truncation'; /** Loud explanation when the terminal failure window was not retained. */ warning?: string; /** Exact follow-up command when the selected view is degraded. */ next?: { logs: string }; /** * True when the run crossed the Run Log Stream retention cap: `totalCount` * keeps counting, but stored line bodies end at a loud truncation marker. */ logsTruncated?: boolean; }; /** Server page cap for GET /api/v2/runs/:runId/logs (ADR-0009). */ const RUN_LOGS_PAGE_LIMIT = 1_000; /** Wire shape of one GET /api/v2/runs/:runId/logs page. */ type RunLogsPageResponse = { runId: string; totalLogCount: number; logsTruncated: boolean; lastStoredSeq: number; afterSeq: number; entries: Array<{ seq: number; line: string }>; firstSeq: number | null; lastSeq: number | null; hasMore: boolean; nextAfterSeq: number | null; view?: 'failed'; association?: 'terminal_failure_window' | 'retained_before_truncation'; warning?: string; next?: { logs: string }; }; /** One persisted runtime-sheet row returned by `client.runs.exportDatasetRows(...)`. */ export type PlaySheetRow = { key?: string; status?: string; data?: Record; [key: string]: unknown; }; /** Runtime-sheet rows and aggregate progress for one dataset/table namespace. */ export type PlaySheetRowsResult = { rows: PlaySheetRow[]; scope?: { kind: 'database' } | { kind: 'run'; runId: string }; summary?: { stats?: { total?: number; queued?: number; running?: number; completed?: number; failed?: number; [key: string]: unknown; }; columns?: Record; [key: string]: unknown; }; customerDbUrl?: string; deltaCursor?: number; }; export type PlaySecretMetadata = { _id: string; orgId: string; scope: 'org' | 'play'; playName?: string; name: string; status: string; hasValue: boolean; createdAt: number; updatedAt: number; lastUsedAt?: number; }; /** * Public runs namespace exposed as `client.runs`. * * This namespace mirrors the canonical `/api/v2/runs` resource family and is * the preferred low-level surface for polling, streaming, stopping, reading * logs, and exporting durable dataset rows. * * @sdkReference client 020 client.runs */ export type RunsNamespace = { /** Get current run status by public run id. */ get: (runId: string, options?: RunsGetOptions) => Promise; /** Explicitly read the retained original input (may include customer data). */ input: (runId: string) => Promise<{ runId: string; input: Record | unknown[]; bytes: number; sha256: string | null; replayedFromRunId: string | null; }>; /** Start a fresh run from a prior run's retained input and pinned revision. */ rerun: (runId: string) => Promise<{ runId: string; replayedFromRunId: string; revisionId: string | null; status: string; next: { inspect: string; input: string }; }>; /** List runs for one play, optionally filtered by status. */ list: (options: RunsListOptions) => Promise; /** Stream run events and return the latest/terminal run status. */ tail: (runId: string, options?: RunsTailOptions) => Promise; /** Fetch persisted log lines for a run. */ logs: (runId: string, options?: RunsLogsOptions) => Promise; /** Export persisted rows for a runtime-sheet dataset/table namespace. */ exportDatasetRows: (input: { playName: string; tableNamespace: string; runId?: string; limit?: number; offset?: number; rowMode?: 'output' | 'all'; }) => Promise; /** Stop a running/waiting run. */ stop: ( runId: string, options?: { reason?: string }, ) => Promise; /** Stop active runs across the current workspace. */ stopAll: (options?: { reason?: string }) => Promise; }; /** Current mutable customer database namespace exposed as `client.db`. */ export type DbNamespace = { /** Run a bounded SQL query against the current customer data plane. */ query: (input: { sql: string; maxRows?: number; }) => Promise; }; /** * Whether the current workspace can use Deepline Monitors, from * `GET /api/v2/monitors/access`. Reachable without monitor access (requires * only an authenticated session/API key); a denial is a normal 200 body, not a * 403. */ export type MonitorsAccessStatus = { has_access: boolean; reason?: string; }; /** * The monitor tools catalog (deployable monitor TYPES), from * `GET /api/v2/monitors/tools`. Server-owned shape: describing one tool returns * the full payload/stream contract, while list mode returns the compact * inventory. Kept as an open record so the SDK does not have to bump when the * server enriches the catalog. */ export type MonitorsAvailableResult = { tools?: Array>; total?: number; returned?: number; is_truncated?: boolean; [key: string]: unknown; }; /** Options for `client.monitors.available(...)` (list or describe mode). */ export type MonitorsAvailableOptions = { provider?: string; search?: string; limit?: number | string; /** * Request the full catalog contract in LIST mode (payload schemas + streams). * List mode is compact by default; ignored when describing a single tool. */ full?: boolean; /** Ask the server for high-signal fields only. */ compact?: boolean; }; /** One deployed monitor row returned by `client.monitors.list(...)`. */ export type MonitorListEntry = { key?: string; monitor_key?: string; status?: string; tool?: string; name?: string; configured?: boolean; active?: boolean; provider?: string; output_table?: string | null; webhook_state?: string; last_received_event?: string | null; bound_plays?: Array>; consumer_health_truncated?: boolean; [key: string]: unknown; }; /** * Deployed-monitor registry page from `GET /api/v2/monitors/deployed`. `total` * is the TRUE registry count for the status filter, not this page's size; page * past a truncated result with `next_cursor`. */ export type MonitorsListResult = { monitors?: MonitorListEntry[]; total?: number; returned?: number; is_truncated?: boolean; next_cursor?: string | null; status_filter_applied?: string; status_summary?: Array<{ status: string; count: number }>; include_consumers?: boolean; [key: string]: unknown; }; /** Options for `client.monitors.list(...)`. */ export type MonitorsListOptions = { /** active (default), disabled, or all. */ status?: string; limit?: number | string; /** Page past a truncated result using a prior response's `next_cursor`. */ cursor?: string; compact?: boolean; /** Include bounded current SQL-listener delivery/run health (requires limit <= 20). */ includeConsumers?: boolean; }; /** * Server-owned monitor payload shapes returned by the deploy/check/get/mutation * endpoints. Kept as open records because the render layer and the endpoints * own the exact field set (output contracts, pricing, reuse candidates, delete * plans, dependents) and it must not require an SDK bump to evolve. */ export type MonitorCheckResult = Record; export type MonitorDeployResult = Record; /** Field-level monitor capability spec returned by `client.monitors.get(...)`. */ export type MonitorSpec = { /** Whether Deepline has a current capability spec for this deployed monitor. */ available?: boolean; tool?: string; description?: string; fields?: Array>; conditional_requirements?: Array>; /** Present when a legacy monitor has no current capability spec. */ message?: string; }; /** One deployed-monitor read. The server may add monitor-specific fields. */ export type MonitorDetail = Record & { monitor_spec?: MonitorSpec; }; export type MonitorDependents = Record; export type MonitorUpdateChangeSummary = { definition: { changed: Array<{ path: string; before: unknown; after: unknown }>; unchanged: Array<{ path: string; value: unknown }>; }; storage: { existing_rows_preserved: true; unchanged_destinations: Array<{ output: string; table: string; row_type: string | null; }>; changed_destinations: Array<{ output: string; before_table: string | null; after_table: string | null; }>; }; upstream: { resource_replaced: boolean; strategy: | 'unchanged' | 'create_then_delete_previous' | 'deferred_until_reactivation'; }; }; export type MonitorUpdateResult = { change_summary?: MonitorUpdateChangeSummary; [key: string]: unknown; }; export type MonitorDeleteResult = Record; export type MonitorReactivateResult = Record; export type MonitorTestResult = Record; export type MonitorValidateResult = Record; /** * Public monitors namespace exposed as `client.monitors`. * * Mirrors the /api/v2/monitors resource family so the monitors CLI and * programmatic callers share one product surface — every `deepline monitors` * verb maps to a method here. Monitors are fully expressible as SDK code: author * a definition with {@link defineMonitor}, then check/deploy/list/get/update/ * delete/reactivate through this namespace. * * @sdkReference client 040 client.monitors */ export type MonitorsNamespace = { /** Whether the current workspace can use monitors (`{ has_access, reason }`). */ status: () => Promise; /** * The deployable monitor tools catalog. Call with no tool id for the list, or * with a tool id (positional or `{ tool }`) to describe one tool's full * payload/stream contract. */ available: ( toolIdOrOptions?: string | (MonitorsAvailableOptions & { tool?: string }), options?: MonitorsAvailableOptions, ) => Promise; /** Validate a monitor definition without deploying it (no spend). */ check: (definition: MonitorDefinition) => Promise; /** Deploy a monitor from a definition. May spend Deepline credits. */ deploy: ( definition: MonitorDefinition, options?: { dryRun?: boolean }, ) => Promise; /** List deployed monitors (active by default). `includeConsumers` requires a limit of 20 or fewer. */ list: (options?: MonitorsListOptions) => Promise; /** Fetch one deployed monitor by public key with bounded current listener health. */ get: (key: string) => Promise; /** * Test a deployed monitor. `validationOnly` safely verifies the callback * envelope; omitted options preserve the historic full-ingestion behavior. */ test: ( key: string, payload: Record, options?: { validationOnly?: boolean; dispatch?: boolean }, ) => Promise; validate: (key: string) => Promise; /** List the published plays depending on one monitor's output streams. */ dependents: (key: string) => Promise; /** Update a deployed monitor by public key. */ update: ( key: string, patch: Record, ) => Promise; /** Delete a deployed monitor and its upstream provider resource. `dryRun` returns the delete plan. */ delete: ( key: string, options?: { dryRun?: boolean }, ) => Promise; /** Reactivate a disabled monitor. `dryRun` returns the reactivation cost. */ reactivate: ( key: string, options?: { dryRun?: boolean }, ) => Promise; }; /** One credit grant pool reported by the billing subscription status endpoint. */ export type BillingCreditPool = { pool: string; credits_remaining: number; credits_granted: number; source: string; cycle_key: string | null; effective_at: string; }; /** * Subscription state for the active workspace, from * `GET /api/v2/billing/subscription/status`. All amounts are Deepline credits * and Deepline-facing USD — never provider spend. */ export type BillingSubscriptionStatus = { org_id: string; plan_version_id: string; plan_name: string; plan_family_id: string; /** Whether a Stripe subscription backs the active plan. */ subscribed: boolean; price_usd: number | null; price_interval: string | null; monthly_grant_credits: number; assigned_by: string; assigned_at: string | null; credit_pools: BillingCreditPool[]; pooled_credits_remaining: number; /** End of the current paid period (ISO timestamp), when Stripe is reachable. */ current_period_end: string | null; /** True when a cancellation is already scheduled for period end. */ cancel_at_period_end: boolean | null; stripe_status: string | null; }; /** * Result of `POST /api/v2/billing/subscription/cancel`. Cancellation is always * at period end: the customer keeps the cycle they paid for and every * remaining credit. `undo_cancel` reverses a pending cancellation. */ export type BillingSubscriptionCancelResult = { org_id: string; subscription_id: string; cancel_at_period_end: boolean; current_period_end: string | null; status: string; message: string; }; /** * One customer-facing billing history entry from * `GET /api/v2/billing/invoices`: a subscription invoice or a one-time credit * purchase receipt. Amounts are what the customer paid — never provider spend. */ export type BillingInvoiceEntry = { kind: 'invoice' | 'receipt'; id: string; created_at: string; description: string; amount_cents: number; currency: string; status: string; /** Stripe-hosted page (invoice or card receipt). */ url: string | null; /** Direct PDF when Stripe provides one (invoices only). */ pdf_url: string | null; /** Stripe-hosted invoice page when this purchase has an invoice. */ invoice_url?: string | null; /** Stripe-hosted card receipt when this purchase has one. */ receipt_url?: string | null; }; export type BillingInvoicesResult = { org_id: string; entries: BillingInvoiceEntry[]; }; export type TargetBillingOperation = { id: string; state: string; version: number; next_action?: string | null; }; export type TargetBillingPlan = { sku: 'payg-v1' | 'builder-v1' | 'team-v1'; name: string; recurring_price_usd: number; interval: 'month'; included_credits: number; purchased_credit_price_usd: number; }; export type TargetBillingPlansResult = { org_id: string; plans: TargetBillingPlan[]; current_plan_sku: string; pending_plan_sku: string | null; acquisition_enabled: boolean; }; export type TargetBillingStatusResult = { org_id: string; plan: { sku: string; name: string; recurring_price_usd: number | null; interval: string | null; included_credits: number | null; }; state: string; changes_allowed: boolean; legacy_changes_allowed?: boolean; payment_state: string; recharge_state: string; next_action: string | null; pending_plan_sku: string | null; as_of: string | null; }; export type TargetBillingMutationResult = { data: Record; operation: TargetBillingOperation; request_id?: string; }; export type TargetBillingPlanTransitionOptions = | { action: 'start_or_change'; targetPlanSku: 'payg-v1' | 'builder-v1' | 'team-v1'; idempotencyKey: string; } | { action: 'cancel' | 'undo_cancel'; targetPlanSku?: never; idempotencyKey: string; }; /** Saved payment method details returned by one-click billing top-up. */ export type BillingPaymentMethodSummary = { brand?: string | null; last4?: string | null; exp_month?: number | null; exp_year?: number | null; label?: string | null; }; /** * Result of `POST /api/v2/billing/top-up`: a saved-card charge and purchased * Deepline credit grant for the active workspace. */ export type BillingTopUpResult = { ok: true; credits: number; amount_cents: number; currency: string; balance: number; stripe_payment_intent_id: string; payment_method?: BillingPaymentMethodSummary | null; }; /** One published plan from `GET /api/v2/billing/catalog/current`. */ export type BillingPlanEntry = { plan_family_id: string; plan_version_id: string; public_name: string; price_usd: number | null; price_interval: string | null; monthly_grant_credits: number; rollover: { mode: string; max_credits?: number }; /** Whether the plan can be purchased via subscription checkout. */ acquirable: boolean; }; /** One usage metric published in the billing catalog. */ export type BillingMeterEntry = { id: string; name: string; }; /** @deprecated Use BillingMeterEntry. Retained for wire/API alias compatibility. */ export type BillingMetricEntry = BillingMeterEntry; /** The caller's active plan as reported by the plans endpoint. */ export type BillingActivePlan = { plan_family_id: string; plan_version_id: string; public_name: string; rate_card_id: string; assigned_by: string; assigned_at: string | null; has_subscription: boolean; }; /** * Published plans plus the caller's active plan, from * `GET /api/v2/billing/catalog/current`. Answers "what plans exist and what * am I on". All amounts are Deepline credits and Deepline-facing USD — never * provider spend. */ export type BillingPlansResult = { catalog_id: string; catalog_version: string; active_plan: BillingActivePlan; plans: BillingPlanEntry[]; meters: BillingMeterEntry[]; /** @deprecated Use meters. Retained for installed SDK compatibility. */ metrics: BillingMetricEntry[]; }; /** * Public billing namespace exposed as `client.billing`. * * Carries plans, subscription state, cancellation, and invoice/receipt history * so CLI commands and programmatic callers share one surface. * * @sdkReference client 030 client.billing */ export type BillingNamespace = { /** Charge the saved payment method and add Deepline credits to the active workspace. */ topUp: (options: { credits: number; idempotencyKey?: string; }) => Promise; /** Published plans plus the plan you are on ("what plans exist and what am I on"). */ plans: () => Promise; subscription: { /** Active plan, Stripe subscription state, and remaining credit pools. */ status: () => Promise; /** * Schedule cancellation at period end (credits are kept), or reverse a * pending cancellation with `{ undo: true }`. */ cancel: (options?: { undo?: boolean; }) => Promise; }; invoices: { /** Subscription invoices plus credit purchase receipts, newest first. */ list: (options?: { limit?: number }) => Promise; }; /** Metronome-authored target catalog and current Contract projection. */ targetPlans: () => Promise; /** Normalized target billing state. */ targetStatus: () => Promise; /** Buy Deepline credits through a payment-gated Metronome commit. */ purchaseCredits: (options: { credits: number; idempotencyKey: string; }) => Promise; /** Start, change, cancel, or undo a target plan transition. */ transitionPlan: ( options: TargetBillingPlanTransitionOptions, ) => Promise; /** Create a Stripe-hosted billing Portal session. */ portalSession: () => Promise<{ url: string }>; }; function requireTargetBillingIdempotencyKey(value: string): string { const normalized = value.trim(); if ( normalized.length === 0 || normalized.length > 200 || normalized !== value ) { throw new DeeplineError( 'Billing idempotencyKey must contain 1–200 characters with no leading or trailing whitespace.', undefined, 'INVALID_BILLING_IDEMPOTENCY_KEY', ); } return normalized; } function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } /** Materialize raw-v2's rawV2-only wire response into the stable SDK result view. */ function materializeToolExecutionResponse< TData = unknown, TMeta = Record, >(response: ToolExecution): ToolExecution { const toolResponse = response.toolResponse; // An older V2 backend does not understand raw-v2 yet and therefore falls // through to the historical `{ result: { data, meta? } }` response. New SDK // clients must keep working during that server rollout window. if (!toolResponse && isRecord(response.result)) { const legacyResult = response.result; if (Object.prototype.hasOwnProperty.call(legacyResult, 'data')) { const legacyMeta = isRecord(legacyResult.meta) ? (legacyResult.meta as TMeta) : undefined; return { ...response, toolResponse: { raw: legacyResult.data as TData, ...(legacyMeta ? { meta: legacyMeta } : {}), }, }; } } if ( !toolResponse || Object.prototype.hasOwnProperty.call(toolResponse, 'raw') ) { return response; } const rawV2 = toolResponse.rawV2; const view = toolResponse.view; const providerMeta = providerMetaFromToolResponseRawV2( rawV2, view ?? 'rawV2', ) as TMeta | undefined; const responseMeta = isRecord(toolResponse.responseMeta) ? (toolResponse.responseMeta as TMeta) : undefined; return { ...response, toolResponse: { ...toolResponse, raw: legacyRawFromToolResponseRawV2( rawV2, view ?? 'rawV2', responseMeta as Record | undefined, ) as TData, ...(toolResponse.meta || providerMeta || responseMeta ? { meta: { ...(toolResponse.meta ?? {}), ...(providerMeta ?? {}), ...(responseMeta ?? {}), } as TMeta, } : {}), }, }; } function isPrebuiltPlayDescription( play: Pick, ): boolean { return play.origin === 'prebuilt' || play.ownerType === 'deepline'; } function preferPrebuiltPlayDescriptions( plays: T[], ): T[] { return plays .map((play, index) => ({ play, index })) .sort( (left, right) => Number(right.play.pinned) - Number(left.play.pinned) || Number(isPrebuiltPlayDescription(right.play)) - Number(isPrebuiltPlayDescription(left.play)) || left.index - right.index, ) .map(({ play }) => play); } function isPlayRunPackage(value: unknown): value is PlayRunPackage { return Boolean( value && typeof value === 'object' && !Array.isArray(value) && (value as Record).kind === 'play_run' && (value as Record).run && typeof (value as { run?: { id?: unknown } }).run?.id === 'string', ); } function normalizePlayStatus(raw: Record): PlayStatus { const runPackage = isPlayRunPackage(raw) ? raw : isPlayRunPackage(raw.package) ? raw.package : null; const packageRun = runPackage?.run; const rawStatus = typeof raw.status === 'string' ? raw.status : typeof packageRun?.status === 'string' ? packageRun.status : null; const status = decodePlayRunPublicStatus(rawStatus); if (!status) { throw new Error( `Invalid play run lifecycle status in API response: ${JSON.stringify(rawStatus)}.`, ); } const runId = typeof raw.runId === 'string' ? raw.runId : typeof raw.workflowId === 'string' ? raw.workflowId : (packageRun?.id ?? ''); return { ...(raw as unknown as Omit), runId, ...(runPackage ? { package: runPackage, outputs: runPackage.outputs } : {}), status, }; } function normalizePlayRunStart(raw: Record): PlayRunStart { const runPackage = isPlayRunPackage(raw) ? raw : isPlayRunPackage(raw.package) ? raw.package : null; if (!runPackage) { const workflowId = typeof raw.workflowId === 'string' && raw.workflowId ? raw.workflowId : typeof raw.runId === 'string' ? raw.runId : ''; return { ...(raw as unknown as Omit), workflowId, }; } const status = typeof runPackage.run.status === 'string' ? runPackage.run.status : 'running'; return { workflowId: runPackage.run.id, name: runPackage.run.playName, status, ...(runPackage.run.dashboardUrl ? { dashboardUrl: runPackage.run.dashboardUrl } : {}), ...(TERMINAL_PLAY_STATUSES.has(status) ? { finalStatus: runPackage } : {}), package: runPackage, }; } function decodeBase64Bytes(value: string): Uint8Array { const binary = atob(value); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return bytes; } /** * A staged-file upload target minted by POST /api/v2/plays/files/stage/mint. * When `alreadyStaged` is true the server already holds the content-addressed * object and `uploadUrl` is null; otherwise the client PUTs the body to * `uploadUrl` with `uploadHeaders`. */ type MintStagedFileUpload = { logicalPath: string; contentHash: string; ref: PlayStagedFileRef; alreadyStaged: boolean; uploadUrl: string | null; uploadHeaders: Record | null; uploadExpiresAt: string | null; }; // Legacy multipart staging proxies the body through the serverless function, // which caps request bodies near 4.5MB. Stay conservatively below that so the // fail-loud guidance fires before the platform returns an opaque 413. const STAGE_LEGACY_MULTIPART_MAX_BYTES = 4_000_000; const STAGE_DIRECT_UPLOAD_MAX_ATTEMPTS = 3; function stagedUploadIdentity( logicalPath: string, contentHash: string, ): string { return `${contentHash}:${logicalPath}`; } function isStagedUploadMintUnsupported(error: unknown): boolean { // A server without the mint route returns 404. Treat only that as // "unsupported, fall back"; every other failure propagates. return error instanceof DeeplineError && error.statusCode === 404; } function isStagedUploadDirectEgressDenied(error: unknown): boolean { return ( error instanceof DeeplineError && error.code === 'STAGED_FILE_UPLOAD_EGRESS_DENIED' ); } function formatMegabytes(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } async function uploadStagedFileToPresignedUrl(input: { url: string; headers: Record; body: Uint8Array; logicalPath: string; }): Promise { // Wrap the bytes in a fresh Blob so the body is a BodyInit and the upload is // re-runnable across retries (a consumed stream would not be). const arrayBuffer = input.body.buffer.slice( input.body.byteOffset, input.body.byteOffset + input.body.byteLength, ) as ArrayBuffer; let lastError: unknown; for ( let attempt = 1; attempt <= STAGE_DIRECT_UPLOAD_MAX_ATTEMPTS; attempt += 1 ) { try { const response = await fetch(input.url, { method: 'PUT', headers: input.headers, body: new Blob([arrayBuffer]), }); if (response.ok) { return; } const text = await response.text().catch(() => ''); const egressDenied = response.status === 403 && response.headers.get('x-deny-reason')?.trim().toLowerCase() === 'host_not_allowed'; lastError = new DeeplineError( `Direct storage upload of ${input.logicalPath} failed: ${response.status} ${text.slice(0, 200)}`, response.status, egressDenied ? 'STAGED_FILE_UPLOAD_EGRESS_DENIED' : 'STAGED_FILE_UPLOAD_FAILED', { logicalPath: input.logicalPath }, ); // 4xx (other than throttling) will not recover on retry. if (response.status < 500 && response.status !== 429) { break; } } catch (error) { // fetch rejects network-level failures instead of returning a Response. // Sandboxed egress proxies commonly reject the TLS CONNECT this way, so // preserve the existing multipart fallback after direct retries finish. lastError = error instanceof TypeError ? new DeeplineError( `Direct storage upload of ${input.logicalPath} could not reach the storage endpoint: ${error.message}`, undefined, 'STAGED_FILE_UPLOAD_EGRESS_DENIED', { logicalPath: input.logicalPath }, ) : error; } if (attempt < STAGE_DIRECT_UPLOAD_MAX_ATTEMPTS) { await sleep(250 * attempt); } } throw lastError instanceof Error ? lastError : new DeeplineError( `Direct storage upload of ${input.logicalPath} failed.`, undefined, 'STAGED_FILE_UPLOAD_FAILED', { logicalPath: input.logicalPath }, ); } function readStringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((line): line is string => typeof line === 'string') : []; } type PlayLiveStatusState = { runId: string; status: PlayStatus['status']; logs: string[]; /** * Absolute (1-based) sequence number of the last log line appended to * `logs`. play.run.log payloads carry `firstSeq` (ADR-0009), so overlapping * re-deliveries are skipped positionally — repeated identical lines are * preserved and snapshots never replace the accumulated log list. */ lastLogSeq: number; result?: unknown; error?: string; latest: PlayStatus | null; }; function getPlayLiveEventPayload( event: PlayLiveEvent, ): Record { return event.payload && typeof event.payload === 'object' ? (event.payload as Record) : {}; } function normalizeLiveStatus(value: unknown): PlayStatus['status'] | null { if ( value === 'queued' || value === 'running' || value === 'waiting' || value === 'completed' || value === 'failed' || value === 'cancelled' ) { return value; } return null; } function appendPlayLiveLogLines( state: PlayLiveStatusState, payload: Record, ): void { const lines = readStringArray(payload.lines); if (lines.length === 0) { return; } const firstSeq = typeof payload.firstSeq === 'number' && Number.isFinite(payload.firstSeq) && payload.firstSeq >= 1 ? Math.trunc(payload.firstSeq) : null; if (firstSeq === null) { // Marker payloads (gap/unavailable notices) and pre-ADR-0009 servers // carry no seq: append verbatim and advance the cursor by the payload's // cumulative count when present so later seq-stamped lines line up. state.logs.push(...lines); const totalLogCount = typeof payload.totalLogCount === 'number' && Number.isFinite(payload.totalLogCount) ? Math.trunc(payload.totalLogCount) : null; if (totalLogCount !== null) { state.lastLogSeq = Math.max(state.lastLogSeq, totalLogCount); } return; } // Positional append: skip the already-seen prefix of overlapping // re-deliveries; repeated identical lines are preserved. const skip = Math.max(0, state.lastLogSeq + 1 - firstSeq); if (skip >= lines.length) { return; } state.logs.push(...lines.slice(skip)); state.lastLogSeq = Math.max(state.lastLogSeq, firstSeq + lines.length - 1); } function updatePlayLiveStatusState( state: PlayLiveStatusState, event: PlayLiveEvent, ): PlayStatus | null { const payload = getPlayLiveEventPayload(event); if (event.type === 'play.run.log') { appendPlayLiveLogLines(state, payload); return null; } if ( event.type !== 'play.run.snapshot' && event.type !== 'play.run.status' && event.type !== 'play.run.final_status' ) { return null; } const runId = typeof payload.runId === 'string' && payload.runId ? payload.runId : isPlayRunPackage(payload) ? payload.run.id : state.runId; const status = normalizeLiveStatus(payload.status) ?? (isPlayRunPackage(payload) ? normalizeLiveStatus(payload.run.status) : null) ?? state.status; const progressPayload = isRecord(payload.progress) ? payload.progress : {}; // Snapshots no longer REPLACE accumulated logs (ADR-0009): the snapshot // only retains a bounded tail, so replacing would clobber the seq-keyed // log list built from play.run.log events (the stream differ always emits // log lines through play.run.log, snapshot ticks included). A terminal // final_status payload may still seed an EMPTY state — that is the only // event some non-stream flows ever see. if ( event.type === 'play.run.final_status' && state.logs.length === 0 && state.lastLogSeq === 0 ) { const payloadLogs = readStringArray(payload.logs); const progressLogs = readStringArray(progressPayload.logs); const seedLogs = payloadLogs.length > 0 ? payloadLogs : progressLogs; if (seedLogs.length > 0) { state.logs = seedLogs; } } if ('result' in payload) { state.result = payload.result; } else if (isPlayRunPackage(payload)) { state.result = payload; } if (typeof payload.error === 'string' && payload.error.trim()) { state.error = payload.error; } state.runId = runId; state.status = status; const progressRecord = progressPayload; const next: PlayStatus = { ...(payload as unknown as Omit< PlayStatus, 'runId' | 'status' | 'progress' >), runId, status, ...(isPlayRunPackage(payload) ? { package: payload, outputs: payload.outputs } : {}), progress: { ...progressRecord, status: typeof progressRecord.status === 'string' ? progressRecord.status : status, logs: state.logs, ...(state.error ? { error: state.error } : {}), }, ...('result' in state ? { result: state.result } : {}), }; state.latest = next; return next; } function playRunResultFromStatus( status: PlayStatus, startedAt: number, fallbackRunId: string, ): PlayRunResult { return { success: status.status === 'completed', runId: status.runId || fallbackRunId, result: status.package ?? status.result, ...(status.package ? { package: status.package } : {}), logs: status.progress?.logs ?? [], durationMs: Date.now() - startedAt, error: status.progress?.error ?? (status.status !== 'completed' ? status.status : undefined), }; } function playRunStatusFromState(state: PlayLiveStatusState): PlayStatus { return { runId: state.runId, status: state.status, progress: { status: state.status, logs: state.logs, ...(state.error ? { error: state.error } : {}), }, ...('result' in state ? { result: state.result } : {}), }; } /** * Low-level client for the Deepline REST API. * * Provides typed methods for every API endpoint: tools, plays, auth, and health. * Handles authentication, retries, and localhost failover automatically. * * @example * ```typescript * import { DeeplineClient } from 'deepline'; * * // Zero-config (uses env vars / CLI auth): * const client = new DeeplineClient(); * * // Explicit config: * const client2 = new DeeplineClient({ * apiKey: 'dl_test_...', * baseUrl: 'http://localhost:3000', * }); * ``` * * @sdkReference client 010 */ export class DeeplineClient { private readonly http: HttpClient; private readonly config: ResolvedConfig; /** Canonical run lifecycle namespace backed by `/api/v2/runs`. */ readonly runs: RunsNamespace; /** Current mutable customer database namespace backed by `/api/v2/db/query`. */ readonly db: DbNamespace; /** Billing namespace: subscription status/cancel and invoice history. */ readonly billing: BillingNamespace; /** Monitors namespace: access, catalog, deploy/check, and lifecycle. */ readonly monitors: MonitorsNamespace; /** * Create a low-level SDK client. * * Most callers can omit options and let the SDK resolve auth/config from * environment variables and CLI-managed credentials. * * @param options - Optional overrides for API key, base URL, timeout, and retries. * @throws {@link ConfigError} if no API key can be resolved from any source. */ constructor(options?: DeeplineClientOptions) { this.config = resolveConfig(options); this.http = new HttpClient(this.config); this.runs = { get: (runId, options) => this.getRunStatus(runId, options), list: (options) => this.listRuns(options), tail: (runId, options) => this.tailRun(runId, options), logs: (runId, options) => this.getRunLogs(runId, options), input: (runId) => this.getRunInput(runId), rerun: (runId) => this.rerun(runId), exportDatasetRows: (input) => this.getPlaySheetRows(input), stop: (runId, options) => this.stopRun(runId, options), stopAll: (options) => this.stopAllRuns(options), }; this.db = { query: (input) => this.queryDb(input), }; this.billing = { topUp: (options) => this.topUpBillingBalance(options), plans: () => this.getBillingPlans(), subscription: { status: () => this.getBillingSubscriptionStatus(), cancel: (options) => this.cancelBillingSubscription(options), }, invoices: { list: (options) => this.listBillingInvoices(options), }, targetPlans: () => this.getTargetBillingPlans(), targetStatus: () => this.getTargetBillingStatus(), purchaseCredits: (options) => this.purchaseTargetBillingCredits(options), transitionPlan: (options) => this.transitionTargetBillingPlan(options), portalSession: () => this.createTargetBillingPortalSession(), }; this.monitors = { status: () => this.getMonitorsAccess(), available: (toolIdOrOptions, options) => this.getMonitorsAvailable(toolIdOrOptions, options), check: (definition) => this.checkMonitor(definition), deploy: (definition, options) => this.deployMonitor(definition, options), list: (options) => this.listMonitors(options), get: (key) => this.getMonitor(key), test: (key, payload, options) => this.testMonitorWebhook(key, payload, options), validate: (key) => this.validateMonitor(key), dependents: (key) => this.getMonitorDependents(key), update: (key, patch) => this.updateMonitor(key, patch), delete: (key, options) => this.deleteMonitor(key, options), reactivate: (key, options) => this.reactivateMonitor(key, options), }; } /** The resolved base URL this client is targeting (e.g. `"http://localhost:3000"`). */ get baseUrl(): string { return this.config.baseUrl; } private compactSchema(schema: Record | null | undefined) { if (!schema) return null; const fields = Array.isArray(schema.fields) ? schema.fields .map((field) => field && typeof field === 'object' ? { name: String((field as Record).name ?? ''), type: (field as Record).type ?? undefined, required: (field as Record).required ?? undefined, } : null, ) .filter((field) => Boolean(field?.name)) : []; return fields.length > 0 ? { fields } : schema; } private schemaMetadata( schema: Record | null | undefined, key: string, ): Record | null { if (!isRecord(schema)) return null; const value = schema[key]; return isRecord(value) ? value : null; } private playRunCommand( play: Pick, options?: { csvInput?: Record | null }, ): string { const target = play.reference || play.name; if (options?.csvInput) { const inputField = typeof options.csvInput.inputField === 'string' && options.csvInput.inputField.trim() ? options.csvInput.inputField.trim() : 'csv'; return `deepline plays run ${target} --input '${JSON.stringify({ [inputField]: 'leads.csv' })}' --watch`; } return `deepline plays run ${target} --input '{...}' --watch`; } private starterPlayPath( play: Pick, ): string { const target = play.reference || play.name; const unqualifiedName = target.split('/').pop() || play.name; const safeName = unqualifiedName .trim() .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); return `./${safeName || 'play'}.play.ts`; } private playCloneEditStarter( play: Pick< PlayListItem, 'name' | 'reference' | 'canClone' | 'canEdit' | 'origin' | 'ownerType' >, ): PlayDescription['cloneEditStarter'] | undefined { const readonlyPrebuilt = (play.origin === 'prebuilt' || play.ownerType === 'deepline') && !play.canEdit; if (!play.canClone && !readonlyPrebuilt) return undefined; const target = play.reference || play.name; const path = this.starterPlayPath(play); return { path, command: `deepline plays get ${target} --source --out ${path}`, checkCommand: `deepline plays check ${path}`, }; } private summarizePlayListItem( play: PlayListItem, options?: { compact?: boolean }, ): PlayDescription { const aliases = play.aliases?.length ? play.aliases : [play.name]; const csvInput = this.schemaMetadata(play.inputSchema, 'csvInput'); const rowOutputSchema = this.schemaMetadata( play.outputSchema, 'rowOutputSchema', ); const description = play.description?.trim() || play.currentRevision?.description?.trim() || play.liveRevision?.description?.trim() || null; const runCommand = this.playRunCommand(play, { csvInput }); const cloneEditStarter = this.playCloneEditStarter(play); return { name: play.name, // playKey and triggerStatus were projected away here, so `plays describe` // was strictly less informative than `plays list` for the same play — no // stable key, and no way to see that a cron was armed. ...(play.playKey ? { playKey: play.playKey } : {}), ...(play.reference ? { reference: play.reference } : {}), ...(play.displayName ? { displayName: play.displayName } : {}), ...(description ? { description } : {}), pinned: Boolean(play.pinned), toolCategories: play.toolCategories ?? [], origin: play.origin, ownerType: play.ownerType, canEdit: play.canEdit, canClone: play.canClone, aliases, inputSchema: options?.compact ? this.compactSchema(play.inputSchema) : (play.inputSchema ?? null), outputSchema: options?.compact ? this.compactSchema(play.outputSchema) : (play.outputSchema ?? null), staticPipeline: isRecord(play.staticPipeline) ? play.staticPipeline : isRecord(play.currentRevision?.staticPipeline) ? play.currentRevision.staticPipeline : isRecord(play.liveRevision?.staticPipeline) ? play.liveRevision.staticPipeline : null, ...(csvInput ? { csvInput } : {}), ...(rowOutputSchema ? { rowOutputSchema } : {}), runCommand, examples: [runCommand], ...(cloneEditStarter ? { cloneEditStarter } : {}), currentPublishedVersion: play.currentPublishedVersion ?? null, // Read the live version off the live revision. It was previously only // ever written by the publish/live routes, so every list and describe // payload reported liveVersion: null even for an actively serving play. liveVersion: play.liveRevision?.version ?? null, ...(play.triggerStatus ? { triggerStatus: play.triggerStatus } : {}), isDraftDirty: play.isDraftDirty, }; } private summarizePlayDetail( detail: PlayDetail, options?: { compact?: boolean }, ): PlayDescription { const play = detail.play; return { ...this.summarizePlayListItem(play, options), currentPublishedVersion: play.currentPublishedVersion ?? play.liveRevision?.version ?? null, latestRunId: play.latestRunId ?? detail.latestRuns[0]?.workflowId ?? null, }; } // —————————————————————————————————————————————————————————— // Secrets // —————————————————————————————————————————————————————————— /** List secret metadata visible to the current workspace. */ async listSecrets(): Promise { const response = await this.http.get<{ secrets?: PlaySecretMetadata[] }>( '/api/v2/secrets', ); return Array.isArray(response.secrets) ? response.secrets : []; } /** * Check whether a named secret exists, is active, and has a stored value. * * @param name - Secret name. It is normalized to uppercase before lookup. * @returns Matching active secret metadata, or `null`. */ async checkSecret(name: string): Promise { const normalized = name.trim().toUpperCase(); const secrets = await this.listSecrets(); return ( secrets.find( (secret) => secret.name === normalized && secret.status === 'active' && secret.hasValue, ) ?? null ); } // —————————————————————————————————————————————————————————— // Tools // —————————————————————————————————————————————————————————— /** * List all available tools. * * Returns tool definitions including ID, provider, description, input/output schemas, * and list extractor paths for automatic CSV conversion. * * @returns Array of tool definitions * * @example * ```typescript * const tools = await client.listTools(); * const searchTools = tools.filter(t => t.categories.includes('search')); * console.log(`Found ${searchTools.length} search tools`); * ``` */ async listTools(options?: { categories?: string; tags?: string; grep?: string; grepMode?: 'all' | 'any' | 'phrase'; compact?: boolean; }): Promise { const params = new URLSearchParams(); if (options?.categories?.trim()) { params.set('categories', options.categories.trim()); } if (options?.tags?.trim()) { params.set('tags', options.tags.trim()); } if (options?.grep?.trim()) { params.set('grep', options.grep.trim()); params.set('grep_mode', options.grepMode ?? 'all'); } params.set('compact', options?.compact === true ? 'true' : 'false'); const suffix = params.toString() ? `?${params.toString()}` : ''; const res = await this.http.get<{ tools: ToolDefinition[] }>( `/api/v2/tools${suffix}`, ); return res.tools; } /** List discoverable providers without requiring a local plugin catalog. */ async listProviders(options?: { changed?: boolean; }): Promise { const suffix = options?.changed ? '?changed=true' : ''; const res = await this.http.get<{ providers: ProviderDefinition[] }>( `/api/v2/tools/providers${suffix}`, ); return res.providers; } /** * Search available tools using Deepline's ranked backend search. * * This is the same discovery surface used by the CLI: it ranks across * tool metadata, categories, agent guidance, and input schema fields. */ async searchTools( options: ToolSearchOptions = {}, ): Promise { const params = new URLSearchParams(); const query = options.query?.trim() ?? ''; params.set('q', query); params.set( 'include_search_debug', options.includeSearchDebug ? 'true' : 'false', ); params.set('search_mode', options.searchMode ?? 'v2'); if (options.categories?.trim()) { params.set('categories', options.categories.trim()); } if (options.searchTerms?.trim()) { params.set('search_terms', options.searchTerms.trim()); } return this.http.get( `/api/v2/tools/search?${params.toString()}`, ); } /** * Get detailed metadata for a single tool. * * Returns everything from {@link ToolDefinition} plus pricing info, sample * inputs/outputs, failure modes, and cost estimates. * * @param toolId - Tool identifier (e.g. `"dropleads_search_people"`) * @returns Full tool metadata * * @example * ```typescript * const meta = await client.getTool('dropleads_search_people'); * console.log(`Cost: ${meta.estimatedCreditsRange} credits`); * console.log(`Input schema:`, meta.inputSchema); * ``` */ async getTool(toolId: string): Promise { return this.http.request( `/api/v2/integrations/${encodeURIComponent(toolId)}/get`, { method: 'GET', headers: { 'x-deepline-tool-meta-only': '1', }, }, ); } /** * Describe a Deepline Agent model and its provider-specific option surface. * * Combines live AI Gateway model metadata with Deepline's generated AI SDK * provider option registry so agents can construct `providerOptions` * payloads before executing `deeplineagent`. * * The returned option schemas describe accepted provider option shapes, not * guaranteed support for every model. Runtime AI SDK/Gateway errors remain * authoritative for model-gated values. * * @param model - Gateway model id such as `"openai/gpt-5.5"` * @returns Model metadata, provider option shapes, and runnable examples */ async describeModel(model: string): Promise { return this.http.request( `/api/v2/models/describe?model=${encodeURIComponent(model)}`, { method: 'GET', }, ); } /** * Quote dynamic AI inference pricing for a concrete payload. * * The result separates a planning estimate from a proven authorization * maximum and contains Deepline credits only. */ async quoteInferenceTool( toolId: 'ai_inference' | 'deeplineagent', payload: Record, ): Promise { return this.http.post( `/api/v2/integrations/${encodeURIComponent(toolId)}/quote`, payload, ); } /** * Execute a tool and return the standard execution envelope. * * The `toolResponse.raw` field contains the raw tool response. * `toolResponse.meta` contains tool/provider metadata. * Top-level fields such as `status`, `job_id`, and `billing` describe the * Deepline execution envelope. */ async executeTool>( toolId: string, input: Record, options?: ExecuteToolRawOptions, ): Promise> { const headers = { [EXECUTE_RESPONSE_CONTRACT_HEADER]: RAW_V2_EXECUTE_RESPONSE_CONTRACT, [TOOL_EXECUTION_ERROR_SCHEMA_HEADER]: String( TOOL_EXECUTION_ERROR_SCHEMA_VERSION, ), ...(options?.includeToolMetadata ? { [INCLUDE_TOOL_METADATA_HEADER]: 'true' } : {}), [EXECUTE_RESPONSE_INTENT_HEADER]: options?.responseIntent ?? 'raw', }; const response = await this.http.post>( `/api/v2/integrations/${encodeURIComponent(toolId)}/execute`, { payload: input, ...(options?.metadata ? { metadata: options.metadata } : {}), }, headers, { timeout: options?.timeout ?? resolveToolExecuteTimeoutMs(toolId, input), maxRetries: options?.maxRetries ?? 0, exactUrlOnly: true, toolId, }, ); return materializeToolExecutionResponse(response); } /** * Back-compatible alias for {@link executeTool}. * * Retained for callers that still use the older raw naming while the response * envelope remains the same. */ async executeToolRaw>( toolId: string, input: Record, options?: ExecuteToolRawOptions, ): Promise> { return this.executeTool(toolId, input, options); } private async queryDb(input: { sql: string; maxRows?: number; }): Promise { const result = await this.http.post( '/api/v2/db/query', { sql: input.sql, ...(input.maxRows ? { max_rows: input.maxRows } : {}), }, ); return { ...result, scope: { kind: 'database', mutability: 'current' }, }; } /** * Run a bounded SQL query against the current mutable customer database. * * This query is not scoped to one play run. Use `client.runs` export actions * when the caller needs the rows produced by a specific run. * * @deprecated Use {@link DeeplineClient.db} `.query(...)`. */ async queryCustomerDb(input: { sql: string; maxRows?: number; }): Promise { return this.db.query(input); } /** * Re-establish this workspace's tenant storage contract: role/DB connect * grants plus materialized table grants. Org-admin only. Use when a run fails * with WORKSPACE_STORAGE_NOT_READY. */ async repairIngestionStorage(input?: { provider?: string; }): Promise { return this.http.post( '/api/v2/ingestion/repair', { ...(input?.provider ? { provider: input.provider } : {}), }, ); } // —————————————————————————————————————————————————————————— // Plays — submission and lifecycle // —————————————————————————————————————————————————————————— /** * Start a play run. * * Internal/advanced primitive. For normal callers, prefer the public * entrypoints: the CLI, {@link Deepline.connect}, {@link submitPlay}, * or {@link runPlay}. * * Supported invocation surfaces intentionally share this same run contract: * `deepline plays run`, repo scripts such as `bun run deepline -- plays run`, * SDK context calls like `Deepline.connect().play(name).run()`, and direct * `POST /api/v2/plays/run` calls all return a workflow/run id. The completed * output is always retrievable from `getPlayStatus(runId).result` (or from * `PlayJob.get()` for SDK context calls). Execution logs live under * `progress.logs`; they are not part of the user output object. * * @param request - Play run configuration (name, code, input, etc.) * @returns Run metadata including the public `workflowId` * * @example * ```typescript * // Run a live play by name: * const started = await client.startPlayRun({ * name: 'email-waterfall', * input: { linkedin_url: 'https://linkedin.com/in/jdoe', domain: 'acme.com' }, * }); * console.log(`Workflow: ${started.workflowId}`); * * // Run an ad hoc artifact-backed play: * const started2 = await client.startPlayRun({ * artifactStorageKey: 'plays/v1/orgs/acme/plays/my-play/artifacts/playgraph_abc123.json', * }); * ``` */ async startPlayRun(request: StartPlayRunRequest): Promise { const integrationMode = resolvePlayRunIntegrationMode(request); const testPolicyOverrides = request.testPolicyOverrides ?? parseEnvTestPolicyOverrides(); const forceToolRefresh = request.forceToolRefresh === true; const runtime = resolvePlayRunRuntimeSelection(request); const response = await this.http.post>( '/api/v2/plays/run', { ...(request.name ? { name: request.name } : {}), ...(request.revisionId ? { revisionId: request.revisionId } : {}), ...(request.artifactStorageKey ? { artifactStorageKey: request.artifactStorageKey } : {}), ...(request.sourceCode ? { sourceCode: request.sourceCode } : {}), ...(request.sourceFiles ? { sourceFiles: request.sourceFiles } : {}), ...(request.description ? { description: request.description } : {}), ...('staticPipeline' in request ? { staticPipeline: request.staticPipeline } : {}), ...(request.artifactHash ? { artifactHash: request.artifactHash } : {}), ...(request.graphHash ? { graphHash: request.graphHash } : {}), ...(request.runtimeArtifact ? { runtimeArtifact: request.runtimeArtifact } : {}), ...(request.compilerManifest ? { compilerManifest: request.compilerManifest } : {}), ...(request.inputFileUpload ? { inputFileUpload: request.inputFileUpload } : {}), ...(request.packagedFileUploads?.length ? { packagedFileUploads: request.packagedFileUploads } : {}), ...(request.input ? { input: request.input } : {}), ...(request.inputFile ? { inputFile: request.inputFile } : {}), ...(request.packagedFiles?.length ? { packagedFiles: request.packagedFiles } : {}), ...(request.force ? { force: true } : {}), ...(forceToolRefresh ? { forceToolRefresh: true } : {}), ...(typeof request.maxConcurrentExternalCalls === 'number' ? { maxConcurrentExternalCalls: request.maxConcurrentExternalCalls, } : {}), ...(typeof request.maxConcurrentRows === 'number' ? { maxConcurrentRows: request.maxConcurrentRows } : {}), ...(typeof request.waitForCompletionMs === 'number' ? { waitForCompletionMs: request.waitForCompletionMs } : {}), // Profile selection is the API's job, not the CLI's. The server // defaults to absurd; callers normally omit this field. ...(request.profile ? { profile: request.profile } : {}), ...(integrationMode ? { integrationMode } : {}), ...(request.fixtureBehavior ? { fixtureBehavior: request.fixtureBehavior } : {}), ...(runtime ? { runtime } : {}), ...(testPolicyOverrides ? { testPolicyOverrides } : {}), }, runtimeSelectionHeaders(runtime), // A start can reach the server even when its response is lost or times // out. Retrying this mutation without an idempotency key can create a // second root run, so callers must resolve ambiguous failures explicitly. { maxRetries: 0, exactUrlOnly: true }, ); return normalizePlayRunStart(response); } /** * Start a play run and stream live runtime events from the same request. * * Use this when a caller wants low-level event handling instead of submitting * first and then connecting to `streamPlayRunEvents(runId)`. * * @param request - Play run configuration. * @param options - Optional streaming options. * @param options.signal - Optional abort signal for the streaming request. * @returns Async stream of play-scoped live events. */ async *startPlayRunStream( request: StartPlayRunRequest, options?: { signal?: AbortSignal }, ): AsyncGenerator { const integrationMode = resolvePlayRunIntegrationMode(request); const testPolicyOverrides = request.testPolicyOverrides ?? parseEnvTestPolicyOverrides(); const forceToolRefresh = request.forceToolRefresh === true; const runtime = resolvePlayRunRuntimeSelection(request); const body = { ...(request.name ? { name: request.name } : {}), ...(request.revisionId ? { revisionId: request.revisionId } : {}), ...(request.artifactStorageKey ? { artifactStorageKey: request.artifactStorageKey } : {}), ...(request.sourceCode ? { sourceCode: request.sourceCode } : {}), ...(request.sourceFiles ? { sourceFiles: request.sourceFiles } : {}), ...(request.description ? { description: request.description } : {}), ...('staticPipeline' in request ? { staticPipeline: request.staticPipeline } : {}), ...(request.artifactHash ? { artifactHash: request.artifactHash } : {}), ...(request.graphHash ? { graphHash: request.graphHash } : {}), ...(request.runtimeArtifact ? { runtimeArtifact: request.runtimeArtifact } : {}), ...(request.compilerManifest ? { compilerManifest: request.compilerManifest } : {}), ...(request.inputFileUpload ? { inputFileUpload: request.inputFileUpload } : {}), ...(request.packagedFileUploads?.length ? { packagedFileUploads: request.packagedFileUploads } : {}), ...(request.input ? { input: request.input } : {}), ...(request.inputFile ? { inputFile: request.inputFile } : {}), ...(request.packagedFiles?.length ? { packagedFiles: request.packagedFiles } : {}), ...(request.force ? { force: true } : {}), ...(forceToolRefresh ? { forceToolRefresh: true } : {}), ...(typeof request.maxConcurrentExternalCalls === 'number' ? { maxConcurrentExternalCalls: request.maxConcurrentExternalCalls } : {}), ...(typeof request.maxConcurrentRows === 'number' ? { maxConcurrentRows: request.maxConcurrentRows } : {}), ...(typeof request.waitForCompletionMs === 'number' ? { waitForCompletionMs: request.waitForCompletionMs } : {}), ...(request.profile ? { profile: request.profile } : {}), ...(integrationMode ? { integrationMode } : {}), ...(request.fixtureBehavior ? { fixtureBehavior: request.fixtureBehavior } : {}), ...(runtime ? { runtime } : {}), ...(testPolicyOverrides ? { testPolicyOverrides } : {}), }; for await (const event of this.http.streamSse( '/api/v2/plays/run?stream=true', { method: 'POST', body, headers: runtimeSelectionHeaders(runtime), signal: options?.signal, }, )) { if (event.scope === 'play') { yield event; } } } /** * Register a bundled play artifact. * * Internal/advanced primitive used by packaging flows. Public callers should * prefer the CLI, {@link submitPlay}, or {@link runPlay}. */ async registerPlayArtifact(input: { name: string; sourceCode: string; sourceFiles?: Record; description?: string; artifact: Record; compilerManifest?: PlayCompilerManifest; publish?: boolean; ownerType?: 'org' | 'deepline'; scope?: 'org' | 'system'; userId?: string; }): Promise<{ success?: boolean; name?: string; artifactStorageKey: string; artifactMetadata?: Record | null; staticPipeline?: unknown; definitionId?: string | null; revisionId?: string | null; version?: number | null; liveVersion?: number | null; triggerMetadata?: unknown; triggerBindings?: unknown; }> { const compilerManifest = input.compilerManifest ?? (await this.compilePlayManifest({ name: input.name, sourceCode: input.sourceCode, sourceFiles: input.sourceFiles, artifact: input.artifact, })); return this.http.post( '/api/v2/plays/artifacts', { ...input, compilerManifest, }, undefined, { retryApiErrors: true }, ); } /** * Register multiple bundled play artifacts in one request. * * Used by packaging and prebuilt publication flows. Each artifact is compiled * first when a compiler manifest is not already supplied. */ async registerPlayArtifacts( artifacts: Array<{ name: string; sourceCode: string; sourceFiles?: Record; description?: string; artifact: Record; compilerManifest?: PlayCompilerManifest; publish?: boolean; ownerType?: 'org' | 'deepline'; scope?: 'org' | 'system'; userId?: string; }>, ): Promise<{ success: boolean; artifacts: Array<{ success?: boolean; name?: string; artifactStorageKey: string; artifactMetadata?: Record | null; staticPipeline?: unknown; definitionId?: string | null; revisionId?: string | null; version?: number | null; liveVersion?: number | null; triggerMetadata?: unknown; triggerBindings?: unknown; }>; }> { if (artifacts.length === 0) { return this.http.post( '/api/v2/plays/artifacts', { artifacts }, undefined, { retryApiErrors: true }, ); } const compiledArtifacts = await mapWithConcurrency( artifacts, REGISTER_PLAY_ARTIFACTS_COMPILE_CONCURRENCY, async (artifact) => ({ ...artifact, compilerManifest: artifact.compilerManifest ?? (await this.compilePlayManifest({ name: artifact.name, sourceCode: artifact.sourceCode, sourceFiles: artifact.sourceFiles, artifact: artifact.artifact, })), }), ); const responses = []; for (const chunk of chunkRegisterPlayArtifacts(compiledArtifacts)) { responses.push( await this.http.post<{ success: boolean; artifacts: Array<{ success?: boolean; name?: string; artifactStorageKey: string; artifactMetadata?: Record | null; staticPipeline?: unknown; definitionId?: string | null; revisionId?: string | null; version?: number | null; liveVersion?: number | null; triggerMetadata?: unknown; triggerBindings?: unknown; }>; }>( '/api/v2/plays/artifacts', { artifacts: chunk, }, undefined, { retryApiErrors: true }, ), ); } return { success: responses.every((response) => response.success), artifacts: responses.flatMap((response) => response.artifacts), }; } /** * Compile a bundled play artifact into the server-side compiler manifest. * * The manifest records imports, trigger bindings, static pipeline shape, and * runtime metadata needed before a play artifact can be checked, registered, * or run. */ async compilePlayManifest(input: { name: string; sourceCode: string; sourceFiles?: Record; artifact: Record; importedPlayDependencies?: PlayCompilerManifest[]; }): Promise { const retryDelays = COMPILE_MANIFEST_RETRY_DELAYS_MS.slice( 0, Math.max(0, this.config.maxRetries), ); for (let attempt = 0; ; attempt += 1) { try { const response = await this.http.post<{ compilerManifest: PlayCompilerManifest; }>('/api/v2/plays/compile-manifest', input); return requireCompileManifestResponse(response, input.name); } catch (error) { const delayMs = retryDelays[attempt]; if (delayMs === undefined || !isTransientCompileManifestError(error)) { throw error; } await sleep(delayMs); } } } /** * Check a bundled play artifact against the server's current play compiler. * * Unlike {@link registerPlayArtifact}, this does not store the artifact, * publish a revision, or start a run. It is the authoritative cloud validation * path used by `deepline plays check`. */ async checkPlayArtifact(input: { name?: string; sourceCode: string; sourceFiles?: Record; description?: string; artifact: Record; /** Which exported play in `sourceCode` this artifact is, when the file * exports more than one. Omit for the default export. */ exportName?: string | null; integrationMode?: 'live' | 'eval_stub' | 'fixture'; /** * Sibling plays from the same local bundle graph. Lets the server splice * unpublished local children into the checked plan and enforce the * row-scoped batch rule (ADR 0013). */ importedPlays?: Array<{ playName?: string | null; sourceCode: string; sourcePath?: string | null; }>; }): Promise { const integrationMode = normalizePlayRunIntegrationMode( input.integrationMode ?? process.env.DEEPLINE_EVAL_INTEGRATION_MODE, ); return this.http.post('/api/v2/plays/check', { ...input, ...(integrationMode ? { integrationMode } : {}), }); } /** * Compile legacy enrich command arguments into a runtime plan. * * This is primarily used by CLI compatibility paths that translate older * enrichment commands onto the play runtime. */ async compileEnrichPlan(input: { plan_args?: string[]; config?: unknown; native_play_materialization?: 'macro' | 'inline_prebuilt'; }): Promise<{ config: EnrichCompiledConfig }> { return this.http.post('/api/v2/enrich/compile', input); } /** * Register an already-bundled play artifact and start a run from it. * * This is the low-level file-backed run path used by SDK/CLI packaging * wrappers after local bundling has produced the runtime artifact. */ async startPlayRunFromBundle(input: { name: string; sourceCode: string; sourceFiles?: Record; description?: string; artifact: Record; compilerManifest?: PlayCompilerManifest; input?: Record; inputFile?: PlayStagedFileRef | null; packagedFiles?: PlayStagedFileRef[]; force?: boolean; forceToolRefresh?: boolean; }): Promise { const compilerManifest = input.compilerManifest ?? (await this.compilePlayManifest({ name: input.name, sourceCode: input.sourceCode, sourceFiles: input.sourceFiles, artifact: input.artifact, })); const registeredArtifact = await this.registerPlayArtifact({ name: input.name, sourceCode: input.sourceCode, sourceFiles: input.sourceFiles, description: input.description, artifact: input.artifact, compilerManifest, publish: false, }); if (!registeredArtifact.artifactStorageKey) { throw new Error( 'registerPlayArtifact did not return an artifactStorageKey.', ); } return this.startPlayRun({ name: input.name, artifactStorageKey: registeredArtifact.artifactStorageKey, description: input.description, compilerManifest, ...(input.input ? { input: input.input } : {}), ...(input.inputFile ? { inputFile: input.inputFile } : {}), ...(input.packagedFiles?.length ? { packagedFiles: input.packagedFiles } : {}), ...(input.force ? { force: true } : {}), ...(input.forceToolRefresh ? { forceToolRefresh: true } : {}), }); } /** * Register a bundled play artifact and start a run from the live revision. * * Convenience wrapper around {@link registerPlayArtifact} plus * {@link startPlayRun}. This is the canonical file-backed path used by wrappers. * The returned id can be passed to {@link getPlayStatus} to retrieve the same * durable `{ result }` object that the CLI prints after `--watch` completes. * * @param code - Source string fallback; the bundled artifact should be passed in `options.artifact` * @param csvPath - Path to input CSV file, or `null` * @param name - Play name (extracted from source if omitted) * @param options - Additional submission options * @returns Workflow metadata with `workflowId` * * @example * ```typescript * const started = await client.submitPlay( * originalSource, * './leads.csv', * 'bulk-enrich', * { artifact: bundledArtifact, input: { limit: 100 } }, * ); * ``` */ async submitPlay( code: string, csvPath: string | null, name?: string, options?: { sourceCode?: string; sourceFiles?: Record; description?: string; artifact?: Record; compilerManifest?: PlayCompilerManifest; input?: Record; inputFile?: PlayStagedFileRef | null; packagedFiles?: PlayStagedFileRef[]; force?: boolean; forceToolRefresh?: boolean; }, ): Promise { const runtimeInput = options?.input ? { ...options.input } : {}; if (csvPath) { runtimeInput.file = csvPath; } const sourceCode = options?.sourceCode ?? code; const artifact = options?.artifact; if (!name?.trim()) { throw new Error('submitPlay requires a play name.'); } if (!artifact) { throw new Error('submitPlay requires a bundled play artifact.'); } const compilerManifest = options?.compilerManifest ?? (await this.compilePlayManifest({ name, sourceCode, sourceFiles: options?.sourceFiles, artifact, })); const registeredArtifact = await this.registerPlayArtifact({ name, sourceCode, sourceFiles: options?.sourceFiles, description: options?.description, artifact, compilerManifest, publish: false, }); if (!registeredArtifact.artifactStorageKey) { throw new Error( 'registerPlayArtifact did not return an artifactStorageKey.', ); } return this.startPlayRun({ name, artifactStorageKey: registeredArtifact.artifactStorageKey, sourceCode, description: options?.description, staticPipeline: registeredArtifact.staticPipeline ?? null, artifactHash: typeof artifact.artifactHash === 'string' ? artifact.artifactHash : undefined, graphHash: typeof artifact.graphHash === 'string' ? artifact.graphHash : undefined, runtimeArtifact: artifact, compilerManifest, ...(Object.keys(runtimeInput).length > 0 ? { input: runtimeInput } : {}), ...(options?.inputFile ? { inputFile: options.inputFile } : {}), ...(options?.packagedFiles?.length ? { packagedFiles: options.packagedFiles } : {}), ...(options?.force ? { force: true } : {}), ...(options?.forceToolRefresh ? { forceToolRefresh: true } : {}), }); } /** * Upload files to the staging area for use in play runs. * * Internal/advanced primitive used by packaging flows. Public callers should * prefer the CLI, {@link submitPlay}, or {@link runPlay}. * * Staged files are referenced by their returned {@link PlayStagedFileRef} * in subsequent {@link startPlayRun} calls via `inputFile` or `packagedFiles`. * * @param files - Array of files to stage (base64-encoded content) * @returns Array of staged file references * * @example * ```typescript * const staged = await client.stagePlayFiles([{ * logicalPath: 'data/leads.csv', * contentBase64: Buffer.from(csvContent).toString('base64'), * contentHash: sha256(csvContent), * contentType: 'text/csv', * bytes: csvContent.length, * }]); * // Use staged[0] as inputFile in startPlayRun * ``` */ async stagePlayFiles( files: Array<{ logicalPath: string; contentBase64: string; contentHash: string; contentType: string; bytes: number; }>, ): Promise { // Primary path: mint presigned R2 PUT targets and upload each file body // straight to storage. This bypasses the ~4.5MB serverless request-body // limit that the legacy multipart proxy hits (413 // FUNCTION_PAYLOAD_TOO_LARGE), so large enriched CSVs stage and resume. let uploads: MintStagedFileUpload[]; try { uploads = await this.mintStagedPlayFileUploads( files.map((file) => ({ logicalPath: file.logicalPath, contentHash: file.contentHash, contentType: file.contentType, bytes: file.bytes, })), ); } catch (error) { if (isStagedUploadMintUnsupported(error)) { // The connected server predates direct-to-storage staging. Fall back to // the legacy multipart proxy, but only for files small enough to clear // the serverless body limit; larger files fail loud with guidance. return this.stagePlayFilesViaMultipart(files); } throw error; } const uploadByIdentity = new Map(); for (const upload of uploads) { uploadByIdentity.set( stagedUploadIdentity(upload.logicalPath, upload.contentHash), upload, ); } type DirectStageResult = | { ref: PlayStagedFileRef } | { fallbackFile: (typeof files)[number] }; const directResults: DirectStageResult[] = await Promise.all( files.map(async (file) => { const upload = uploadByIdentity.get( stagedUploadIdentity(file.logicalPath, file.contentHash), ); if (!upload) { throw new DeeplineError( `The staging server did not return an upload target for ${file.logicalPath}.`, undefined, 'STAGED_FILE_MINT_INCOMPLETE', ); } if (upload.alreadyStaged || !upload.uploadUrl) { return { ref: upload.ref }; } try { await uploadStagedFileToPresignedUrl({ url: upload.uploadUrl, headers: upload.uploadHeaders ?? { 'content-type': file.contentType, }, body: decodeBase64Bytes(file.contentBase64), logicalPath: file.logicalPath, }); return { ref: upload.ref }; } catch (error) { if (isStagedUploadDirectEgressDenied(error)) { return { fallbackFile: file }; } throw error; } }), ); return await Promise.all( directResults.map(async (result) => { if ('fallbackFile' in result) { const [ref] = await this.stagePlayFilesViaMultipart( [result.fallbackFile], 'direct-egress-denied', ); if (!ref) { throw new DeeplineError( `The staging server did not return an upload target for ${result.fallbackFile.logicalPath}.`, undefined, 'STAGED_FILE_MINT_INCOMPLETE', ); } return ref; } return result.ref; }), ); } /** * Mint short-lived presigned upload targets for staged play files. * * Internal primitive used by {@link stagePlayFiles}. The server returns an * already-staged ref (no upload needed) for content-addressed files it * already holds, or a presigned PUT URL the caller uploads the body to. */ async mintStagedPlayFileUploads( files: Array<{ logicalPath: string; contentHash: string; contentType: string; bytes: number; }>, ): Promise { const response = await this.http.post<{ uploads: MintStagedFileUpload[]; }>('/api/v2/plays/files/stage/mint', { files }); return response.uploads ?? []; } private async stagePlayFilesViaMultipart( files: Array<{ logicalPath: string; contentBase64: string; contentHash: string; contentType: string; bytes: number; }>, reason: 'mint-unsupported' | 'direct-egress-denied' = 'mint-unsupported', ): Promise { for (const file of files) { if (file.bytes > STAGE_LEGACY_MULTIPART_MAX_BYTES) { const reasonMessage = reason === 'direct-egress-denied' ? "direct storage is blocked by this environment's network egress policy" : 'the connected Deepline server does not support direct-to-storage uploads'; throw new DeeplineError( `Cannot stage ${file.logicalPath} (${formatMegabytes(file.bytes)}): ${reasonMessage}, and this file exceeds the ~4.5MB request-body limit the legacy upload path is subject to.`, 413, 'STAGED_FILE_TOO_LARGE_FOR_LEGACY_UPLOAD', { logicalPath: file.logicalPath, bytes: file.bytes, maxBytes: STAGE_LEGACY_MULTIPART_MAX_BYTES, }, ); } } const buildFormData = () => { const formData = new FormData(); formData.set( 'metadata', JSON.stringify({ files: files.map((file, index) => ({ index, logicalPath: file.logicalPath, contentHash: file.contentHash, contentType: file.contentType, bytes: file.bytes, })), }), ); for (const [index, file] of files.entries()) { const bytes = decodeBase64Bytes(file.contentBase64); const body = bytes.buffer.slice( bytes.byteOffset, bytes.byteOffset + bytes.byteLength, ) as ArrayBuffer; formData.set( `file:${index}`, new Blob([body], { type: file.contentType }), file.logicalPath, ); } return formData; }; const response = await this.http.postFormData<{ files: PlayStagedFileRef[]; }>('/api/v2/plays/files/stage', buildFormData); return response.files; } /** * Resolve staged play files by content hash without uploading bytes. * * Missing files are returned so callers can upload only the files the server * does not already have. */ async resolveStagedPlayFiles( files: Array<{ logicalPath: string; contentHash: string; contentType: string; bytes: number; }>, ): Promise<{ files: PlayStagedFileRef[]; missing: Array<{ logicalPath: string; contentHash: string }>; }> { return this.http.post<{ files: PlayStagedFileRef[]; missing: Array<{ logicalPath: string; contentHash: string }>; }>('/api/v2/plays/files/stage', { files }); } // —————————————————————————————————————————————————————————— // Plays — status and monitoring // —————————————————————————————————————————————————————————— /** * Get the current status of a play execution. * * Internal/advanced primitive. Public callers should usually prefer * {@link runPlay}, {@link PlayJob.get}, or `deepline plays run --watch`. * * @param workflowId - Play-run id from {@link startPlayRun} * @returns Current status with progress logs and partial results * * @example * ```typescript * const status = await client.getPlayStatus('play-abc123'); * console.log(`Status: ${status.status}`); * console.log(`Logs: ${status.progress?.logs.length ?? 0} lines`); * ``` */ async getPlayStatus( workflowId: string, options?: { billing?: boolean; full?: boolean }, ): Promise { const params = new URLSearchParams(); if (options?.billing === false) { params.set('billing', 'false'); } if (options?.full === true) { params.set('full', 'true'); } const query = params.size > 0 ? `?${params.toString()}` : ''; const response = await this.http.get>( `/api/v2/runs/${encodeURIComponent(workflowId)}${query}`, ); return normalizePlayStatus(response); } /** * Stream semantic play-run events using the same SSE feed as the dashboard. * * The server emits a canonical `play.run.snapshot` event first for every * connection, then incremental live events until terminal state or reconnect. */ async *streamPlayRunEvents( workflowId: string, options?: { signal?: AbortSignal; lastEventId?: string; mode?: 'cli' | 'ui'; }, ): AsyncGenerator { const headers = options?.lastEventId && options.lastEventId.trim() ? { 'Last-Event-ID': options.lastEventId.trim() } : undefined; const params = new URLSearchParams(); params.set('mode', options?.mode ?? 'cli'); for await (const event of this.http.streamSse( `/api/v2/runs/${encodeURIComponent(workflowId)}/tail?${params.toString()}`, { signal: options?.signal, headers }, )) { if (event.scope === 'play') { yield event; } } } /** * Cancel a running play execution. * * Sends a stop request for the run. * * @param workflowId - Public Deepline play-run id to cancel * * @example * ```typescript * await client.cancelPlay('play-abc123'); * ``` */ async cancelPlay(workflowId: string): Promise { await this.http.request( `/api/v2/runs/${encodeURIComponent(workflowId)}/stop`, { method: 'POST' }, ); } /** * Stop a running play execution, including open HITL waits. * * @param workflowId - Public Deepline play-run id to stop * @param options.reason - Optional audit/debug reason */ async stopPlay( workflowId: string, options?: { reason?: string }, ): Promise { return this.http.post( `/api/v2/runs/${encodeURIComponent(workflowId)}/stop`, options?.reason ? { reason: options.reason } : {}, ); } /** * List recent runs for a named play. * * Returns runs sorted by start time (newest first), including workflow IDs, * status, timestamps, and metadata. * * @param playName - The play name to query * @returns Array of run summaries (empty array if no runs exist) * * @example * ```typescript * const runs = await client.listPlayRuns('email-waterfall'); * for (const run of runs) { * console.log(`${run.workflowId}: ${run.status} (${run.executionTime})`); * } * ``` */ async listPlayRuns(playName: string): Promise { const encodedName = encodeURIComponent(playName); const response = await this.http.get<{ runs: PlayRunListItem[] }>( `/api/v2/plays/${encodedName}/runs`, ); return response.runs ?? []; } // --------------------------------------------------------------------------- // Legacy workflows (double-shipped). Thin pass-throughs over the live cloud // `/api/v2/workflows/*` API so the SDK CLI keeps existing cloud workflows // working while users migrate them to plays via `workflows transform`. Kept // intentionally minimal — workflows are a deprecated surface. // --------------------------------------------------------------------------- /** List the org's workflows. `GET /api/v2/workflows`. */ async listWorkflows(options?: { limit?: number }): Promise<{ workflows: Array<{ id: string; name: string; status: string; current_published_version: number | null; }>; }> { const params = new URLSearchParams(); if (typeof options?.limit === 'number') { params.set('limit', String(options.limit)); } const query = params.size > 0 ? `?${params.toString()}` : ''; return this.http.get(`/api/v2/workflows${query}`); } /** * Fetch a single workflow (including its published-revision config — the * input to `compileWorkflowConfigToPlay`). `GET /api/v2/workflows/:id`. */ async getWorkflow(id: string): Promise<{ workflow: { id: string; name: string; status: string; current_published_version: number | null; current_published_revision: { version: number; config: unknown; } | null; } | null; validation?: unknown; }> { return this.http.get(`/api/v2/workflows/${encodeURIComponent(id)}`); } /** Delete a workflow. `DELETE /api/v2/workflows/:id`. */ async deleteWorkflow(id: string): Promise { return this.http.delete(`/api/v2/workflows/${encodeURIComponent(id)}`); } /** Turn a workflow off. `POST /api/v2/workflows/:id/disable`. */ async disableWorkflow(id: string): Promise { return this.http.post( `/api/v2/workflows/${encodeURIComponent(id)}/disable`, {}, ); } /** Turn a workflow back on. `POST /api/v2/workflows/:id/enable`. */ async enableWorkflow(id: string): Promise { return this.http.post( `/api/v2/workflows/${encodeURIComponent(id)}/enable`, {}, ); } /** Create/update a workflow from config. `POST /api/v2/workflows/apply`. */ async applyWorkflow(body: Record): Promise { return this.http.post('/api/v2/workflows/apply', body); } /** Validate a workflow config without saving. `POST /api/v2/workflows/lint`. */ async lintWorkflow(body: Record): Promise { return this.http.post('/api/v2/workflows/lint', body); } /** Fetch live workflow request schemas. `GET /api/v2/workflows/schema`. */ async getWorkflowSchema(subject?: string): Promise { const params = new URLSearchParams(); if (subject) params.set('subject', subject); const query = params.size > 0 ? `?${params.toString()}` : ''; return this.http.get(`/api/v2/workflows/schema${query}`); } /** Queue a workflow run. `POST /api/v2/workflows/call`. */ async callWorkflow(body: Record): Promise { return this.http.post('/api/v2/workflows/call', body); } /** List a workflow's runs. `GET /api/v2/workflows/:id/runs`. */ async listWorkflowRuns( id: string, options?: { limit?: number }, ): Promise { const params = new URLSearchParams(); if (typeof options?.limit === 'number') { params.set('limit', String(options.limit)); } const query = params.size > 0 ? `?${params.toString()}` : ''; return this.http.get( `/api/v2/workflows/${encodeURIComponent(id)}/runs${query}`, ); } /** Fetch one workflow run. `GET /api/v2/workflows/:id/runs/:runId`. */ async getWorkflowRun(id: string, runId: string): Promise { return this.http.get( `/api/v2/workflows/${encodeURIComponent(id)}/runs/${encodeURIComponent( runId, )}`, ); } /** Cancel a workflow run. `POST /api/v2/workflows/:id/runs/:runId/cancel`. */ async cancelWorkflowRun(id: string, runId: string): Promise { return this.http.post( `/api/v2/workflows/${encodeURIComponent(id)}/runs/${encodeURIComponent( runId, )}/cancel`, {}, ); } /** * Get a run by id using the public runs resource model. * * This is the SDK equivalent of: * * ```bash * deepline runs get --json * ``` */ async getRunStatus( runId: string, options?: RunsGetOptions, ): Promise { const params = new URLSearchParams(); if (options?.full === true) { params.set('full', 'true'); } const query = params.size > 0 ? `?${params.toString()}` : ''; const response = await this.http.get>( `/api/v2/runs/${encodeURIComponent(runId)}${query}`, ); const status = normalizePlayStatus(response); if (options?.failedLogs !== true || status.status !== 'failed') { return status; } const requestedFailedLogLimit = typeof options.failedLogLimit === 'number' && Number.isFinite(options.failedLogLimit) ? Math.max(1, Math.floor(options.failedLogLimit)) : RUNS_FAILED_LOG_LIMIT; let failedLogs: RunsLogsResult; let failedLogsLoaded = true; try { failedLogs = await this.getRunLogs(runId, { failed: true, limit: Math.min(RUNS_FAILED_LOG_LIMIT, requestedFailedLogLimit), }); } catch (error) { failedLogsLoaded = false; const retryCommand = `deepline runs get ${runId} --log-failed --json`; const reason = error instanceof Error && error.message.trim() ? ` (${error.message.trim().slice(0, 500)})` : ''; failedLogs = { runId, totalCount: 0, returnedCount: 0, firstSequence: null, lastSequence: null, truncated: false, hasMore: false, entries: [], view: 'failed', warning: `Failed logs could not be loaded${reason}. The run status and persisted datasets are still available.`, next: { logs: retryCommand }, }; } return { ...status, failedLogs: { ...failedLogs, view: 'failed' as const, ...(failedLogsLoaded ? { association: failedLogs.association ?? 'terminal_failure_window', } : {}), }, }; } /** * List play runs using the public runs resource model. * * This is the SDK equivalent of: * * ```bash * deepline runs list --play --status failed --json * ``` */ async listRuns(options: RunsListOptions): Promise { const playName = options.play?.trim(); const params = new URLSearchParams(); if (playName) { params.set('play', playName); } const status = options.status?.trim(); if (status) { params.set('status', status); } if (typeof options.limit === 'number' && Number.isFinite(options.limit)) { params.set('limit', String(Math.max(1, Math.floor(options.limit)))); } if (options.offset !== undefined) { if ( !Number.isFinite(options.offset) || !Number.isInteger(options.offset) || options.offset < 0 ) { throw new Error( 'runs.list options.offset must be a non-negative integer.', ); } if (!playName && options.offset > 0) { throw new Error('runs.list options.offset requires options.play.'); } params.set('offset', String(options.offset)); } if (!playName && !status) { throw new Error('runs.list requires options.play or options.status.'); } params.set('compact', 'true'); const response = await this.http.get<{ runs: PlayRunListItem[] }>( `/api/v2/runs?${params.toString()}`, ); return response.runs ?? []; } /** * Observe one run's live events. Uses the Convex Run Snapshot subscription * transport first (ADR-0008), then falls back to the canonical SSE stream * when the subscription transport or its optional client modules are not * available. Pass `fallback: 'none'` to receive * {@link RunObserveTransportUnavailableError} instead. */ async *observeRunEvents( runId: string, options?: { signal?: AbortSignal; onNotice?: (message: string) => void; fallback?: 'sse' | 'none'; }, ): AsyncGenerator { let yieldedObserveEvent = false; try { for await (const event of observeRunEvents({ http: this.http, runId, signal: options?.signal, onNotice: options?.onNotice, }) as AsyncGenerator) { yieldedObserveEvent = true; yield event; } } catch (error) { if ( !(error instanceof RunObserveTransportUnavailableError) || yieldedObserveEvent || options?.fallback === 'none' ) { throw error; } options?.onNotice?.( `[observe] live subscription unavailable (${error.reason}); falling back to SSE tail (support window, ADR-0008)`, ); yield* this.streamPlayRunEventsUntilTerminal(runId, options); } } private async *streamPlayRunEventsUntilTerminal( runId: string, options?: { signal?: AbortSignal; onNotice?: (message: string) => void }, ): AsyncGenerator { const state: PlayLiveStatusState = { runId, status: 'running', logs: [], lastLogSeq: 0, latest: null, }; let lastEventId: string | undefined; let reconnectAttempt = 0; for (;;) { if (options?.signal?.aborted) { return; } const connectedAt = Date.now(); let sawEvent = false; let endedReason = 'stream window ended before a terminal event'; try { for await (const event of this.streamPlayRunEvents(runId, { mode: 'cli', signal: options?.signal, ...(lastEventId ? { lastEventId } : {}), })) { sawEvent = true; if (event.cursor?.trim()) { lastEventId = event.cursor; } yield event; const status = updatePlayLiveStatusState(state, event); if (status && TERMINAL_PLAY_STATUSES.has(status.status)) { return; } } } catch (error) { if (options?.signal?.aborted) { return; } if (!isTransientPlayStreamError(error)) { throw error; } endedReason = error instanceof Error ? error.message : String(error); } let refreshed: PlayStatus | null = null; try { refreshed = await this.getRunStatus(runId); } catch (error) { if (!isTransientPlayStreamError(error)) { throw error; } } if (refreshed && TERMINAL_PLAY_STATUSES.has(refreshed.status)) { yield { cursor: String(Date.now()), streamId: `sse-fallback:${runId}`, scope: 'play', type: 'play.run.status', at: new Date().toISOString(), payload: refreshed as unknown as Record, }; return; } if ( sawEvent || Date.now() - connectedAt >= STREAM_HEALTHY_CONNECTION_MS ) { reconnectAttempt = 0; } const delayMs = streamReconnectDelayMs(reconnectAttempt); reconnectAttempt += 1; options?.onNotice?.( `[observe] SSE tail window ended before terminal status (${endedReason}); reconnecting to run ${runId}`, ); await sleep(delayMs); } } /** * Tail one run through the subscription transport until terminal, then * return one durable REST status read (the final Run Response Package). */ private async tailRunViaObserveTransport( runId: string, options?: RunsTailOptions, ): Promise { const state: PlayLiveStatusState = { runId, status: 'running', logs: [], lastLogSeq: 0, latest: null, }; for await (const event of this.observeRunEvents(runId, { signal: options?.signal, onNotice: options?.onNotice, fallback: 'none', })) { options?.onEvent?.(event); const status = updatePlayLiveStatusState(state, event); if (!status || !TERMINAL_PLAY_STATUSES.has(status.status)) { continue; } return await this.getRunStatus(status.runId || runId).catch( () => state.latest ?? playRunStatusFromState(state), ); } if (options?.signal?.aborted) { throw new DeeplineError('Run observation aborted.', undefined, 'ABORTED'); } // The transport ends only after a terminal snapshot; the differ always // emits a terminal `play.run.status` first, so reaching here means the // terminal package read raced — re-check durable status once, loudly. const refreshed = await this.getRunStatus(runId); if (TERMINAL_PLAY_STATUSES.has(refreshed.status)) { return refreshed; } throw new DeeplineError( `Run observation for ${runId} ended before a terminal status.`, undefined, 'PLAY_LIVE_STREAM_ENDED', ); } /** * Read the canonical run stream until a terminal run status is observed. * * Tries the Convex Run Snapshot subscription transport first (ADR-0008); * when the server cannot serve it (grant endpoint missing/unconfigured or * Convex unreachable) it falls back — with one `onNotice` message — to the * support-window SSE stream below. * * Server stream windows are finite: they end cleanly at the function * ceiling even while the run keeps executing. A window that ends (cleanly * or via transient network error) without a terminal event triggers one * durable-status re-check followed by a backed-off reconnect, so long runs * tail to completion. Abort via `options.signal` to stop waiting. */ async tailRun(runId: string, options?: RunsTailOptions): Promise { try { return await this.tailRunViaObserveTransport(runId, options); } catch (error) { if (!(error instanceof RunObserveTransportUnavailableError)) { throw error; } options?.onNotice?.( `[observe] live subscription unavailable (${error.reason}); falling back to SSE tail (support window, ADR-0008)`, ); } const state: PlayLiveStatusState = { runId, status: 'running', logs: [], lastLogSeq: 0, latest: null, }; let reconnectAttempt = 0; for (;;) { const connectedAt = Date.now(); let sawEvent = false; let endedReason = 'stream window ended before a terminal event'; try { for await (const event of this.streamPlayRunEvents(runId, { mode: 'cli', signal: options?.signal, })) { options?.onEvent?.(event); sawEvent = true; const status = updatePlayLiveStatusState(state, event); if (!status || !TERMINAL_PLAY_STATUSES.has(status.status)) { continue; } return await this.getRunStatus(status.runId || runId).catch( () => state.latest ?? playRunStatusFromState(state), ); } } catch (error) { if (options?.signal?.aborted || !isTransientPlayStreamError(error)) { throw error; } endedReason = error instanceof Error ? error.message : String(error); } // Window ended without a terminal event. The run may have finished // during the gap — re-check durable status once before reconnecting. // Non-transient status failures (e.g. 404 = run gone) fail loudly. let refreshed: PlayStatus | null = null; try { refreshed = await this.getRunStatus(runId); } catch (error) { if (!isTransientPlayStreamError(error)) { throw error; } } if (refreshed && TERMINAL_PLAY_STATUSES.has(refreshed.status)) { return refreshed; } if ( sawEvent || Date.now() - connectedAt >= STREAM_HEALTHY_CONNECTION_MS ) { reconnectAttempt = 0; } const delayMs = streamReconnectDelayMs(reconnectAttempt); reconnectAttempt += 1; options?.onReconnect?.({ attempt: reconnectAttempt, delayMs, reason: endedReason, }); await sleep(delayMs); } } /** Get the exact original input retained for a run. This is intentionally separate from status. */ async getRunInput(runId: string): Promise<{ runId: string; input: Record | unknown[]; bytes: number; sha256: string | null; replayedFromRunId: string | null; }> { return this.http.get(`/api/v2/runs/${encodeURIComponent(runId)}/input`); } /** Start a fresh run from a prior run's retained input and pinned revision. */ async rerun(runId: string): Promise<{ runId: string; replayedFromRunId: string; revisionId: string | null; status: string; next: { inspect: string; input: string }; }> { return this.http.post( `/api/v2/runs/${encodeURIComponent(runId)}/rerun`, {}, ); } /** * Fetch persisted logs for a run using the public runs resource model. * * This is the SDK equivalent of: * * ```bash * deepline runs logs --limit 200 --json * ``` */ async getRunLogs( runId: string, options?: RunsLogsOptions, ): Promise { if (options?.all === true && options.failed === true) { throw new DeeplineError( 'runs.logs cannot combine all and failed views.', undefined, 'INVALID_RUN_LOG_VIEW', ); } if (options?.failed === true) { const requestedLimit = typeof options.limit === 'number' && Number.isFinite(options.limit) && options.limit > 0 ? Math.trunc(options.limit) : RUNS_FAILED_LOG_LIMIT; const failedLimit = Math.min(RUNS_FAILED_LOG_LIMIT, requestedLimit); const page = await this.http.get( `/api/v2/runs/${encodeURIComponent(runId)}/logs?view=failed&limit=${failedLimit}`, ); return { runId: page.runId, totalCount: page.totalLogCount, returnedCount: page.entries.length, firstSequence: page.firstSeq, lastSequence: page.lastSeq, // The failed view is a complete designed window, not a pageable slice. // `truncated` means retention loss here; association/warning explain it. truncated: page.logsTruncated === true, hasMore: false, entries: page.entries.map((entry) => entry.line), view: page.view ?? 'failed', association: page.association ?? 'terminal_failure_window', ...(page.warning ? { warning: page.warning } : {}), ...(page.next ? { next: page.next } : {}), ...(page.logsTruncated ? { logsTruncated: true } : {}), }; } const limit = options?.all ? Number.MAX_SAFE_INTEGER : typeof options?.limit === 'number' && Number.isFinite(options.limit) && options.limit > 0 ? Math.trunc(options.limit) : 200; const fetchPage = (afterSeq: number, pageLimit: number) => this.http.get( `/api/v2/runs/${encodeURIComponent(runId)}/logs?afterSeq=${afterSeq}&limit=${pageLimit}`, ); // Probe for the run's stored extent, then read the LAST `limit` stored // lines (matching the historical tail-slice semantics), paginating in // server-capped pages until the window is exhausted. const probe = await fetchPage(0, 1); const lastStoredSeq = probe.lastStoredSeq; let afterSeq = options?.all ? 0 : Math.max(0, lastStoredSeq - limit); const entries: Array<{ seq: number; line: string }> = []; while (entries.length < limit) { const page = await fetchPage( afterSeq, Math.min(RUN_LOGS_PAGE_LIMIT, limit - entries.length), ); if (page.entries.length === 0) { break; } entries.push(...page.entries); afterSeq = page.entries[page.entries.length - 1]!.seq; if (!page.hasMore) { break; } } const firstSequence = entries.length > 0 ? entries[0]!.seq : null; const lastSequence = entries.length > 0 ? entries[entries.length - 1]!.seq : null; return { runId: probe.runId, totalCount: probe.totalLogCount, returnedCount: entries.length, firstSequence, lastSequence, truncated: entries.length < probe.totalLogCount, hasMore: lastSequence !== null && lastSequence < lastStoredSeq, entries: entries.map((entry) => entry.line), view: options?.all ? 'all' : 'tail', ...(probe.logsTruncated ? { logsTruncated: true } : {}), }; } /** * Export persisted runtime-sheet rows for a play dataset/table namespace. * * This is the SDK form of exporting `ctx.dataset(...).run()` output for a * specific play and optional run id. */ async getPlaySheetRows(input: { playName: string; tableNamespace: string; runId?: string; limit?: number; offset?: number; rowMode?: 'output' | 'all'; }): Promise { const params = new URLSearchParams({ tableNamespace: input.tableNamespace, limit: String(input.limit ?? 5000), offset: String(input.offset ?? 0), }); if (input.runId?.trim()) { params.set('runId', input.runId.trim()); } if (input.rowMode === 'all') { params.set('rowMode', 'all'); } const result = await this.http.get( `/api/v2/plays/${encodeURIComponent(input.playName)}/sheet?${params.toString()}`, ); const requestedRunId = input.runId?.trim() || ''; if (requestedRunId) { const confirmedRunId = result.scope?.kind === 'run' ? result.scope.runId.trim() : ''; const foreignRowRunIds = [ ...new Set( result.rows .map((row) => typeof row.runId === 'string' ? row.runId.trim() : '', ) .filter((runId) => runId && runId !== requestedRunId), ), ]; if ( result.scope?.kind !== 'run' || confirmedRunId !== requestedRunId || foreignRowRunIds.length > 0 ) { throw new DeeplineError( `Run-scoped dataset export was not confirmed for ${requestedRunId}; no rows were returned to the caller. Update Deepline, then retry the same export command. Do not rerun the play.`, undefined, 'RUN_EXPORT_SCOPE_MISMATCH', { requestedRunId, confirmedScope: result.scope ?? null, foreignRowRunIds, playName: input.playName, tableNamespace: input.tableNamespace, }, ); } } return result; } /** * Stop a run by id using the public runs resource model. * * This is the SDK equivalent of: * * ```bash * deepline runs stop --reason "stale lock" --json * ``` */ async stopRun( runId: string, options?: { reason?: string }, ): Promise { return this.http.post( `/api/v2/runs/${encodeURIComponent(runId)}/stop`, options?.reason ? { reason: options.reason } : {}, ); } /** * Stop every active run visible to the current workspace. * * This is the SDK equivalent of: * * ```bash * deepline runs stop-all --reason "stale lock" --json * ``` * * Use this when a failed parent run left child or waiting runs active and you * need to clear the workspace run-slot state without knowing each run id. */ async stopAllRuns(options?: { reason?: string; }): Promise { return this.http.post( '/api/v2/runs/stop-all', options?.reason ? { reason: options.reason } : {}, ); } /** * List callable plays visible to the workspace. * * Pass `origin: "prebuilt"` for Deepline-managed prebuilts or * `origin: "owned"` for org-owned plays. */ async listPlays(options?: { origin?: 'prebuilt' | 'owned'; grep?: string; grepMode?: 'all' | 'any' | 'phrase'; categories?: string | string[]; includeToolCategories?: boolean; includeArchived?: boolean; }): Promise { const params = new URLSearchParams(); if (options?.origin) params.set('origin', options.origin); if (options?.categories) { params.set( 'categories', Array.isArray(options.categories) ? options.categories.join(',') : options.categories, ); } if (options?.categories || options?.includeToolCategories) { params.set('include_tool_categories', '1'); } if (options?.includeArchived) params.set('include_archived', '1'); if (options?.grep?.trim()) { params.set('grep', options.grep.trim()); params.set('grep_mode', options.grepMode ?? 'all'); params.set('limit', '60'); } const suffix = params.toString() ? `?${params.toString()}` : ''; const response = await this.http.get<{ plays: PlayListItem[] }>( `/api/v2/plays${suffix}`, ); return response.plays ?? []; } /** Set whether an org-owned Play sorts before unpinned Plays. */ async setPlayPinned( playName: string, pinned: boolean, ): Promise<{ name: string; pinned: boolean }> { return this.http.post(`/api/v2/plays/${encodeURIComponent(playName)}/pin`, { pinned, }); } /** Read product-notification destinations, subscriptions, event catalog, and DLQ health. */ async getNotificationSettings(): Promise { return this.http.get('/api/v2/settings/notifications'); } /** Start the Slack OAuth flow required by product notifications. */ async connectNotificationSlack(options?: { successUrl?: string; failureUrl?: string; }): Promise<{ ok: boolean; redirect_url: string }> { return this.http.post('/api/v2/integrations/connect', { provider: 'slack', scopes: [...PRODUCT_NOTIFICATION_SLACK_OAUTH_SCOPES], ...(options?.successUrl ? { success_url: options.successUrl } : {}), ...(options?.failureUrl ? { failure_url: options.failureUrl } : {}), }); } /** List Slack channels visible to the connected Deepline Slack app. */ async listNotificationSlackChannels(query?: string): Promise<{ identity: { teamId: string; teamName?: string }; channels: Array<{ id: string; name: string; isPrivate: boolean }>; }> { const suffix = query ? `?query=${encodeURIComponent(query)}` : ''; return this.http.get(`/api/v2/settings/notifications/channels${suffix}`); } /** Select a Slack channel or direct member used for product notifications. */ async setNotificationSlack(destination: string | { memberId: string }) { return this.http.put( '/api/v2/settings/notifications', typeof destination === 'string' ? { channel: destination } : destination, ); } /** Send one synchronous test ping and return Slack's delivery result. */ async testNotificationSlack(): Promise<{ ok: boolean; deliveryId: string; state: string; message: string; }> { return this.http.post('/api/v2/settings/notifications/test', {}); } /** Disable Slack product notifications without deleting the OAuth connection. */ async disableNotificationSlack() { return this.http.delete('/api/v2/settings/notifications'); } /** Enable or disable event IDs from the server-provided notification catalog. */ async setNotificationSubscriptions(eventTypes: string[], enabled: boolean) { return this.http.patch('/api/v2/settings/notifications/subscriptions', { eventTypes, enabled, }); } /** List exhausted deliveries. Dead-lettered messages never replay automatically. */ async listNotificationDlq(limit = 25) { return this.http.get( `/api/v2/settings/notifications/dlq?limit=${encodeURIComponent(String(limit))}`, ); } /** Inspect one exhausted notification delivery. */ async getNotificationDlqDelivery(deliveryId: string) { return this.http.get( `/api/v2/settings/notifications/dlq/${encodeURIComponent(deliveryId)}`, ); } /** Explicitly retry or archive one dead-lettered notification delivery. */ async updateNotificationDlqDelivery( deliveryId: string, action: 'retry' | 'archive', ) { return this.http.post( `/api/v2/settings/notifications/dlq/${encodeURIComponent(deliveryId)}`, { action }, ); } /** List the workspace's named notification rules. */ async getNotifications(): Promise { return this.http.get('/api/v2/notifications'); } /** List Slack channels available to an already-connected Slack integration. */ async listNotificationChannels(query?: string): Promise<{ identity: { teamId: string; teamName?: string }; channels: Array<{ id: string; name: string; isPrivate: boolean }>; }> { const suffix = query ? `?search=${encodeURIComponent(query)}` : ''; return this.http.get(`/api/v2/notifications/slack/channels${suffix}`); } /** Create a named notification routed through an existing provider integration. */ async createNotification(input: CreateNotificationInput) { return this.http.post('/api/v2/notifications', input); } /** Update a notification's target, event selection, or enabled state. */ async updateNotification( notificationId: string, input: UpdateNotificationInput, ) { return this.http.patch( `/api/v2/notifications/${encodeURIComponent(notificationId)}`, input, ); } /** Send a validation ping to one notification. */ async testNotification(notificationId: string): Promise<{ ok: boolean; deliveryId: string; state: string; message: string; }> { return this.http.post( `/api/v2/notifications/${encodeURIComponent(notificationId)}/test`, {}, ); } /** Archive one notification without touching its provider integration. */ async deleteNotification( notificationId: string, ): Promise<{ deleted: boolean; id: string }> { return this.http.delete( `/api/v2/notifications/${encodeURIComponent(notificationId)}`, ); } /** * Search callable plays and return compact play descriptions. * * Prebuilt plays are preferred by default because they have maintained * contracts and stable run behavior. */ async searchPlays(options: { query: string; compact?: boolean; scope?: 'prebuilt' | 'owned' | 'all'; }): Promise { const params = new URLSearchParams(); params.set('search', options.query.trim()); const scope = options.scope ?? 'prebuilt'; if (scope !== 'all') { params.set('origin', scope); } const response = await this.http.get<{ plays: PlayListItem[] }>( `/api/v2/plays?${params.toString()}`, ); const plays = (response.plays ?? []).map((play) => this.summarizePlayListItem(play, options), ); if (scope === 'prebuilt') { return plays.filter(isPrebuiltPlayDescription); } if (scope === 'owned') { return plays.filter((play) => !isPrebuiltPlayDescription(play)); } return preferPrebuiltPlayDescriptions(plays); } /** * Get the full definition and state of a named play. * * Returns the play's revision state (draft, live), recent runs, * sheet processing summary, and database URL. * * @param name - Play name * @returns Complete play detail * * @example * ```typescript * const detail = await client.getPlay('email-waterfall'); * console.log(`Live: v${detail.play.currentPublishedVersion}`); * console.log(`Draft dirty: ${detail.play.isDraftDirty}`); * console.log(`Total runs: ${detail.play.runCount}`); * ``` */ async getPlay( name: string, options?: { source?: 'working' | 'live' | `version:${number}` }, ): Promise { const encodedName = encodeURIComponent(name); const query = options?.source ? `?include=source&revision=${encodeURIComponent(options.source)}` : ''; return this.http.get(`/api/v2/plays/${encodedName}${query}`); } /** * Get a normalized play description suitable for agents and CLIs. * * The description includes runnable examples, input/output summaries, clone * guidance, revision state, and latest run metadata when available. */ async describePlay( name: string, options?: { compact?: boolean }, ): Promise { const detail = await this.getPlay(name); return this.summarizePlayDetail(detail, options); } /** * Clear run history and durable sheet/result data for a play without deleting * the play definition or revisions. */ async clearPlayHistory( name: string, request: ClearPlayHistoryRequest = {}, ): Promise { const encodedName = encodeURIComponent(name); return this.http.post( `/api/v2/plays/${encodedName}/history/clear`, request, ); } /** * List saved versions for a named play. * * Returns immutable revision snapshots newest-first, including the revision * id needed for exact-version runs and live-version switching. * * @param name - Play name * @returns Version list (newest first) */ async listPlayVersions( name: string, options?: { full?: boolean }, ): Promise { const encodedName = encodeURIComponent(name); const suffix = options?.full ? '?full=true' : ''; const response = await this.http.get<{ versions: PlayRevisionSummary[] }>( `/api/v2/plays/${encodedName}/versions${suffix}`, ); return response.versions ?? []; } /** * Make a play revision live. * * When `revisionId` is omitted, the current working revision becomes live. * The live version is what executes when the play is run by name without * specifying an explicit revision. * * @param name - Play name * @param request - Optional explicit revision to make live * @returns Result with the new live version number * * @example * ```typescript * const result = await client.publishPlayVersion('email-waterfall'); * if (result.success) { * console.log(`Live v${result.liveVersion}`); * } * ``` */ async publishPlayVersion( name: string, request: PublishPlayVersionRequest = {}, ): Promise { const encodedName = encodeURIComponent(name); return this.http.post( `/api/v2/plays/${encodedName}/live`, request, ); } /** * Move an org-owned play to Trash. This disables its active triggers while * retaining its revisions and run history so it can be restored. Deepline * prebuilt plays are read-only. */ async deletePlay(name: string): Promise { const encodedName = encodeURIComponent(name); return this.http.delete(`/api/v2/plays/${encodedName}`); } /** Restore an org-owned play that was previously moved to Trash. */ async restorePlay(name: string): Promise { const encodedName = encodeURIComponent(name); return this.http.post( `/api/v2/plays/${encodedName}/restore`, {}, ); } // —————————————————————————————————————————————————————————— // Plays — public share pages // —————————————————————————————————————————————————————————— /** * Current share status for a play: the public page (if any), the published * copy, and the revision picker. Read-only. */ async getSharePage( name: string, options: { revisionId?: string } = {}, ): Promise { const encodedName = encodeURIComponent(name); const revisionQuery = options.revisionId ? `?revisionId=${encodeURIComponent(options.revisionId)}` : ''; return this.http.get( `/api/v2/plays/${encodedName}/share${revisionQuery}`, ); } /** * Publish (or repoint) the play's public share page to a revision. Requires * `acknowledgedUnlisted: true` — the page is publicly viewable. Org-admin only. */ async publishSharePage( name: string, request: PublishSharePageRequest, ): Promise { const encodedName = encodeURIComponent(name); return this.http.post( `/api/v2/plays/${encodedName}/share`, request, ); } /** * Update share-page settings (SEO indexing, credit-cost / latency display) * without moving the published pointer. Org-admin only. */ async updateSharePage( name: string, request: UpdateSharePageRequest, ): Promise { const encodedName = encodeURIComponent(name); return this.http.patch( `/api/v2/plays/${encodedName}/share`, request, ); } /** * Unshare: hard-delete the play's public page and its cards. Returns the * fresh status (now `share: null`). Org-admin only. Idempotent — a no-op when * the play was never published. */ async unpublishSharePage(name: string): Promise { const encodedName = encodeURIComponent(name); return this.http.delete( `/api/v2/plays/${encodedName}/share`, ); } /** * Regenerate the LLM landing-page copy for a revision (defaults to the * published one). Org-admin only. */ async regenerateSharePage( name: string, request: { revisionId?: string } = {}, ): Promise { const encodedName = encodeURIComponent(name); return this.http.post( `/api/v2/plays/${encodedName}/share/regenerate`, request, ); } // —————————————————————————————————————————————————————————— // Plays — high-level orchestration // —————————————————————————————————————————————————————————— /** * Run a play end-to-end: submit, stream until terminal, return result. * * This is the highest-level play execution method. It submits the play, * reads the canonical run stream for status updates, and returns a structured * result with logs and timing. Supports cancellation via `AbortSignal`. * * @param code - Source string fallback; pass the bundled artifact in `options.artifact` * @param csvPath - Input CSV path, or `null` * @param name - Play name * @param options - Execution options * @returns Final execution result with success/failure, output, logs, and duration * * @example * ```typescript * const result = await client.runPlay(bundledCode, null, 'my-play', { * input: { domain: 'stripe.com' }, * onProgress: (status) => { * const logs = status.progress?.logs ?? []; * console.log(`[${status.status}] ${logs.length} log lines`); * }, * }); * * if (result.success) { * console.log('Output:', result.result); * } else { * console.error(`Failed after ${result.durationMs}ms:`, result.error); * } * ``` * * @example Cancellation * ```typescript * const controller = new AbortController(); * setTimeout(() => controller.abort(), 30_000); // 30s timeout * * const result = await client.runPlay(code, null, 'slow-play', { * signal: controller.signal, * }); * // result.success === false, result.error === 'Cancelled by user' * ``` */ async runPlay( code: string, csvPath: string | null, name?: string, options?: { /** Called for each status snapshot emitted by the run stream. */ onProgress?: (status: PlayStatus) => void; /** Abort signal — triggers cancellation and immediate return. */ signal?: AbortSignal; /** Runtime input for the play function. */ input?: Record; sourceCode?: string; artifact?: Record; compilerManifest?: PlayCompilerManifest; inputFile?: PlayStagedFileRef | null; packagedFiles?: PlayStagedFileRef[]; /** Compatibility flag; active sibling runs are allowed. */ force?: boolean; /** Explicit cache-bypass flag for durable dataset and tool-call reuse. */ forceToolRefresh?: boolean; }, ): Promise { const { workflowId } = await this.submitPlay(code, csvPath, name, { input: options?.input, sourceCode: options?.sourceCode, artifact: options?.artifact, compilerManifest: options?.compilerManifest, inputFile: options?.inputFile, packagedFiles: options?.packagedFiles, force: options?.force, forceToolRefresh: options?.forceToolRefresh, }); const start = Date.now(); const state: PlayLiveStatusState = { runId: workflowId, status: 'running', logs: [], lastLogSeq: 0, latest: null, }; if (options?.signal?.aborted) { await this.cancelPlay(workflowId); return { success: false, runId: workflowId, logs: [], durationMs: Date.now() - start, error: 'Cancelled by user', }; } for await (const event of this.streamPlayRunEvents(workflowId, { mode: 'cli', signal: options?.signal, })) { if (options?.signal?.aborted) { await this.cancelPlay(workflowId); return { success: false, runId: workflowId, logs: state.logs, durationMs: Date.now() - start, error: 'Cancelled by user', }; } const status = updatePlayLiveStatusState(state, event); if (!status) { continue; } options?.onProgress?.(status); if (TERMINAL_PLAY_STATUSES.has(status.status)) { const finalStatus = await this.getPlayStatus( status.runId || workflowId, ).catch(() => status); return playRunResultFromStatus(finalStatus, start, workflowId); } } throw new DeeplineError( `Run stream for ${workflowId} ended before the run reached a terminal state.`, undefined, 'PLAY_RUN_STREAM_ENDED', { runId: workflowId, workflowId }, ); } // —————————————————————————————————————————————————————————— // Health // —————————————————————————————————————————————————————————— /** * Published plans plus the caller's active plan: prices, monthly grant * credits, rollover policy, and which plans are open for subscription. * Prefer `client.billing.plans()`. * * @returns Snake_case catalog from `GET /api/v2/billing/catalog/current` */ async getBillingPlans(): Promise { return this.http.get('/api/v2/billing/catalog/current'); } /** * Charge the saved payment method and add Deepline credits to the active * workspace. Prefer `client.billing.topUp(...)`. * * @throws {@link DeeplineError} with `statusCode: 409` when checkout is * required because the workspace has no saved payment method or the card * requires confirmation. */ async topUpBillingBalance(options: { credits: number; idempotencyKey?: string; }): Promise { return this.http.post( '/api/v2/billing/top-up', { credits: options.credits, ...(options.idempotencyKey ? { idempotency_key: options.idempotencyKey } : {}), }, undefined, // The idempotency key makes a retry at the product layer safe, but the // CLI should not hide ambiguous local delivery failures behind implicit // transport retries for a payment mutation. { maxRetries: 0, exactUrlOnly: true }, ); } /** * Subscription state for the active workspace: active plan, whether a * Stripe subscription backs it, renewal/cancellation facts, and remaining * Deepline credit pools. Prefer `client.billing.subscription.status()`. * * @returns Snake_case subscription status from `GET /api/v2/billing/subscription/status` */ async getBillingSubscriptionStatus(): Promise { return this.http.get( '/api/v2/billing/subscription/status', ); } /** * Schedule subscription cancellation at period end, or reverse a pending * cancellation with `{ undo: true }`. The customer keeps the cycle they * paid for and every remaining credit — cancellation never claws back * credits. Prefer `client.billing.subscription.cancel(...)`. * * @throws {@link DeeplineError} with `statusCode: 409` when the workspace * has no active subscription, and `statusCode: 502` when Stripe rejects * the update (the server message is preserved). */ async cancelBillingSubscription(options?: { undo?: boolean; }): Promise { return this.http.post( '/api/v2/billing/subscription/cancel', { action: options?.undo ? 'undo_cancel' : 'cancel' }, ); } /** * Customer-facing billing history: subscription invoices plus one-time * credit purchase receipts, newest first, with Stripe-hosted links. * Prefer `client.billing.invoices.list(...)`. * * @param options.limit - Maximum entries to return (server clamps to 1–100, default 24). */ async listBillingInvoices(options?: { limit?: number; }): Promise { const params = new URLSearchParams(); if (options?.limit !== undefined) { params.set('limit', String(options.limit)); } const suffix = Array.from(params).length > 0 ? `?${params.toString()}` : ''; return this.http.get( `/api/v2/billing/invoices${suffix}`, ); } /** List the reviewed target plans and whether new acquisition is enabled. */ async getTargetBillingPlans(): Promise { return this.http.get('/api/v2/billing/plans'); } /** Read the workspace's normalized target plan, payment, and balance state. */ async getTargetBillingStatus(): Promise { return this.http.get('/api/v2/billing/status'); } /** * Purchase target-billing credits through the durable commercial operation * flow. The caller supplies an idempotency key for safe retries. */ async purchaseTargetBillingCredits(options: { credits: number; idempotencyKey: string; }): Promise { const idempotencyKey = requireTargetBillingIdempotencyKey( options.idempotencyKey, ); return this.http.post( '/api/v2/billing/credit-purchases', { credits: options.credits }, { 'Idempotency-Key': idempotencyKey }, { maxRetries: 0, exactUrlOnly: true }, ); } /** * Start, change, cancel, or restore a target plan through one idempotent * commercial operation. */ async transitionTargetBillingPlan( options: TargetBillingPlanTransitionOptions, ): Promise { const idempotencyKey = requireTargetBillingIdempotencyKey( options.idempotencyKey, ); return this.http.post( '/api/v2/billing/plan-transitions', { action: options.action, ...(options.targetPlanSku ? { target_plan_sku: options.targetPlanSku } : {}), }, { 'Idempotency-Key': idempotencyKey }, { maxRetries: 0, exactUrlOnly: true }, ); } /** Create a Stripe-hosted portal session for payment recovery and invoices. */ async createTargetBillingPortalSession(): Promise<{ url: string }> { const response = await this.http.post<{ data: { url: string }; request_id?: string; }>('/api/v2/billing/portal-sessions', {}); return response.data; } // —————————————————————————————————————————————————————————— // Monitors // —————————————————————————————————————————————————————————— /** * Whether the current workspace can use Deepline Monitors. Reachable without * monitor access; a denial is a normal 200 body, not a 403. Prefer * `client.monitors.status()`. */ async getMonitorsAccess(): Promise { // The endpoint answers 200 in both the granted and denied cases. const payload = await this.http.request( '/api/v2/monitors/access', { method: 'GET' }, ); return { has_access: payload.has_access === true, ...(typeof payload.reason === 'string' && payload.reason.trim() ? { reason: payload.reason.trim() } : {}), }; } /** * The deployable monitor tools catalog. Pass a tool id (positional or * `{ tool }`) to describe one tool's full payload/stream contract, or no tool * id for the compact inventory. Prefer `client.monitors.available(...)`. */ async getMonitorsAvailable( toolIdOrOptions?: string | (MonitorsAvailableOptions & { tool?: string }), maybeOptions?: MonitorsAvailableOptions, ): Promise { const positionalTool = typeof toolIdOrOptions === 'string' ? toolIdOrOptions : undefined; // The first argument is either the tool id (string) or an options object. // When it is a string (or omitted), `maybeOptions` carries the options; // when it is an options object, use it directly. const options = toolIdOrOptions && typeof toolIdOrOptions === 'object' ? toolIdOrOptions : (maybeOptions ?? {}); const optionTool = toolIdOrOptions && typeof toolIdOrOptions === 'object' ? toolIdOrOptions.tool : undefined; const tool = positionalTool ?? optionTool; const params = new URLSearchParams(); if (options.provider) params.set('provider', options.provider); if (tool) params.set('tool', tool); if (options.search) params.set('search', options.search); if (options.limit !== undefined) params.set('limit', String(options.limit)); // List mode is compact by default (id + name + deployed_count). `full` // restores the heavy catalog; `compact` stays an explicit alias. Describing // a single tool always returns the full contract, so this is skipped there. const compactList = !tool && options.full !== true; if (compactList || options.compact) params.set('compact', 'true'); const query = params.toString(); // Precompute the query suffix: a nested template literal in the request // path (`...tools${query ? `?...` : ''}`) breaks the SDK/API contract // checker's backtick path extraction. Keep the path a single interpolation. const suffix = query ? `?${query}` : ''; return this.http.request( `/api/v2/monitors/tools${suffix}`, { method: 'GET' }, ); } /** Validate a monitor definition without deploying it. Prefer `client.monitors.check(...)`. */ async checkMonitor( definition: MonitorDefinition, ): Promise { return this.http.request('/api/v2/monitors/check', { method: 'POST', body: definition, }); } /** * Deploy a monitor from a definition. `dryRun` validates via the check * endpoint and returns the plan without deploying. Prefer * `client.monitors.deploy(...)`. */ async deployMonitor( definition: MonitorDefinition, options?: { dryRun?: boolean }, ): Promise { if (options?.dryRun) { // The deploy plan is served by the CHECK endpoint. No deploy call is ever // made in dry-run mode. return this.http.request('/api/v2/monitors/check', { method: 'POST', body: definition, }); } const deployed = await this.http.request( '/api/v2/monitors/deploy', { method: 'POST', body: definition, // A provider can reject a create with a 429 after Deepline has begun // the lifecycle request. Do not replay a mutation or hide the server's // monitor-specific recovery guidance behind a generic transport retry. maxRetries: 0, exactUrlOnly: true, preserveRateLimitResponse: true, }, ); if (definition.tool !== 'deepline.analytics') return deployed; // Analytics remains an ordinary monitor deploy. Its provider-specific // post-deploy work creates/reuses the tracker and returns the artifact a // caller needs to install it; there is intentionally no separate setup // command in the public monitor lifecycle. const setup = await this.setupMonitor( definition.tool, definition.payload ?? {}, ); return { ...deployed, monitor: { ...(deployed.monitor && typeof deployed.monitor === 'object' ? deployed.monitor : {}), tracking: setup.tracking ?? null, ip2company: setup.ip2company ?? null, }, }; } /** List deployed monitors. Prefer `client.monitors.list(...)`. */ async listMonitors( options?: MonitorsListOptions, ): Promise { const params = new URLSearchParams(); if (options?.status) params.set('status', options.status); if (options?.limit !== undefined) params.set('limit', String(options.limit)); if (options?.cursor) params.set('cursor', options.cursor); if (options?.compact) params.set('compact', 'true'); if (options?.includeConsumers) params.set('include_consumers', 'true'); const query = params.toString(); // Single interpolation only — see availableMonitors: a nested template in // the path confuses the SDK/API contract path extractor. const suffix = query ? `?${query}` : ''; return this.http.request( `/api/v2/monitors/deployed${suffix}`, { method: 'GET' }, ); } /** Fetch one deployed monitor by public key. Prefer `client.monitors.get(...)`. */ async getMonitor(key: string): Promise { return this.http.request( `/api/v2/monitors/deployed/${encodeURIComponent(key)}`, { method: 'GET' }, ); } async testMonitorWebhook( key: string, payload: Record, options?: { validationOnly?: boolean; dispatch?: boolean }, ): Promise { return this.http.request( `/api/v2/monitors/deployed/${encodeURIComponent(key)}/test`, { method: 'POST', body: { payload, ...(options?.validationOnly ? { mode: 'validation_only' } : options?.dispatch ? { mode: 'dispatch' } : {}), }, }, ); } async setupMonitor( tool: string, payload: Record, ): Promise> { return this.http.request>( `/api/v2/monitors/setup/${encodeURIComponent(tool)}`, { method: 'POST', body: payload }, ); } async validateMonitor(key: string): Promise { return this.http.request( `/api/v2/monitors/deployed/${encodeURIComponent(key)}/validate`, { method: 'POST', body: {} }, ); } /** Published plays depending on one monitor. Prefer `client.monitors.dependents(...)`. */ async getMonitorDependents(key: string): Promise { return this.http.request( `/api/v2/monitors/deployed/${encodeURIComponent(key)}/dependents`, { method: 'GET' }, ); } /** Update a deployed monitor by public key. Prefer `client.monitors.update(...)`. */ async updateMonitor( key: string, patch: Record, ): Promise { return this.http.request( `/api/v2/monitors/deployed/${encodeURIComponent(key)}`, { method: 'PATCH', body: patch }, ); } /** Delete a deployed monitor and its upstream provider resource. `dryRun` returns the delete plan. Prefer `client.monitors.delete(...)`. */ async deleteMonitor( key: string, options?: { dryRun?: boolean }, ): Promise { // The public type intentionally excludes `localOnly`, but JavaScript // callers (and compiled older SDK clients) can still pass it at runtime. // Never silently reinterpret an old local-only request as an upstream // deletion. The server applies the same guard for direct API callers. if ((options as { localOnly?: unknown } | undefined)?.localOnly === true) { throw new DeeplineError( 'localOnly monitor deletion is no longer supported. Monitor deletion always deprovisions the upstream provider resource.', undefined, 'MONITOR_LOCAL_ONLY_DELETE_NOT_SUPPORTED', ); } const params = new URLSearchParams(); if (options?.dryRun) params.set('dry_run', 'true'); const query = params.toString(); return this.http.request( `/api/v2/monitors/deployed/${encodeURIComponent(key)}${query ? `?${query}` : ''}`, { method: 'DELETE' }, ); } /** * Reactivate a disabled monitor. `dryRun` returns the reactivation cost. * Prefer `client.monitors.reactivate(...)`. */ async reactivateMonitor( key: string, options?: { dryRun?: boolean }, ): Promise { const query = options?.dryRun ? '?dry_run=true' : ''; return this.http.request( `/api/v2/monitors/deployed/${encodeURIComponent(key)}/reactivate${query}`, { method: 'POST', body: {} }, ); } /** * Check API connectivity and server health. * * @returns Health status with API version * * @example * ```typescript * const health = await client.health(); * console.log(`API: ${health.status} (${health.version})`); * // { status: "ok", version: "v2" } * ``` */ async health(): Promise<{ status: string; version?: string; status_banner?: { message: string; updatedAt: number; }; }> { return this.http.get<{ status: string; version?: string; status_banner?: { message: string; updatedAt: number; }; }>('/api/v2/health'); } } export type { PlayRunListItem, PlayStatus } from './types.js';